中华万年历客户端埋点方案解析

前言

孔雀开屏

孔雀系统SDK是我们客户端埋点SDK的前身。在最开始设计时,孔雀系统主要是为应用提供各种样式的广告展示及广告的view、click等事件的统计。广告样式包括:开屏广告、提醒弹窗、回退弹窗广告、通用广告等,和现在第三方的广告SDK类似(banner、插屏、开屏、原生等),直接返回广告view,客户端负责展示就行。

后随着业务发展,为满足各APP自定义广告样式,逐渐剔除直接返回广告view的形式,而返回满足相应条件的广告数据,客户端自行解析数据展示广告。其优点:

  • 后台数据模板配置方便,易于扩展
  • 投放条件多样,便于控制(有效期、渠道、城市、分版本、分应用投放等)
  • 业务逻辑SDK内部实现,业务无需关心

除上述广告业务的投放和拉取外,孔雀系统还负责数据的统计上报,最初只是简单的广告view、click、start、loading等次数统计,后逐渐演进增加活跃上报、基于事件的日志报文、自定义事件上报等多种形式的数据上报。为了更好的数据驱动业务,数据的全面性和正确性格外重要,这就驱使着统计业务的不断迭代和优化。下面从几个方面介绍埋点统计项目的实践和演进。

概念

首页下面几个问题需要了解下:

埋点是什么?埋哪些点?埋点的形式都有哪些?有哪些区别和优缺点?


埋点,是对网页、APP或后台等应用程序进行数据采集的一种行为。通过埋点,可以采集一些客户端数据,用于分析和优化产品的体验,也可以为产品运营提供数据支撑。其中比较常见的指标比如PV、UV、DAU、时长、新增、页面点击等。

采集数据时,一般会在APP中添加相应代码,当达到某种条件时,触发上报(这儿由于策略原因,可能是非实时上传到服务器的)。而这个“添加代码”的过程我们称之为“埋点”。一般埋点分为三种形式:

  1. 代码埋点:是指在某个事件发生时调用数据收集接口进行数据上报。 例如研发按照产品/运营的需求或埋点文档,在Web页面或App的源码里添加行为上报的代码,当满足某一个条件时,这些代码就会被执行,向服务器上报数据。这种方案是最基础的方案,每次增加或者修改数据上报的条件,都需要开发人员的参与,并且只能在下一个版本上线后才能看到效果。基本上所有的数据平台都提供了这类数据上报的SDK,将行为上报的后台服务器接口封装成了简单的客户端SDK接口。开发者可以通过嵌入这类SDK,在埋点的地方调用少量的代码就可以上报行为数据。

  2. 全埋点:又叫无埋点,指的是将网页或App内产生的所有的、满足某个条件的行为,全部上报到后台服务器。 例如把一个App中所有的按钮点击都进行上报,然后由产品或运营去后台筛选所需要的行为数据。这种方案的优点非常明显,就是在新增或修改行为上报条件时,不用再找开发人员去修改埋点的代码。然而它的缺点也和优点一样明显,那就是上报的数据量比代码埋点大很多,里面可能很多是没有价值的数据。此外,这种方案更倾向于独立去看待用户的行为,而没有关注行为的上下文,给数据分析带来了一些难度。很多公司也提供了这类功能的SDK,通过静态或者动态的方式, “Hook”了原有的App代码 ,从而实现了行为的监测,在数据上报时通常是采用累积多条再上报的方案来合并请求。

  3. 可视化埋点:是指通过可视化工具配置采集节点,在App或网页解析配置查找节点,监听节点产生的事件并上报。 例如产品在Web页面/App的界面上进行圈选,配置需要监测界面上哪一个元素,然后保存这个配置,当App启动时会从后台服务器获得产品/运营预先圈选好的配置,然后根据这份配置查找并监测App界面上的元素,当某一个元素满足条件时,就会上报行为数据到后台服务器。有了暴力的全埋点技术方案,很容易联想到按需埋点,可视化埋点就是一种按需配置埋点的方案。现在也有一些公司提供了这类SDK,圈选监测元素时,有的是提供一个Web管理界面,手机在安装并初始化了SDK之后,可以和管理界面连接,让用户在Web管理界面上配置需要监测的元素,有的是直接让用户在手机上圈选元素进行埋点。

