阅读 1928

51信用卡 Android 自动埋点实践

本文首发于 51NB 技术公众号,原文链接 51信用卡 Android 自动埋点实践


背景

随着公司业务的发展,对业务团队的敏捷性和创新性提出了更高的要求,而通过大数据的手段在一定程度上可以帮助我们实现这个愿景,同时良好的数据分析可以也帮助我们进行更好更优的决策。对于数据本身,其处理流程主要可以归结为以下几点:

  • 数据采集
  • 数据上报
  • 数据存储
  • 数据分析
  • 数据展示

其中所谓的数据采集是针对特定用户行为或事件进行捕获、处理,这一步骤无疑是十分重要的,因为数据采集的准确性和多样性也会直接对后续的步骤产生影响。本文也主要是讨论数据采集的几种方式,而我们常说的『埋点』就是数据采集领域的术语,数据采集的方式也可以说是埋点的几种方式。

现状、痛点

目前公司内部主要使用代码埋点的方式进行数据采集,所谓代码埋点指的是

在某个事件发生时通过预先写好的代码来发送数据

基于预先编码实现的代码埋点,其优点是:控制精准、采集灵活性强,可以自由的选择什么时候发送什么样的数据;但缺点也同样十分明显,开发、测试成本高,对于客户端而言需要等待发版才能修改线上的埋点。

日常的开发过程中,经常有同事反馈埋点的错埋及漏埋,其根本原因都是代码埋点本身特点导致,这样的情况推动着我们去尝试使用其他埋点方式。

业内情况

无痕埋点

无痕埋点也可称为无埋点或者全埋点,即在端上自动采集并上报尽可能多的数据,在计算时筛选出可用的数据。其优点是:很大程度上减少开发、测试的重复劳动,数据可以回溯并且全面。缺点是:采集信息不够灵活,并且数据量大。

可视化埋点

可视化埋点是通过可视化工具选择需要收集的埋点数据,下发配置给客户端,从而解析配置采集相应埋点的方式。其优点是:很大程度上减少开发、测试的重复劳动,数据量可控,可以在线上动态的进行埋点配置,无需等待 App 发版。其缺点同样是采集信息不够灵活,并且无法解决数据回溯的问题。

阶段一:无痕埋点

分析公司常用的一些数据指标,我们发现对于大部分指标而言,我们只需要有页面的曝光事件、控件的点击事件等一些发送时机、内容相对固定的埋点即可,而这部分埋点,恰恰可以比较方便的使用自动埋点(相对于代码埋点这种手动埋点来说,无痕埋点及可视化埋点均可被称为自动埋点)来进行采集。

相对于可视化埋点来说,无痕埋点在前期不需要可视化工具进行埋点收集,SDK 开发投入较小,因此我们进行了第一步从手动埋点到无痕埋点的迭代。

无痕埋点技术实现

无痕埋点需要自动采集数据,因此针对页面、控件等元素需要生成其 ID,该 ID 需尽量具备『唯一性』和『稳定性』。『唯一性』非常好理解,因为对于任意元素而言,其 ID 应该是与其他所有元素都不同的,这样我们才能根据 ID 唯一标识出那个我们想要的元素,采集上来的数据才是准确的,不重复的。而『稳定性』则是说,元素的 ID 应尽量不受版本的变动而改变,这样后期关联业务含义的操作才会更加便捷。

页面ID规则

页面的 ID 较容易定义,参考上文提到的『唯一性』和『稳定性』,我们很容易就可以想到将页面所在类的类名作为 ID。类名作为 ID,首先它是相对唯一的,除了页面复用,不存在其他类名相同的页面,而页面复用的情况可以通过页面标题名称等方式进行规避;其次它是相对稳定的,只有在页面类名被修改的情况下 ID 才会改变,而我们日常开发的过程中,除了一些页面重大的改版之外不会轻易修改类名。在 Android 中,页面有两种类型 Activity 和 Fragment,Fragment 可以镶嵌在不同的 Activity 内,因此两者的 ID 定义规则有些不同:

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

页面PV、UV

