本文共 8334 字,大约阅读时间需要 27 分钟。
背景
随着公司业务的发展,对业务团队的敏捷性和创新性提出了更高的要求,而通过大数据的手段在一定程度上可以帮助我们实现这个愿景,同时良好的数据分析可以也帮助我们进行更好更优的决策。对于数据本身,其处理流程主要可以归结为以下几点:
其中所谓的数据采集是针对特定用户行为或事件进行捕获、处理,这一步骤无疑是十分重要的,因为数据采集的准确性和多样性也会直接对后续的步骤产生影响。本文也主要是讨论数据采集的几种方式,而我们常说的『埋点』就是数据采集领域的术语,数据采集的方式也可以说是埋点的几种方式。
现状、痛点
目前公司内部主要使用代码埋点的方式进行数据采集,所谓代码埋点指的是,在某个事件发生时通过预先写好的代码来发送数据。
基于预先编码实现的代码埋点,其优点是:控制精准、采集灵活性强,可以自由的选择什么时候发送什么样的数据;但缺点也同样十分明显,开发、测试成本高,对于客户端而言需要等待发版才能修改线上的埋点。
日常的开发过程中,经常有同事反馈埋点的错埋及漏埋,其根本原因都是代码埋点本身特点导致,这样的情况推动着我们去尝试使用其他埋点方式。
业内情况
分析公司常用的一些数据指标,我们发现对于大部分指标而言,我们只需要有页面的曝光事件、控件的点击事件等一些发送时机、内容相对固定的埋点即可,而这部分埋点,恰恰可以比较方便的使用自动埋点(相对于代码埋点这种手动埋点来说,无痕埋点及可视化埋点均可被称为自动埋点)来进行采集。
相对于可视化埋点来说,无痕埋点在前期不需要可视化工具进行埋点收集,SDK开发投入较小,因此我们进行了第一步从手动埋点到无痕埋点的迭代。
无痕埋点技术实现
无痕埋点需要自动采集数据,因此针对页面、控件等元素需要生成其ID,该ID需尽量具备『唯一性』和『稳定性』。『唯一性』非常好理解,因为对于任意元素而言,其ID应该是与其他所有元素都不同的,这样我们才能根据ID唯一标识出那个我们想要的元素,采集上来的数据才是准确的,不重复的。而『稳定性』则是说,元素的ID应尽量不受版本的变动而改变,这样后期关联业务含义的操作才会更加便捷。
页面ID规则
页面的ID较容易定义,参考上文提到的『唯一性』和『稳定性』,我们很容易就可以想到将页面所在类的类名作为ID。类名作为ID,首先它是相对唯一的,除了页面复用,不存在其他类名相同的页面,而页面复用的情况可以通过页面标题名称等方式进行规避;其次它是相对稳定的,只有在页面类名被修改的情况下ID才会改变,而我们日常开发的过程中,除了一些页面重大的改版之外不会轻易修改类名。在Android中,页面有两种类型Activity和Fragment,Fragment可以镶嵌在不同的Activity内,因此两者的ID定义规则有些不同:
页面PV、UV
有了页面的唯一ID生成的规则,我们只需要在页面曝光的时候,生成这个ID,然后上传即可实现页面的PV、UV指标。至于页面曝光的时机,在Android开发中很容易可以找到,因为对于Activity和Fragment而言都有标准的生命周期。针对业务中PV、UV的定义,我们可以将Activity的onResume()方法,Fragment的onResume()、setUserVisibleHint(boolean isVisibleToUser)、onHiddenChanged(boolean hidden)方法作为曝光时机,在上述方法被回调时,调用SDK埋点方法,生成ID然后上传埋点。
控件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相比,已经发生了改变,变得不那么『稳定』了,于是我们做了以下的优化:
将兄弟节点中的位置,变成相同类型控件的位置。优化后的控件路径为:FrameLayout[0]/LinearLayout[1]/Button[0],即使在插入FrameLayout后,其路径仍旧不变,相较之前会更加稳定一些。但如果插入的是LinearLayout,或者整个页面的UI进行了重构,控件路径依旧会发生改变。
因为不同的系统版本或手机厂商,会对页面的根View做一定的处理,所以我们需要屏蔽掉这种情况,对于我们而言,我们只关心我们自定义的那部分布局,即通过setContentView传入的布局。我们可以通过判断控件ID是否等于android.R.id.content
来获取我们自定义的布局的根View,并将其作为我们控件路径的起点。
在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的工作流程进行代码插桩:
将项目中所有的源文件遍历一边后,我们就完成了整个项目代码的插桩,在我们想要的切入点(页面的曝光、控件的点击等回调函数),就成功的插入了相应捕获页面、控件对象的代码,在页面曝光或者控件点击时,就能够获得相应的对象,生成唯一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
DSL即Domain-specific language,翻译为领域特定语言,意为在特定领域解决特定任务的语言。
哪些场景下需要用到DSL
上文提到的自动埋点以页面和控件为切入点,hook页面曝光和控件点击事件,并获取页面及控件相关信息作为特征值写入埋点。在简单的场景下,这样的逻辑尚可胜任,但在某些复杂的场景,比如典型的banner轮播、资源位曝光等,控件相同但实际内容不同的埋点,无法根据控件信息来区分。对于手动埋点而言,获取接口内的信息,然后传入埋点就能进行区分,但是自动埋点无法关联这部分接口信息,于是需要DSL来定义简单的规则,通过运行时的方式来获取内存中的这部分数据,从而写入埋点,进行更加精细的区分。
如何实现DSL
DSL的构建与编程语言其实比较类似,想想我们在重新实现编程语言时,需要做那些事情;实现编程语言的过程可以简化为定义语法与语义,然后实现编译器或者解释器的过程,而 DSL 的实现与它也非常类似,我们也需要对 DSL 进行语法与语义上的设计。总结下来,实现 DSL 总共有这么两个需要完成的工作:
设计语法和语义
这部分其实是千人千面的,我们可以根据自己的业务需求来不断的迭代,但是核心思路是定义一些特殊的字符串,并对应调用各自的API,一些简单的语法大致有以下这些:
实现解释器
说是解释器,其实只是一段预先写好在SDK内的代码逻辑。通过预先约定好的语法和语义,业务开发者在可视化平台针对某个控件进行代码编写,然后下发这部分代码,SDK根据规则解析这部分代码,然后通过反射(runtime)的方式来获取相应的数据并写入自动埋点。
平台配套
可视化平台在元素录入的时候或者后期编辑的时候,可以额外录入事件发生时想要获取的数据的路径,这部分内容需要由业务开发人员根据SDK这边给出的规则进行路径的录入。成功录入后,生成配置文件下发至App。SDK在事件发生时,获取到相应事件携带的数据路径,根据DSL约定的规则解析路径并获取相应的数据,存放至埋点相应字段内上传。
从最早的手动埋点到后续的无痕埋点,再到可视化管理平台的搭建,以及DSL的实现,一步步的走来我们可以看到虽然相比手动埋点而言,自动埋点有许多优势,但同样其劣势也非常明显,即使我们通过一些工具、技术去不断的优化和弥补它的不足,但他依旧不能完全的替代手动埋点。所以结合业务本身的特点,选择最合适的埋点采集方式才是最正确的做法,在一些相对稳定,不常变动的页面、控件中使用自动埋点,可以极大的节省各个环节的时间;但如果页面、控件本身是频繁迭代的那自动埋点就不如手动埋点来的合适。
转载地址:http://qbdox.baihongyu.com/