优缺点
各种埋点方式都有其使用的场景和优缺点。

现在已经有多家SDK支持上述埋点方式中的一种或全部,如Mixpanel、Sensorsdata、TalkingData、GrowingIO、Umeng Analytics等,其中MixpanelSensorsdata都已开源。这样我们在封装自己的埋点SDK时可以参考其比较好的解决方案及了解相关实现原理。

数据采集的过程

上面是一些埋点的概念和方式,而一个典型的数据平台,对于数据的处理一般包括以下几个步骤: 采集步骤

  • 数据采集:采集的数据,这一步其实还包括确定采集的数据及采集后对数据的校验。
  • 数据传输:数据上报的过程,分实时和批量上报。
  • 数据存储:数据到达服务器后,建模存储,便于分析。
  • 数据分析:对数据进行统计、分析、挖掘。
  • 数据展示:呈现一个可视化的页面,反馈相应的数据供参考。

其中第一步是最核心的部分,数据的准确性、丰富性、实时性会直接影响数据平台的最终效果。

  1. 准确性:就是要保证埋点的正确,埋点事件是满足产品和数据需求的,统计的口径应该是和各方讨论确定好的,这是最重要的。因为若是埋点错误了,一是相当于此次埋点是无效的,做了无用功,对于代码埋点只能等待下次迭代才能重新加上;二是也可能对之前的埋点数据造成影响。所以保证埋点的准确性是至关重要的。
  2. 丰富性:埋点是用于数据分析的,埋点元素要足够丰富能满足各种条件的数据分析。
  3. 实时性:尽量保证埋点数据的实时上传,但是如果每一条数据就上报一次的话,上报接口的请求量会特别大。所以一般的策略是:APP启动时上报、退出上报、满足一定条数上报、时间间隔及提供立即上报接口。

数据校验的方式:

  1. 研发人员可以通过日志校验。
  2. 客户端页面显示,将产生的埋点数据悬浮显示;后端页面显示(抓包中转、后端接口实时刷新)。
  3. assert断言字段结构

另外,数据的传输过程也有一些需要注意的:

  1. 批量上传:数据一般都是多条数据批量上传的。
  2. 压缩:为减少上传的大小,一般要进行压缩,如GZIP。
  3. 加密:为保证数据的安全,一般会采用加密策略。
  4. 容错:数据若是上传失败,要保存于数据库中,避免丢失。

前两步是涉及到客户端,后续一般在服务端处理,不做介绍。

我们项目的埋点演进

目前大多数项目还是采用的代码埋点,因为代码埋点能够比较准确的统计用户行为,而且如果埋点合理相较全埋点和可视化较为简单方便。目前我们所用的埋点方案也是经过多次迭代改进的。

埋点演进

早期埋点:第三方SDK

我们早期的埋点相对比较简单,一般集成统计SDK(友盟、talkingdata等),按照相应SDK初始化,加入页面的统计埋点。遇到一些复杂的业务,使用SDK的自定义埋点即可(比如友盟的计数和计算事件)。

这种方式的好处是,简单方便,维护成本低,不需要自己定义统计的数据格式、上传报文等,SDK中都封装好了直接用即可。对一些小型的APP,个人觉得已经足够了。

缺点是数据不透明,集成方无法获得上传报文。

peacock SDK中的埋点

peacock中的埋点统计,经历过下面几个阶段:

  1. 中华万年历中广告的统计,一般只统计广告的view、click、loading、start等次数,现已废弃。
  2. 启动活跃上报(现已废弃)、基于事件的日志报文。
  3. 自定义事件上报

上述的统计策略配合第三方SDK,基本能够满足我们目前的业务需求。但不够完善,一是没有独立的SDK处理这部分逻辑(和peacock耦合),二是报文格式不统一(事件型和自定义型上传报文和接口不一致),三是上报和存储策略待优化(之前数据存储于内存,满足一定条件后上报,不成功则存储于数据库,这样会有数据丢失风险),四是缺少一些自动化统计,比如页面pv、时长、APP启动退出及应用时长统计等。