有了页面的唯一 ID 生成的规则,我们只需要在页面曝光的时候,生成这个 ID,然后上传即可实现页面的 PV、UV 指标。至于页面曝光的时机,在 Android 开发中很容易可以找到,因为对于 Activity 和 Fragment 而言都有标准的生命周期。针对业务中 PV、UV 的定义,我们可以将 Activity 的 onResume() 方法,Fragment 的 onResume()setUserVisibleHint(boolean isVisibleToUser)onHiddenChanged(boolean hidden) 方法作为曝光时机,在上述方法被回调时,调用 SDK 埋点方法,生成 ID 然后上传埋点。

  • Activity

  • Fragment

控件ID规则

相对于页面而言,控件的 ID 定义规则要更加复杂。起初我们会想到用『R.id』,在编译时 Android aapt 会给每个写在 xml 里的控件生成一个唯一 ID,但是从 aapt 的生成规则来看,这个 ID 并不是固定不变的,在资源文件发生变化的时候,id 也可能会出现变化,也就是不同版本的相同控件的 ID 是有可能不同的。根据 ID 需要具备的『唯一性』和『稳定性』来看,这个 ID 具备『唯一性』,但『稳定性』非常差,因此这个方案不可行。

紧接着我们想到,每个界面所有的控件根据其父子关系可以绘制出页面的视图树,从控件本身出发,根据控件的类名加上其所处层级的位置等特征信息,并逐级的向上遍历,直至找到根节点位置,这样我们就能得到一个控件在该视图树中的一个控件路径;反过来说,根据这个控件路径,我们就能在这个视图树中唯一确定一个控件。下图是一个简单的 ViewTree 模型:

根据上文所述控件路径生成规则,对于 Button 而言,其路径为:FrameLayout[0]/LinearLayout[1]/Button[0],在一个页面中,这个路径就可以帮我们唯一定位到这个 Button,但是对于不同的页面而言,还是存在不同的控件相同的路径的情况,因此控件 ID 的生成规则应为:『页面 ID: 控件路径』。

上文页面 ID 的生成规则中我们说到,对于 Android 来说,页面有 Activity 和 Fragment 两种,因为一个 Activity 可以包含不同的 Fragment,所以控件如果是存在于 Fragment 中的,则页面 ID 需要为其所在的 Fragment 的页面 ID,如果不在 Fragment 中,则包含 Activity 的页面 ID 即可,那么如何能够从控件本身的实例获取到其所在的 Activity 或者 Fragment。对于 Activity 而言比较简单,我们可以通过如下代码实现:

对于 Fragment 则相对比较麻烦,我们只能事先将 Fragment 对应的页面 ID 和控件本身绑定,即通过打 tag 的方式,在 Fragment 的 OnViewCreated 方法中,拿到 Fragment 容器中的根 View,并打上 Fragment 的页面 ID,然后遍历该 View,为其所有的子控件都打上标记,核心代码如下:

所以当我们拿到一个 View 的实例时,我们先看是否能拿到这个 tag 对应的页面 ID,如果拿不到再去找其所属的 Activity,然后得到页面 ID,随后根据它本身的控件路径,拼凑出控件的 ID,核心代码如下:

控件ID的优化

基于我们上述的控件 ID 定义,在页面元素不发生变动的情况下,基本能够保证『稳定性』和『唯一性』,但是页面元素发送动态变化,或者不同版本之间 UI 进行改版的情况下,我们的控件 ID 就会变得不够稳定,比如以下情况:

