本文首发于 51NB 技术公众号,原文连接 51信用卡 Android 自动埋点实践android
随着公司业务的发展,对业务团队的敏捷性和创新性提出了更高的要求,而经过大数据的手段在必定程度上能够帮助咱们实现这个愿景,同时良好的数据分析能够也帮助咱们进行更好更优的决策。对于数据自己,其处理流程主要能够归结为如下几点:编程
其中所谓的数据采集是针对特定用户行为或事件进行捕获、处理,这一步骤无疑是十分重要的,由于数据采集的准确性和多样性也会直接对后续的步骤产生影响。本文也主要是讨论数据采集的几种方式,而咱们常说的『埋点』就是数据采集领域的术语,数据采集的方式也能够说是埋点的几种方式。数组
目前公司内部主要使用代码埋点的方式进行数据采集,所谓代码埋点指的是框架
在某个事件发生时经过预先写好的代码来发送数据编程语言
基于预先编码实现的代码埋点,其优势是:控制精准、采集灵活性强,能够自由的选择何时发送什么样的数据;但缺点也一样十分明显,开发、测试成本高,对于客户端而言须要等待发版才能修改线上的埋点。函数
平常的开发过程当中,常常有同事反馈埋点的错埋及漏埋,其根本缘由都是代码埋点自己特色致使,这样的状况推进着咱们去尝试使用其余埋点方式。工具
无痕埋点也可称为无埋点或者全埋点,即在端上自动采集并上报尽量多的数据,在计算时筛选出可用的数据。其优势是:很大程度上减小开发、测试的重复劳动,数据能够回溯而且全面。缺点是:采集信息不够灵活,而且数据量大。布局
可视化埋点是经过可视化工具选择须要收集的埋点数据,下发配置给客户端,从而解析配置采集相应埋点的方式。其优势是:很大程度上减小开发、测试的重复劳动,数据量可控,能够在线上动态的进行埋点配置,无需等待 App 发版。其缺点一样是采集信息不够灵活,而且没法解决数据回溯的问题。性能
分析公司经常使用的一些数据指标,咱们发现对于大部分指标而言,咱们只须要有页面的曝光事件、控件的点击事件等一些发送时机、内容相对固定的埋点便可,而这部分埋点,偏偏能够比较方便的使用自动埋点(相对于代码埋点这种手动埋点来讲,无痕埋点及可视化埋点都可被称为自动埋点)来进行采集。测试
相对于可视化埋点来讲,无痕埋点在前期不须要可视化工具进行埋点收集,SDK 开发投入较小,所以咱们进行了第一步从手动埋点到无痕埋点的迭代。
无痕埋点须要自动采集数据,所以针对页面、控件等元素须要生成其 ID,该 ID 需尽可能具有『惟一性』和『稳定性』。『惟一性』很是好理解,由于对于任意元素而言,其 ID 应该是与其余全部元素都不一样的,这样咱们才能根据 ID 惟一标识出那个咱们想要的元素,采集上来的数据才是准确的,不重复的。而『稳定性』则是说,元素的 ID 应尽可能不受版本的变更而改变,这样后期关联业务含义的操做才会更加便捷。
页面的 ID 较容易定义,参考上文提到的『惟一性』和『稳定性』,咱们很容易就能够想到将页面所在类的类名做为 ID。类名做为 ID,首先它是相对惟一的,除了页面复用,不存在其余类名相同的页面,而页面复用的状况能够经过页面标题名称等方式进行规避;其次它是相对稳定的,只有在页面类名被修改的状况下 ID 才会改变,而咱们平常开发的过程当中,除了一些页面重大的改版以外不会轻易修改类名。在 Android 中,页面有两种类型 Activity 和 Fragment,Fragment 能够镶嵌在不一样的 Activity 内,所以二者的 ID 定义规则有些不一样:
ActivityClassName|额外参数
ActivityClassName[FragmentClassName]|额外参数
有了页面的惟一 ID 生成的规则,咱们只须要在页面曝光的时候,生成这个 ID,而后上传便可实现页面的 PV、UV 指标。至于页面曝光的时机,在 Android 开发中很容易能够找到,由于对于 Activity 和 Fragment 而言都有标准的生命周期。针对业务中 PV、UV 的定义,咱们能够将 Activity 的 onResume()
方法,Fragment 的 onResume()
、setUserVisibleHint(boolean isVisibleToUser)
、onHiddenChanged(boolean hidden)
方法做为曝光时机,在上述方法被回调时,调用 SDK 埋点方法,生成 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 定义,在页面元素不发生变更的状况下,基本可以保证『稳定性』和『惟一性』,可是页面元素发送动态变化,或者不一样版本之间 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 是一个基于字节码操做的 AOP 框架,它容许开发者自由的在一个已经编译好的类中添加新的方法,或是修改已经存在的方法。可是和其余的相似库不一样的是,Javassist 并不要求开发者对字节码方面具备多么深刻的了解,一样的,它也容许开发者忽略被修改的类自己的细节和结构。一个简单的修改方法体的例子以下:
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 的工做流程进行代码插桩:
CtClass
对象onResume()
方法,控件就找 onClick(View view)
方法CtMethod
对象CtMethod
对象的编辑方法体的 API,在原始方法体以前插入就调用 insertBefore
,以后就调用 insertAfter
,传入须要插入的代码块CtClass
的 writeFile()
方法,保存此次编辑将项目中全部的源文件遍历一边后,咱们就完成了整个项目代码的插桩,在咱们想要的切入点(页面的曝光、控件的点击等回调函数),就成功的插入了相应捕获页面、控件对象的代码,在页面曝光或者控件点击时,就可以得到相应的对象,生成惟一 ID 并上报相应的埋点事件,完成整一个无痕埋点的流程了。
完成阶段一的无痕埋点以后,咱们能够经过接入一个 SDK 来轻松的实现页面曝光、控件点击等指标的数据获取,可是经过上文咱们能够知道,咱们定义的 ID 其实对于业务方(产品、运营、BI 等非业务开发人员)而言是不友好的,他们没法根据 ID 中的类名、Resource ID 等特征信息来关联到埋点具体的业务含义,所以咱们须要经过一些工具来帮助他们将埋点元素 ID 和具体的业务含义进行关联,甚至是跨平台(Android、iOS 的自动埋点 ID 是不一致的)的关联。
从另一个角度来讲,有了这样的可视化管理后台,咱们还能够经过下发配置表的方式来收集想要的埋点,这其实就是咱们开篇说的可视化埋点。因此有了这样的管理后台并基于自动埋点的数据采集方式,咱们能够根据具体的业务场景,灵活的选择是无痕埋点(全量采集)仍是可视化埋点(根据配置表定向采集)。
一个简单的用户操做可视化管理后台的时序图以下:
从图中咱们能够知道,可视化管理后台的核心内容就是上传手机界面截图及控件相关信息,可让用户在后台对相关的页面、控件与自定义的业务 ID 进行绑定并在后台生成配置,界面实际效果以下:
在上图的可视化管理平台中,主要有这么几大块内容,最上方是当前和管理后台创建链接的设备信息,左下方是当前界面已经绑定过自定义业务 ID 的埋点元数据,右下方是手机当前界面在管理平台上的映射,并标记出界面内全部可埋点的控件,已绑定过自定义业务 ID 的控件标记绿色,未绑定的标记红色,这样用户就能够很是方便的选择本身想要的控件进行操做。
要实现上图这样的效果,咱们只须要遍历当前页面,并上传全部可被埋点的控件信息,对于目前咱们想要实现的数据指标而言,咱们只关心控件的点击和长按事件,换句话说就是咱们只须要找到当前页面内全部的可被点击或长按的控件便可。
对于须要上报的控件须要知足如下几个条件:
对于控件是否可被点击或长按,咱们无法直接经过系统的 API 来获取,可是经过源码咱们能够看到,View 内部仍是有私有变量来存储点击或长按的监听器的,在 API14 以前的 mOnClickListener 对象和 API14 以后的 mListenerInfo 对象,都可用来判断当前 View 对象是否被设置了点击监听函数,咱们能够经过反射来拿到这些对象,并进行判断,长按的判断也同理,核心代码以下:
处理完可被点击或长按的条件后,咱们要判断控件在当前界面是否可见,由于咱们须要在截图上把控件全选出来,若是控件自己是不可见的也被圈出来,用户就会比较迷茫。经过必定的调研,咱们发现知足如下几点条件,即表示该控件在屏幕内可见:
判断 View 自己可见性属性
View 自己可见性属性比较容易判断,咱们只须要判断 View.isShown()
而且 View.getVisibility() == View.VISIBLE
便可。
判断 View 所处的位置是否在当前屏幕内
一个 Activity 加载了多 Fragment 的状况下,可能会出现控件自己可见性属性达标,但实际并不在屏幕内的状况。这种状况咱们根据 View.getLocationOnScreen(int[] outLocation)
,而后经过判断 outLocation[0]
,是否大于等于 0 且小于等于屏幕宽度,就能判断控件是否在当前屏幕内。
判断控件是否被其余控件彻底遮挡
遍历全部与该控件有关联的控件(同层控件、父控件、父控件的同层控件等),经过 View.getGlobalVisibleRect(Rect viewRect)
来获得控件所对应的 Rect 信息,而后经过 Rect.contains(Rect r)
来判断两个控件对应的 Rect 是否彻底包含便可。
控件符合上述的可被点击或长按且在当前界面可见这两个条件,其信息就会被并上传至管理后台,用户就能够对这个控件进行编辑,绑定自定义的业务 ID,管理后台获得控件与自定义业务 ID 的关联关系后,便可生成配置表,并下发至 App。这样采集上来的埋点就会带上自定义业务 ID,用户在后续的数据使用过程当中就能够很是方便的查看相应的业务指标。
可视化管理后台核心的逻辑就是上述的客户端和管理后台创建链接并上传相应信息,其余配置的生成、下发等都很是容易处理,就不在赘述。
文章开头咱们有提到过,不管是无痕埋点仍是可视化埋点,都是基于自动化采集埋点的方式来作的,在这样的采集方式下,咱们没法经过埋点携带更多的信息,这也是咱们面临的一个痛点。基于这样的需求之下,咱们考虑能够用DSL来解决这个问题。
DSL 即 Domain-specific language,翻译为领域特定语言,意为在特定领域解决特定任务的语言。
上文提到的自动埋点以页面和控件为切入点,hook 页面曝光和控件点击事件,并获取页面及控件相关信息做为特征值写入埋点。在简单的场景下,这样的逻辑尚可胜任,但在某些复杂的场景,好比典型的 banner 轮播、资源位曝光等,控件相同但实际内容不一样的埋点,没法根据控件信息来区分。对于手动埋点而言,获取接口内的信息,而后传入埋点就能进行区分,可是自动埋点没法关联这部分接口信息,因而须要 DSL 来定义简单的规则,经过运行时的方式来获取内存中的这部分数据,从而写入埋点,进行更加精细的区分。
DSL 的构建与编程语言其实比较相似,想一想咱们在从新实现编程语言时,须要作那些事情;实现编程语言的过程能够简化为定义语法与语义,而后实现编译器或者解释器的过程,而 DSL 的实现与它也很是相似,咱们也须要对 DSL 进行语法与语义上的设计。总结下来,实现 DSL 总共有这么两个须要完成的工做:
这部分实际上是千人千面的,咱们能够根据本身的业务需求来不断的迭代,可是核心思路是定义一些特殊的字符串,并对应调用各自的 API,一些简单的语法大体有如下这些:
.
来标识对象调用,好比 test.a
表示实例 test
中的 a
字段.()
来表示方法调用,好比 test.test()
表示实例 test
中的 test()
方法调用[]
来表示数组或列表说是解释器,其实只是一段预先写好在 SDK 内的代码逻辑。经过预先约定好的语法和语义,业务开发者在可视化平台针对某个控件进行代码编写,而后下发这部分代码,SDK 根据规则解析这部分代码,而后经过反射(runtime)的方式来获取相应的数据并写入自动埋点。
可视化平台在元素录入的时候或者后期编辑的时候,能够额外录入事件发生时想要获取的数据的路径,这部份内容须要由业务开发人员根据 SDK 这边给出的规则进行路径的录入。成功录入后,生成配置文件下发至 App。SDK 在事件发生时,获取到相应事件携带的数据路径,根据 DSL 约定的规则解析路径并获取相应的数据,存放至埋点相应字段内上传。
从最先的手动埋点到后续的无痕埋点,再到可视化管理平台的搭建,以及 DSL 的实现,一步步的走来咱们能够看到虽然相比手动埋点而言,自动埋点有许多优点,但一样其劣势也很是明显,即便咱们经过一些工具、技术去不断的优化和弥补它的不足,但他依旧不能彻底的替代手动埋点。因此结合业务自己的特色,选择最合适的埋点采集方式才是最正确的作法,在一些相对稳定,不常变更的页面、控件中使用自动埋点,能够极大的节省各个环节的时间;但若是页面、控件自己是频繁迭代的那自动埋点就不如手动埋点来的合适。