Analytics SDK

第三阶段,我们优化了采集协议,重构了埋点文档,使埋点更完善和方便。包括以下特性:

  1. 独立的SDK,业务逻辑清晰,方便集成。
  2. 报文格式和上报接口统一,更简单。
  3. 上报字段更丰富,扩展防作弊字段。
  4. 加入一些自动化的统计,包括APP启动、退出及相应时长统计,页面PV及时长、各种控件点击事件等。
  5. 优化上报策略,保证数据的及时性。(启动、退出、异常退出、自定义上报条数、自定义上报时间间隔、立即上报等)
  6. 支持H5的埋点统计。
  7. 各种配置更灵活,上报地址、满足上报条数、两次上报时间间隔等。

相关技术点

上述是统计埋点相关的演进,在迭代演进的过程中我们会碰到各种问题需要解决。下面罗列一些点详细介绍。

设备指纹

我们知道平时统计的DAU(日活)、DNU(日新增)、MAU(月活)、留存等,都是通过设备指纹去统计的,设备指纹最关键的就是要保证设备的唯一性。我们在采取业界普遍做法的基础上,采取了以下方式来实现:

字段获取流程

先从SP(SharedPreferences)缓存中获取,若获取到直接使用;若获取不到,则从Sdcard文件中尝试获取,若获取到返回并写到SP文件中;若获取不到,则通过系统原生方法获取,存储到SP和Sdcard文件中。即将值存储于SP和Sdcard文件中,获取优先级SP、Sdcard及原生方法,最大限度保证数据的唯一性。

View的自动统计

在应用使用的过程中,我们需要统计各种控件的显示即view事件,为了比较精准且简便的统计此事件,封装了两个类ETADLayout和ETADUtils。

ETADLayout用于统计条目,在需要统计的条目最外层加上,不影响内部布局结构。内部提供多个public方法,方便开发者添加需要统计的信息,其中 setAdEventData 方法是必须要调用的。

ETADUtils是一个工具类,viewAllETADLayouts方法会循环获取ViewGroup里所有在统计区间内的ETADLayout对象,并且调用ETADLayout对象的统计方法。使用者在适当时机调用该方法即可。

例如listview在滑动停止时统计当前可见条目。

  • 在条目布局最外层加上ETADLayout ETADLayout
  • 向ETADLayout对象里添加需要统计的信息
  • 当ListView滑动停止时,调用ETADUtils的viewAllETADLayouts方法

注意点:

  • ETADLayout是最小单位,不可嵌套
  • ETADLayout内部做了去重逻辑,10s内相同条目id只统计一次
  • 条目露出1/2才会统计
  • listview、recyleview在滑动停止时统计,快速滑动时不统计;统计顶部及底部位置传入

全埋点技术方案

全埋点需要自动采集,因此针对页面、控件需要生成对应ID,该ID需具备【唯一】且【稳定】,即不会随意改变。

页面ID

这个就较为简单,一般类名就能够满足(除非主动修改相应类名)。在 Android 中,页面有两种类型 Activity 和 Fragment,Fragment 可以镶嵌在不同的 Activity 内,因此两者的 ID 定义规则有些不同:

  • Activity,ID 规则为 ActivityClassName|额外参数;
  • Fragment,ID 规则为 ActivityClassName[FragmentClassName]|额外参数。

控件ID

相较于页面ID,控件ID的定义就相对比较复杂些。首先控件的R.id无法满足,因为编译原因这个id不是固定不变的,在资源发生变化时此id可能不同,不满足稳定的条件,此方案不可行。以下两种方案:

  • 使用控件的id名称
    一般控件的id名称和类型很少会改变,除非页面布局重构。使用控件的id名称能最大限度的保证其唯一和稳定。但不同页面控件id可能一样,所以还需用页面id做区分。
    规则:页面id+控件id名
  • 控件的布局路径
    根据布局中控件的父子关系,从控件本身出发逐级向上遍历,直至找到根节点结束,这样就找到控件在视图树中的一个布局路径;反过来根据这个路径,我们就能够在此视图树上确定该控件。如下图:

根据上述路径生成规则,对于Button而言,其路径为:FrameLayout[0]/LinearLayout[1]/Button[0].同样不同的页面此路径可能一样,需用页面id做区分。

规则:页面id+控件布局路径

此两种方案结合,可最大限度的保证控件ID的唯一和稳定。我们在埋点统计时,可将两种形式的值都上报,需包括页面id、控件id名、控件路径。优先取控件id名做其唯一ID,若为空再取控件路径。

在定好了页面ID和控件ID之后,我们就可以代码实现其自动化上报了。对于页面埋点,其实现就较为简单了,可以在对应页面的onResume、onPause加入埋点事件(主动性),也可以监听ActivityLifecycleCallbacks生命周期自动埋入事件(自动性)。

对于控件的自动化埋点,在有了控件ID之后,只要在其点击或长按的地方进行统计即可。而在Android中,控件的点击和长按都有比较标准的回调函数,在其回调处调用SDK中封装的相关方法,传入view的相关参数即可。而其难点在于如何将此方法插入到相应回调中,下面介绍一种实现方式。

AspectJ 实现AOP

其实现原理是,在代码编译期,找到源代码中需要上报事件的位置,插入SDK的事件上报代码,其使用的框架是AspectJ 。

几个概念

AOP

AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。  
AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。  
利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

简而言之,AOP是可以通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。

AspectJ

* JPoint: 代码切点(就是我们要插入代码的地方)
* Aspect: 代码切点的描述
    Pointcut: 描述切点具体是什么样的点,如函数被调用的地方( Call(MethodSignature) )、函数执行的内部( execution(MethodSignature) )
    Advice: 描述在切点的什么位置插入代码,如在Pointcut前面( @Before )还是后面( @After ),还是环绕整个Pointcut( @Around )

由此可见,在实现AOP功能时,需要做下面几件事:

  • 定义一个Aspect,这个Aspect里面必须有Pointcut和Advice两个属性。
  • 编写在匹配到符合Pointcut和Advice描述的代码时,需要注入的代码。
  • 在代码编译时,通过特殊的java编译器(Aspect的ajc编译器),找到符合我们定义的Aspect的代码,将需要注入的代码插入到Advice指定的位置。

AspectJ是一个框架,通过依赖导入它可以很方便的实现代码插桩。下面是其实现的简要步骤。

实现

1、首先定义Aspect

import org.aspectj.lang.JoinPoint;  
import org.aspectj.lang.annotation.After;  
import org.aspectj.lang.annotation.Aspect;  
import org.aspectj.lang.annotation.Pointcut;

/**
 * android.view.View.OnClickListener.onClick(android.view.View)
 */
@Aspect
public class ViewOnClickListenerAspectj {  
    /**
     * 埋点的具体实现
     */
    private void doAOP(final JoinPoint joinPoint) {

    }

    /**
     * 支持 butterknife.OnClick 注解
     */
    @Pointcut("execution(@butterknife.OnClick * *(..))")
    public void methodAnnotatedWithButterknifeClick() {
    }