在插入一个 FrameLayout 之后,我们 Button 的控件路径就变成了 FrameLayout[0]/LinearLayout[2]/Button[0],与之前的 ID 相比,已经发生了改变,变得不那么『稳定』了,于是我们做了以下的优化:

  • 优化1:将兄弟节点中的位置,变成相同类型控件的位置。优化后的控件路径为:FrameLayout[0]/LinearLayout[1]/Button[0],即使在插入 FrameLayout 后,其路径仍旧不变,相较之前会更加稳定一些。但如果插入的是 LinearLayout,或者整个页面的 UI 进行了重构,控件路径依旧会发生改变。

  • 优化2:因为不同的系统版本或手机厂商,会对页面的根 View 做一定的处理,所以我们需要屏蔽掉这种情况,对于我们而言,我们只关心我们自定义的那部分布局,即通过 setContentView 传入的布局。我们可以通过判断控件 ID 是否等于 android.R.id.content 来获取我们自定义的布局的根 View,并将其作为我们控件路径的起点。

  • 优化3:在 Android 中,除了 R.id 和控件路径之外,还有一个比较常用的可以作为控件 ID 的特征信息,那就是开发者写在布局文件中,关联控件的 Resource ID。Resource ID 是开发者自己定义的关联 View 的标识,在一个页面当中,理论上是唯一的(为什么说是理论上,因为还是存在有多个相同 Resource ID 的情况,比如动态的 add 多个 layout,且包含了相同的 Resource ID,但这种情况非常少),并且在页面的重构过程中,Resource ID 也一般不会修改,因此用 Resource ID 来作为控件 ID 是非常合适的。但并不是所有的控件都有 Resource ID,我们可以先尝试去获取这个 ID,假如 Resource ID 存在,则使用 Resource ID 来作为控件 ID,假如 Resource ID 不存在,则降级使用控件路径作为控件 ID。核心代码如下:

控件的点击、长按指标

有了控件 ID 的生成规则,控件的点击和长按指标我们就能很方便的进行统计,因为在 Android 中,控件的点击和长按都有非常标准的回调函数,即 onClick(View v)onLongClick(View v) 方法。在回调函数中调用 SDK 封装好的方法,传入被点击控件的 View 对象,通过 View 对象本身的特征信息,得到这个控件的唯一 ID,然后上传埋点,即可统计出我们想要的控件相关的点击、长按指标。

  • 点击

  • 长按

代码插桩

通过上文的描述,我们得到了页面和控件的 ID 的定义规则,也知道了只需要在相应的回调函数中写入 SDK 代码获得我们想要的对象,就能够计算出我们想要的指标,那么如何才能自动的往我们现有的工程中写入获得对象的代码。

在指定的切点插入指定的代码,这个业务场景可能很多同学都非常熟悉,我们常用 AOP 的方式来解决这类问题,将所有的代码插桩逻辑集中在一个 SDK 内处理,这样可以最大程度的不侵入业务。

Javassist

Javassist 是一个基于字节码操作的 AOP 框架,它允许开发者自由的在一个已经编译好的类中添加新的方法,或是修改已经存在的方法。但是和其他的类似库不同的是,Javassist 并不要求开发者对字节码方面具有多么深入的了解,同样的,它也允许开发者忽略被修改的类本身的细节和结构。一个简单的修改方法体的例子如下:

gradle 插件

Javassist 需要操作已经编译好的类,Android 的打包流程从下图可以了解,我们可以在 Java 编译器编译完工程代码,.class 文件转成 dex 之前使用 Javassist 来进行我们需要的代码插桩工作。

了解过 gradle 插件的同学可能知道,在 Android Gradle Plugin 版本在 1.5.0 及以上,我们可以使用官方提供的最新的 Transform API,在打包编译时 .class 打包成 dex 之前对 class 文件进行处理。具体的自定义插件过程不在赘述,我们只需要定义一个自己的 Transform,继承系统的 Transform,重写 transform 方法即可。

在 transform 方法的第二个参数里,我们可以获取到工程内所有的源码编译出来的 .class 文件以及所有依赖的 jar 包,我们挨个遍历所有的 .class 文件,以及解压缩所有的 jar 包,拿到 jar 包内的 .class 文件,即可实现对所有的文件进行代码插桩的需求,核心代码如下:

拿到 .class 文件之后,我们会按照上述 Javassist 的工作流程进行代码插桩:

  1. 先根据类名得到 CtClass 对象
  2. 再根据我们想要寻找的切入点,页面就找 onResume() 方法,控件就找 onClick(View view) 方法
  3. 然后根据方法名和参数类型,得到 CtMethod 对象
  4. 调用 CtMethod 对象的编辑方法体的 API,在原始方法体之前插入就调用 insertBefore,之后就调用 insertAfter,传入需要插入的代码块
  5. 调用 CtClasswriteFile() 方法,保存这次编辑

