一、前言
随着公司业务快速迭代发展,埋点业务需求大量增加,原先埋点框架已经无法快速、准确的支持公司的业务要求。主要表现为:
页面PV/UV不准确: 各个页面需要手动采集,采集时机受具体业务影响,造成数据不准确。
历史埋点无效: 业务逻辑中需要植入大量埋点代码,日常迭代易造成历史埋点无效。
数据丢失率高: 数据存储在内存中,切换APP、异常退出时易丢失。
造成页面卡顿:数据采集和处理均在UI线程中,存在大量埋点业务时容易发送页面卡顿,如首页。
数据错误:埋点事件间数据采集没有隔离,互相影响导致数据被覆盖。
数据埋点作为电商APP重要的采集手段,可以将用户行为信息转化为数据资产,为产品分析、业务决策、广告推荐、运营等提供可靠的数据支撑。因此,设计好埋点模型和合理采集方案,使埋点能够自动化、工具化和平台化,才能有效的保障埋点的质量。
为了解决旧埋点方案存在的问题,我们重新设计了一套埋点解决方案。
二、架构设计及技术实现
2.1、数据设计
我们将埋点事件拆分为两大类事件 页面事件和行为事件。即 用户在什么时间和什么环境下对玩物得志APP某个页面有哪些行为事件。根据这埋点事件模型,我们分拆成数据的体现如下:
{
event // 事件名称,使用英文字母
env // 区分测试、预发、生产环境(develop, pre, production)
timestamp // 上报的时间戳(毫秒值时间戳)
uuid // 未注册用户的唯一标识
userId // 用户唯一标识
url // 当前访问页面的标识
basic {
// 用户的系统、网络、权限等默认采集的环境参数
}
ext {
pageParam //打开此页面的携带信息
// 不同业务规定的自定义上报信息
}
refer // 页面的上一个访问页面的标识
rtpCnt: 'a.b.c.d.e' // 本页面的rtp_cnt
rtpRefer:'a.b.c.d.e' // 上一个模块的rtpCnt 用来跟踪页面来源
}
这样,我们就能根据一条事件数据,清晰用户的完整行为。
2.2、底层设计
为了保障数据的正确性和提高开发效率,我们要把埋点和业务进行解耦,在合适的时机进行自动采集。所以,我们将业务的生命周期进行抽象成埋点生命周期,将埋点事件拆分为页面事件、Element事件、弹窗事件、列表事件 四大事件类型。并且这四大事件类型的均为独立埋点行为,以此保证数据独立性。这样一套自动化数据采集、元数据管理、数据预处理、数据存储和上报才能更好的支撑未来多样性的埋点业务需求。整体架构设计图如下:
2.2.1、页面事件
该类事件中,主要关注为页面的曝光和停留时间。在Android中,页面主要为分为Activity和Fragment。不同的调用方式其生命周期回调也不相同,依托旧埋点方案,无法进行统一的收口。解析埋点业务需求后,发现页面事件只需要涉及 页面的创建(onCreate)、显示(onShow)、隐藏(onHide)、销毁(onDestory)四个状态。所以,我们需要对具体的业务页面进行生命周期抽离,精准的进行页面事件采集。
如何管控Activity的生命周期?
我们可以在在全局 application.registerActivityLifecycleCallbacks() 设置生命周期监听,就可以在各个方法回调中,抽离成Page生命周期,对应调用如下:
如何管控Fragment的生命周期?
针对Fragment的情况,不同的使用情况会触发Fragment的不同的生命周期回调,所以我们无法进行全局监听。具体表现为如下2个情况:
非ViewPager情况,以add()/show()/hide()直接使用的。
初次加载时,并不会回调onHiddenChanged(hiddenFlag)。
ViewPager情况,以Adapter使用的。
初次加载时,会先回调setUserVisibleHint(false) 包含缓存页面。然后在实际页面中再次回调setUserVisibleHint(true)
根据上述2种情况,我们在setUserVisibleHint()方法中判断当前Fragment是否存在ViewPager中。然后进行生命周期的抽离和对应。其对应调用方式如下: 以上只能处理一级Fragment的情况,针对复杂的嵌套Framgent的情况,还需要处理进行父Fragment状态分发,子Fragment根据父Fragment的状态进行重置其Page生命周期。如果使用Adapter的behavior为 BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT 那么还需要在Fragment的onResume中进行调用page.onShow()等。
根据上述方案,我们已经可以实现Activity/Fragment的生命周期管控,可以拿到页面的实例用来获取页面数据,并将整个页面生命周期与数据进行保存在埋点SDK的内存中。
2.2.2、Element事件
View的曝光比较简单。只需要抽离已有的生命周期进行管控即可。
View的点击实现方案一般如下4种方式
1.Listener代理方案
该方案通过自定义监听代理类ProxyListener,实现View.OnClickListener中的onClick方法,将控件的onClickListener统一替换成ProxyListener。采用该方案,会对已有代码的点击事件进行替换,而且业务实现类必须实现super方法,成本较大。
2.Hook方案
该方案降低了Listener代理方案的成本问题,主要思路为递归遍历所有的控件View,通过反射手段将其替换成ProxyListener代理类。但是依旧存在super方法必须实现问题。
2.TouchListener方案
该方案为点击事件响应链上的具体事件分发函数,通过继承FrameLayout,覆写该函数,实现对于所有点击事件的监听。无法精准的感知为点击行为或者滑动行为。
3.dispatchTouchEvent方案
该方案为辅助功能,其辅助主体主要是APP上的具体控件View,可检测控件点击,选中,滑动,文本变化等,当该view的相关属性出现变化时,将回调AccessibilityDelegate中的sendAccessibilityEvent,具体事件类型通过AccessibilityEvent来区分。
根据以上方案的对比,我们最终采用了AccessibilityDelegate方案,具体实现为在View被添加屏幕时,递归遍历每个存在点击事件的View,进行AccessibilityDelegate的绑定。根据TYPE_VIEW_CLICKED实现对View的监听点击行为。
2.2.3、弹窗事件
弹窗的实现在玩物得志中也有DialogFragment、AlertDialog两种情况,但是不论哪种实现,均有个共同的Dialog类。所以,我们抽离如下的生命周期:
基于该生命周期的抽离,我们也能很好的处理弹窗的曝光和停留事件。
那么弹窗内的View点击事件,就可以在dialog.onBind()的时候,采用AccessibilityDelegate方案遍历contentView,即可完成点击事件的绑定。
2.2.4、列表事件
列表的曝光和点击较为复杂
-
一方面在于业务数据与埋点的强关联性,但是其业务数据格式存在不确定性。 -
另外一方面在于其需要列表和其子项单独的生命周期抽离。 基于此,我们将埋点设计在数据侧。即,定义一个agentTraceInfo_字段,该字段内容由后端接口返回。客户端不做处理,针对其Adapter的抽离生命周期,用于列表的管控,对应调用如下:
由于列表可以滑动的性质,如果在滑动的过程中进行采集,会导致滑动卡顿。所以,我们采用在滑动结束的时候,对产生的子项通过反射获取数据模型的是否存在agentTraceInfo_内容,从而进行数据的进行收集处理。
列表的View点击事件的处理,与View的一致,在onChildViewAttachedToWindow中,采用AccessibilityDelegate方案遍历子项view,完成点击事件的绑定。
2.2.5、自动化采集
以上各个事件的生命周期管控处理,均需要在指定的原始生命周期中插入管控代码。如果依靠手动插入,依旧会存在丢失和错误的风险。所以我们采用 transform-api 和javassist的方式实现编译期间自动插桩。
什么是 transform-api?
Transform-api 是 gradle插件自带一个API,该API允许第三方插件在class文件转为dex文件前操作编译好的class文件。
什么是 javassist?
Javassist是一个实现在编译的过程中操作.class文件,动态注入代码。可以用来分析、编辑和创建Java字节码的开源类库。
那么使用 transform-api 和 javassist 就可以完成自动化插桩。具体使用步骤如下:
1.自定义Transform 并且继承系统的Transform,重写transform方法。
2.在transform方法中获取编译好的.class文件,然后判断当前CtClass对象是否有埋点注解或者采集接口类。
3.如果存在就判断该类是否实现类需要插桩的方法,如果存在方法,就获取对应的CtMethod对象,完成插桩,如果不存在则创建CtMethod对象,写入到CtClass中,然后再完成插桩。
4.调用CtClass的writeFile()方法,保存此次编辑。
所有工程文件遍历一遍后,我们就自动化完成了插桩代码。无特殊业务的情况下,不需要开发人员参与我们就能采集对应的基础数据。
2.3、数据处理
上述的各个事件采集方案,均为独立的数据行为,产生的数据无法完整的描述一次用户行为。所以我们需要对数据进行预处理后并且保存到SQLite数据库中。
由于数据的组装和Gson的处理比较耗性能,而且需要保证时序性,所以我们采用SingleThreadExecutor线程池,在子线程中对每条平行数据进行组装设备信息环境数据和页面数据。
这里需要注意的是,因为抽离各个生命周期后,每种类型的事件的采集都互相隔离。所以Activity/Fragment页面采集时会往rootView中设置Tag为自身标示。各个事件采集时获取到的View,可以向上查找,获取对应的Page的Tag。根据Tag就可以从埋点页面管理库中获取相应的Page数据。如果获取到Tag或者当前无View,则以最后一次上报的Page为准。这样就完成了非Page事件与Page数据的绑定。
数据组装完成后,我们将其保存至SQLite中,同时通知上报服务。
2.4、数据上报
根据以上采集和预处理完成的数据,均会以JsonString的形式保存在SQLite数据库中。而在埋点库初始化后,会创建一个监控服务。该服务会在埋点数据保存后重新检测自身是否存活,保证自身在APP活跃期间一直有效。为了满足对数据的完整上报和异常情况下的上报,我们设计了多个触发点。设计方案如下:
三、总结
新的埋点框架上线后,提升了60%的埋点开发效率,极大的降低了业务人员的开发成本。解决了旧埋点框架存在数据缺失、不准确、事件数据互相影响等问题,同时由于埋点和功能代码的解耦,日常迭代对埋点影响的次数也明显减少;在采集和处理峰值,对内存做了一定优化,保证了用户体验的流畅性。
同时我们使用工具化、平台化的手段来管理埋点开发三个阶段:录入、开发、线上验证。
针对录入阶段,推进产品侧使用“观星”埋点录入系统,保证了埋点数据录入的一致性和合理性。开发阶段,摒弃了之前依靠抓包这种的效率低下的方式,使用自主研发的“GodEye”软件(实时性,UI交互,可定制化等都更加友好)来进行埋点自测验证,极大的提高了开发效率。线上验证,在客户端版本灰度阶段,做好线上数据格式实时校验,以及核心业务大盘数据的波动预警,可以第一时间发现问题并解决。
若有收获,就点个赞吧