    @After("methodAnnotatedWithButterknifeClick()")
    public void onButterknifeClickAOP(final JoinPoint joinPoint) throws Throwable {
        try {
            if (AnalyticsDataAPI.sharedInstance().isButterknifeOnClickEnabled()) {
                doAOP(joinPoint);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * android.view.View.OnClickListener.onClick(android.view.View)
     *
     * @param joinPoint JoinPoint
     * @throws Throwable Exception
     */
    @After("execution(* android.view.View.OnClickListener.onClick(android.view.View))")
    public void onViewClickAOP(final JoinPoint joinPoint) throws Throwable {
        doAOP(joinPoint);
    }

    /**
     * android.view.View.OnLongClickListener.onLongClick(android.view.View)
     *
     * @param joinPoint JoinPoint
     * @throws Throwable Exception
     */
    @After("execution(* android.view.View.OnLongClickListener.onLongClick(android.view.View))")
    public void onViewLongClickAOP(JoinPoint joinPoint) throws Throwable {

    }
}

这段Aspect代码定义:在view的onClick方法后插入doAOP(joinPoint);代码进行埋点上报。其中上述也支持onClick的Butterknife依赖注入方式。

2、其次使用ajc编译器向源代码中“织入”Aspect代码
在 project 级别的 build.gradle 文件中添加依赖:

//aop全埋点需要
classpath 'org.aspectj:aspectjtools:1.8.9'  
classpath 'org.aspectj:aspectjweaver:1.8.9'  

在主APP module的 build.gradle 文件中添加依赖:

//aop全埋点需要
implementation 'org.aspectj:aspectjrt:1.8.9'  

添加导入aspectj所需的编译代码,放于文件尾部即可:

import org.aspectj.bridge.IMessage  
import org.aspectj.bridge.MessageHandler  
import org.aspectj.tools.ajc.Main  
final def log = project.logger  
final def variants = project.android.applicationVariants

variants.all { variant ->  
if (!variant.buildType.isDebuggable()) {  
    log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
    return;
}

JavaCompile javaCompile = variant.javaCompile  
javaCompile.doLast {  
    String[] args = ["-showWeaveInfo",
                     "-1.8",
                     "-inpath", javaCompile.destinationDir.toString(),
                     "-aspectpath", javaCompile.classpath.asPath,
                     "-d", javaCompile.destinationDir.toString(),
                     "-classpath", javaCompile.classpath.asPath,
                     "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
    log.debug "ajc args: " + Arrays.toString(args)

    MessageHandler handler = new MessageHandler(true);
    new Main().run(args, handler);
    for (IMessage message : handler.getMessages(null, true)) {
        switch (message.getKind()) {
            case IMessage.ABORT:
            case IMessage.ERROR:
            case IMessage.FAIL:
                log.error message.message, message.thrown
                break;
            case IMessage.WARNING:
                log.warn message.message, message.thrown
                break;
            case IMessage.INFO:
                log.info message.message, message.thrown
                break;
            case IMessage.DEBUG:
                log.debug message.message, message.thrown
                break;
        }
    }
  }
}

3、查看织入后的class文件
完成以上两步,就在onClick方法中插入了埋点统计代码,查看编译后的class文件如下代码:

public void onClick(View v) {  
        JoinPoint var2 = Factory.makeJP(ajc$tjp_0, this, this, v);
        try {
            if (v.getId() == 2131165324) {
                Log.i("MainActivity", "tv_test onClick");
            } else if (v.getId() == 2131165226) {
                Log.i("MainActivity", "btn_test onClick");
                this.startActivity(new Intent(this, TestActivity.class));
            }
        } catch (Throwable var5) {
            ViewOnClickListenerAspectj.aspectOf().onViewClickAOP(var2);
            throw var5;
        }
        ViewOnClickListenerAspectj.aspectOf().onViewClickAOP(var2);
    }

AspectJ的基本用法就是这样,理论上是可以对任何方法进行替换的,比如TabHost、RadioGroup等控件。
目前我们最新版本的统计埋点SDK就是基于此方式实现的,在编译时对字节码进行修改,插入事件上报代码。
除了上述方案之外,还有Gradle插件提供的Transform API(1.5.0版本以上)、ASM、Javassist、代理监听等。

实现参考:

总结和展望

从最早的手动埋点到现在的部分自动埋点SDK,埋点统计在慢慢迭代优化,使埋点的工作能更方便更全面。但即使这样,手动埋点的工作还是无法完全被替换,我们应该根据业务特点相结合使用,在一些相对稳定的页面控件,使用自动埋点;对一些业务变动频繁的则可以使用手动埋点。
未来还可以搭建可视化埋点平台,这样能够实现动态的按需埋点,使埋点平台更完善。

作者介绍

李恒,微鲤客户端技术负责人,作为核心研发人员参与过美历、中华万年历、微鲤看看等App的研发工作。

微鲤技术团队

微鲤技术团队承担了中华万年历、Maybe、蘑菇语音、微鲤游戏高达3亿用户的产品研发工作,并构建了完备的大数据平台、基础研发框架、基础运维设施。践行数据驱动理念,相信技术改变世界。