将项目中所有的源文件遍历一边后,我们就完成了整个项目代码的插桩,在我们想要的切入点(页面的曝光、控件的点击等回调函数),就成功的插入了相应捕获页面、控件对象的代码,在页面曝光或者控件点击时,就能够获得相应的对象,生成唯一 ID 并上报相应的埋点事件,完成整一个无痕埋点的流程了。

阶段二:可视化管理后台

完成阶段一的无痕埋点之后,我们可以通过接入一个 SDK 来轻松的实现页面曝光、控件点击等指标的数据获取,但是通过上文我们可以知道,我们定义的 ID 其实对于业务方(产品、运营、BI 等非业务开发人员)而言是不友好的,他们无法根据 ID 中的类名、Resource ID 等特征信息来关联到埋点具体的业务含义,因此我们需要通过一些工具来帮助他们将埋点元素 ID 和具体的业务含义进行关联,甚至是跨平台(Android、iOS 的自动埋点 ID 是不一致的)的关联。

从另外一个角度来说,有了这样的可视化管理后台,我们还可以通过下发配置表的方式来收集想要的埋点,这其实就是我们开篇说的可视化埋点。所以有了这样的管理后台并基于自动埋点的数据采集方式,我们可以根据具体的业务场景,灵活的选择是无痕埋点(全量采集)还是可视化埋点(根据配置表定向采集)。

一个简单的用户操作可视化管理后台的时序图如下:

从图中我们可以知道,可视化管理后台的核心内容就是上传手机界面截图及控件相关信息,可以让用户在后台对相关的页面、控件与自定义的业务 ID 进行绑定并在后台生成配置,界面实际效果如下:

在上图的可视化管理平台中,主要有这么几大块内容,最上方是当前和管理后台建立连接的设备信息,左下方是当前界面已经绑定过自定义业务 ID 的埋点元数据,右下方是手机当前界面在管理平台上的映射,并标记出界面内所有可埋点的控件,已绑定过自定义业务 ID 的控件标记绿色,未绑定的标记红色,这样用户就可以非常方便的选择自己想要的控件进行操作。

要实现上图这样的效果,我们只需要遍历当前页面,并上传所有可被埋点的控件信息,对于目前我们想要实现的数据指标而言,我们只关心控件的点击和长按事件,换句话说就是我们只需要找到当前页面内所有的可被点击或长按的控件即可。

上报控件信息

对于需要上报的控件需要满足以下几个条件:

  1. 可被点击或长按
  2. 在当前界面可见

对于控件是否可被点击或长按,我们没法直接通过系统的 API 来获取,但是通过源码我们可以看到,View 内部还是有私有变量来存储点击或长按的监听器的,在 API14 之前的 mOnClickListener 对象和 API14 之后的 mListenerInfo 对象,均可用来判断当前 View 对象是否被设置了点击监听函数,我们可以通过反射来拿到这些对象,并进行判断,长按的判断也同理,核心代码如下:

处理完可被点击或长按的条件后,我们要判断控件在当前界面是否可见,因为我们需要在截图上把控件全选出来,如果控件本身是不可见的也被圈出来,用户就会比较迷茫。通过一定的调研,我们发现满足以下几点条件,即表示该控件在屏幕内可见:

  1. 判断 View 本身可见性属性

    View 本身可见性属性比较容易判断,我们只需要判断 View.isShown() 并且 View.getVisibility() == View.VISIBLE 即可。

  2. 判断 View 所处的位置是否在当前屏幕内

    一个 Activity 加载了多 Fragment 的情况下,可能会出现控件本身可见性属性达标,但实际并不在屏幕内的情况。这种情况我们根据 View.getLocationOnScreen(int[] outLocation),然后通过判断 outLocation[0],是否大于等于 0 且小于等于屏幕宽度,就能判断控件是否在当前屏幕内。

  3. 判断控件是否被其他控件完全遮挡

    遍历所有与该控件有关联的控件(同层控件、父控件、父控件的同层控件等),通过 View.getGlobalVisibleRect(Rect viewRect) 来得到控件所对应的 Rect 信息,然后通过 Rect.contains(Rect r) 来判断两个控件对应的 Rect 是否完全包含即可。

控件符合上述的可被点击或长按且在当前界面可见这两个条件,其信息就会被并上传至管理后台,用户就可以对这个控件进行编辑,绑定自定义的业务 ID,管理后台得到控件与自定义业务 ID 的关联关系后,即可生成配置表,并下发至 App。这样采集上来的埋点就会带上自定义业务 ID,用户在后续的数据使用过程中就可以非常方便的查看相应的业务指标。

可视化管理后台核心的逻辑就是上述的客户端和管理后台建立连接并上传相应信息,其他配置的生成、下发等都非常容易处理,就不在赘述。

阶段三:埋点DSL

文章开头我们有提到过,无论是无痕埋点还是可视化埋点,都是基于自动化采集埋点的方式来做的,在这样的采集方式下,我们无法通过埋点携带更多的信息,这也是我们面临的一个痛点。基于这样的需求之下,我们考虑可以用DSL来解决这个问题。

什么是DSL

DSL 即 Domain-specific language,翻译为领域特定语言,意为在特定领域解决特定任务的语言。

哪些场景下需要用到DSL

上文提到的自动埋点以页面和控件为切入点,hook 页面曝光和控件点击事件,并获取页面及控件相关信息作为特征值写入埋点。在简单的场景下,这样的逻辑尚可胜任,但在某些复杂的场景,比如典型的 banner 轮播、资源位曝光等,控件相同但实际内容不同的埋点,无法根据控件信息来区分。对于手动埋点而言,获取接口内的信息,然后传入埋点就能进行区分,但是自动埋点无法关联这部分接口信息,于是需要 DSL 来定义简单的规则,通过运行时的方式来获取内存中的这部分数据,从而写入埋点,进行更加精细的区分。

如何实现DSL

DSL 的构建与编程语言其实比较类似,想想我们在重新实现编程语言时,需要做那些事情;实现编程语言的过程可以简化为定义语法与语义,然后实现编译器或者解释器的过程,而 DSL 的实现与它也非常类似,我们也需要对 DSL 进行语法与语义上的设计。总结下来,实现 DSL 总共有这么两个需要完成的工作:

  1. 设计语法和语义,定义 DSL 中的元素是什么样的,元素代表什么意思
  2. 实现解释器,对 DSL 解析,最终通过反射(runtime)来执行

设计语法和语义

这部分其实是千人千面的,我们可以根据自己的业务需求来不断的迭代,但是核心思路是定义一些特殊的字符串,并对应调用各自的 API,一些简单的语法大致有以下这些:

  1. . 来标识对象调用,比如 test.a 表示实例 test 中的 a 字段
  2. .() 来表示方法调用,比如 test.test() 表示实例 test 中的 test() 方法调用
  3. [] 来表示数组或列表

实现解释器

说是解释器,其实只是一段预先写好在 SDK 内的代码逻辑。通过预先约定好的语法和语义,业务开发者在可视化平台针对某个控件进行代码编写,然后下发这部分代码,SDK 根据规则解析这部分代码,然后通过反射(runtime)的方式来获取相应的数据并写入自动埋点。

平台配套

可视化平台在元素录入的时候或者后期编辑的时候,可以额外录入事件发生时想要获取的数据的路径,这部分内容需要由业务开发人员根据 SDK 这边给出的规则进行路径的录入。成功录入后,生成配置文件下发至 App。SDK 在事件发生时,获取到相应事件携带的数据路径,根据 DSL 约定的规则解析路径并获取相应的数据,存放至埋点相应字段内上传。

总结

从最早的手动埋点到后续的无痕埋点,再到可视化管理平台的搭建,以及 DSL 的实现,一步步的走来我们可以看到虽然相比手动埋点而言,自动埋点有许多优势,但同样其劣势也非常明显,即使我们通过一些工具、技术去不断的优化和弥补它的不足,但他依旧不能完全的替代手动埋点。所以结合业务本身的特点,选择最合适的埋点采集方式才是最正确的做法,在一些相对稳定,不常变动的页面、控件中使用自动埋点,可以极大的节省各个环节的时间;但如果页面、控件本身是频繁迭代的那自动埋点就不如手动埋点来的合适。


作者介绍

  • 李传志,51信用卡客户端基础组 Android 开发工程师,2017 年加入 51信用卡,目前主要负责端上数据埋点、性能监控等相关基础建设工作。
关注下面的标签,发现更多相似文章
评论