作为国内最大分类信息生活服务平台, 58 集团旗下各个产品都会投入大量人力进行用户行为的分析, 来提升运营效率. 但是各个产品对用户行为的分析需求基本是相似的. 在这样的背景下, 我们自研了 WMDA 无埋点用户行为分析平台, 并提供对 PC,M,APP 三端支持, 帮助各个业务线更好的挖掘用户真实行为.
对于 SDK 的使用, 业务方不需要手动埋点, 几行代码, 即可实现数据的全量采集. 对于移动端 SDK 来说, 采集数据的准确性, 及时性, 全面性等因素直接决定后续用户行为的分析. 本文将从技术选型, 技术实现方案角度详细介绍 Android 端无埋点数据采集技术.
一, 技术选型
首先, 技术是为需求提供服务的, WMDA 的定位是采用无埋点技术来实现用户行为的分析. 同时辅助解决手动埋点不易维护, 容易出现错埋, 漏埋等痛点问题. 所以 SDK 在采集用户行为数据的同时, 对开发效率, 采集性能, 准确性, 实时性等有很高的要求, 而且需要支持数据的可回溯.
通过对市面上现有埋点技术调研, 目前技术方案上大体分为三类:
传统代码埋点
实现方案: Coding 阶段手动埋点.
代表解决方案: 友盟, 百度统计.
优点: 灵活, 准确, 可以定制化.
缺点: 业务埋点量非常大, 开发成本高, 不易维护, 如果要修改, 新增埋点, 需要重新发版.
动态埋点
实现方案: 利用 AccessibilityDelegate 对每个 view 实例设置代理, 监听控件点击事件.
代表方案: Github 上开源的 Mixpanel
优点: 无需手动埋点, 通过可视化圈选, 动态下发配置监听指定控件.
缺点: 不支持数据可回溯, 采集不到 Fragment 页面数据, 只支持 API 14 及以上, 同时该监听方式对 app 性能影响严重, 每个控件都需要动态绑定, 在界面变更时, 需要重新刷新 ViewTree, 效率低下.
编译时字节码插桩埋点
实现方案: 利用 Gradle 插件, 在编译阶段在代码中插入埋点代码, 进行数据采集.
代表方案: GrowingIO, 美团的替换 UI 控件方案.
优点: 开发效率高, 无需手动埋点, 编译时插入代码, 性能高, 支持数据可回溯.
缺点: 埋点灵活性低.
通过以上简短分析, 我们可以看出三种方案的优缺点都比较明显. 最后, 我们采取了利用 Gradle 插件自动注入埋点代码为主, 并辅以手动埋点进行数据定制化补全的技术方案.
注: 通过查看 GrowingIO 官方文档, GrowingIO 现在也已提供对手动埋点的支持
二, 技术实现
WMDA SDK Android 端整体架构主要分为圈选模块, 事件采集上报, 配置管理三部分, 如下图所示.
下面根据事件采集上报流程分别来介绍事件采集, 处理, 存储, 上报和圈选.
2.1 事件采集
WMDA 移动端数据采集类型主要分三种: 页面浏览事件, 控件点击事件和自定义事件. 作为无埋点解决方案, SDK 核心点就是事件的无痕采集. 其中, 这三种事件又对应不同的采集处理方式, WMDA 通过不同的技术方案进行采集, 最后将事件统一处理, 然后存储, 上报.
2.1.1 插桩入口
事件采集是无埋点技术的核心, 其中 WMDA 对 Fragment 和控件点击事件拦截, 使用的是自己开发的 gradle 插件 wmda plugin, 编译时使用 ASM 以字节码插桩的方式注入代码, 实现事件的采集.
对于事件拦截, 首先需要确定插入时机和待修改字节码文件. 这里我们使用 Transform API 作为插桩入口, 在 Java Compiler 之后, class 文件打包成 dex 文件之前修改字节码文件. 由于 Transform API 是在 Gradle 插件版本 1.5.0 出现的, 所以项目开发中 Gradle 插件版本不能低于 1.5.0.
classpath 'com.android.tools.build:gradle:1.5.0'
然后在 transform 中遍历并操作字节码文件, 因为现在很多大型项目, 都会进行组件化操作, 拆分成多个 Library. 所以这里除了要修改我们的应用源码, 还需要对第三方库中的字节码文件进行扫描操作.
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException { inputs.each { TransformInput input -> input.directoryInputs.each { DirectoryInput directoryInput -> // 对应用源码生成的 class 操作 } input.jarInputs.each { JarInput jarInput -> // 对第三方库中 class 操作 } }}
同时我们也引入了注入白名单机制, 如果 app 不希望某些包名下的类被注入代码, 比如使用的第三方 sdk, 可以在项目级别根目录下创建 wmda-rules.properties 文件并填写不希望注入的类路径. 以下为示例, 可以添加多个, 每行一条, 以 "/" 结尾:
com/wuba/sdk/com/wuba/test/
2.1.2 页面事件
针对页面浏览事件, WMDA 分两种不同的方式进行采集.
2.1.2.1 Activity 采集 针对 Activity,WMDA 采用的方式是使用 LifecycleCallback 来监听页面的开启和关闭. 在页面开启时, 拦截生命周期方法 onResume, 然后在事件处理模块处理, 将其格式化成事件结构, 并进行存储上报.
@Overridepublic void onActivityResumed(Activity activity) { // 页面浏览事件采集处理 PageManager.getInstance().onActivityResumed(activity);}
不过该方案有个适配问题, 在 Android4.0(API14) 以下, 系统并不支持该方法.
注: 目前 App 最低版本基本都是 android 4.0 及以上
在低版本中, 我们也可以通过 Hook 方式拦截. 通过拦截主线程的 Instrumentation 实例, 来实现低版本页面的监听. 这块同时还需要考虑第三方插件也 Hook 该实例的情况, 执行 Hook 前对应方法, 保证对 app 中其他插件没有影响. 缺点是如果其他 SDK 也使用了这种方式, 可能会影响我们的拦截.
@Overridepublic void callActivityOnResume(Activity activity) { // 页面浏览事件采集处理 PageManager.getInstance().onActivityResumed(activity); // 执行 Hook 前 Instrumentation 实例的 onResume 方法 oldInstrumentation.callActivityOnResume(activity);}
2.1.2.2 Fragment 采集 针对 Fragment, 由于 Android 系统并没有关于 Fragment 生命周期的回调监听, 所以这里 WMDA 通过 Gradle 插件, 在编译时期, 利用 ASM 库进行字节码操作, 对 Fragment 注入 WmdaAgent 相应的页面采集方法, 完成事件采集. 在注入策略上, 我们只需要对 Fragment 父类为下面两个的页面注入采集代码即可.
android/app/Fragmentandroid/support/v4/app/Fragment
对 Fragment 相关方法注入代码示例:
@OverrideMethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); MethodVisitor wrappedMv = mv; if (wrappedMv != null) { // 在 onResume 中插入 WmdaAgent.onFragmentResumed 方法 if (name.equals("onResume") && desc.equals("()V")) { wrappedMv.visitCode() wrappedMv.visitVarInsn(Opcodes.ALOAD, 0) wrappedMv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/wuba/wmda/autobury/WmdaAgent", "onFragmentResumed", "(Landroid/app/Fragment;)V") } }}
WmdaAgent 代码:
public static void onFragmentResumed(Fragment fragment) { // 页面浏览事件采集处理 PageManager.getInstance().onFragmentResumed(fragment);}
2.1.3 控件点击事件
关于点击事件的采集, WMDA 在早期研发过程中采取的是 Mixpanel 开源方案. 在上文中已经提到过, 该方案开发效率不错, 不过性能问题, Fragment 页面采集不到问题, 版本适配问题, 导致该方案存在瓶颈和风险.
在后续的持续探索中, 我们发现, 使用 Gradle 插件在编译时埋点可以完美继承 Mixpanel 方案的各项优点, 同时又可以规避其性能, 数据准确性和版本适配问题. 于是, 在控件点击事件的采集上, 我们调整了技术实现方案, 从动态对 View 设置代理演进为编译时插入埋点代码.
WMDA 对点击事件拦截支持常用的一些第三方框架, 比如:
ButterKnife,databinding,AndroidAnnotations,RxBinding
具体的技术和之前的 Fragment 插桩埋点是一样的, 编译时对 onClick 方法注入代码, 以 AOP 方式对事件拦截处理. 核心实现思路如下:
插件代码
@OverrideMethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); MethodVisitor wrappedMv = mv; if (wrappedMv != null) { // 查找出方法名为 onClick, 入参为 View 的方法, 注入 WmdaAgent.onViewClick(View view) if (name.equals("onClick") && desc.equals("(Landroid/view/View;)V")) { wrappedMv.visitCode() wrappedMv.visitVarInsn(ALOAD, 1); wrappedMv.visitMethodInsn(INVOKESTATIC, "com/wuba/wmda/autobury/WmdaAgent", "onViewClick", "(Landroid/view/View;)V", false); } }}
WmdaAgent 对应代码:
public static void onViewClick(View view){ // 控件点击事件采集处理 AutoEventManager.getInstance().onEvent(view);}
2.1.4 自定义事件
无埋点是 WMDA 的核心功能, 但是由于业务场景特点, 无埋点并不能完全满足所有业务场景需求, 所以 WMDA 也提供了对手动埋点支持, 使得 WMDA 在实际的使用中更加灵活, 数据统计也更全面.
这部分没有特别关键的技术点, 就是普通的代码埋点, 这里就不做过多介绍了.
2.2 事件处理
事件收集完成之后, 就会发送到事件处理线程, 对原生的事件进行加工, 以便服务端能更好的进行分析处理. 在页面事件处理中, 我们将页面的 class 全路径作为页面的特征值. APP_PAGE 示例:
com.wuba.wmdademo.MainActivity
采集到页面事件后将其传入子线程处理, 然后再提取出业务开发人员在页面 onCreate 方法中设置的页面 ID 及页面自定义属性, 将这些数据统一格式化, 构造成页面浏览事件, 传给事件存储模块.
在对控件事件处理中, 我们面临一个最大的问题就是, 如何区分每一个控件, 即如何定义控件的特征值. 在这里, 我们借鉴了 Mixpanel 的方法, 即将 View 自身的类名及 index, 以及其逐级向上的所有父 View 的类名和 index 统一收集起来, 然后将所有遍历信息拼接起来, 当做该 View 在当前页面的唯一特征值. 控件的唯一标识: 页面 APP_PAGE + ViewPath + index
ViewPath 举例:
/MainWindow/LinearLayout[0]/FrameLayout[1]/ActionBarOverlayLayout[0]#decor_content_parent/ContentFrameLayout[0]/LinearLayout[0]/ScrollView[0]/LinearLayout[0]/AppCompatButton
对于采集事件的后续处理, 我们在 UI 性能上做了进一步优化. 由于采用字节码插桩方式拦截事件, 所以事件处理最耗时的点其实是在生成 View 特征值. 在 Android 中, 由于在子线程持有, 操作 view 会引发内存泄漏问题.
在 WMDA 中, 我们将构造特征值方法进行了拆分, 在 UI 线程只进行对 View 数据提取, 可以理解为定向的 View 遍历拷贝, 除此之外不做任何其他耗时操作, 然后将拷贝完成的 ViewData 传递给子线程, 构造特征值, 整合数据构造格式化的点击事件, 最后再将事件传给存储模块.
点击事件处理时序图如下:
2.3 事件存储
事件处理完成之后, 要交由存储模块进行本地持久化. 在存储之前, 先会检查存储策略, 满足策略后再进行存储.
存储这里, 使用的是本地 SQLite 存储 Protobuf 实例的二进制, 然后使用 AES-256 进行加密存储.
2.4 事件上报
事件存储完成之后, 会触发上报请求. 在上报之前, WMDA 会先检查上报策略, 满足策略后进行上报.
上报这里为了缩小 WMDA 包, 只使用了 HttpUrlConnection 来处理网络操作. 在数据上报的时候使用了 GZIP+ProtoBuf 来减少流量消耗, 保证收集数据的同时, 提升用户体验.
2.5 圈选模块
之前只是介绍数据采集方案, 数据全量采集上报后, 并不会直接分析处理, 还需要一个圈选指标的过程. 关于圈选的介绍, 大家可以查看数据驱动增长: 58 无埋点用户行为分析实践之路 https://mp.weixin.qq.com/s?__biz=MzI1NDc5MzIxMw==&mid=2247483686&idx=1&sn=06ca2711298cab42e689aebaaa69318e&scene=21#wechat_redirect https://mp.weixin.qq.com/s?__biz=MzI1NDc5MzIxMw==&mid=2247483686&idx=1&sn=06ca2711298cab42e689aebaaa69318e&scene=21#wechat_redirect 这篇的圈选部分, 这里就不做重复介绍了.
通常, 我们圈选时会在一个页面停留较长时间, 这时其实是不需要一直将当前页面快照数据发送给服务端的, 因为页面并没有变化. 这块有一个优化策略, SDK 会根据当前屏幕快照生成一指纹, 只有当前屏幕有变更时才会将当前页面快照数据发给用户分析平台.
2.6 其他技术点
2.6.1 多进程数据采集
子进程中只存在事件采集和事件处理两个模块, 为了保证事件的连续性, 数据的存储和上报则放到主进程来统一进行处理, 这样也避免了数据库的同步问题, 增加了数据的准确性, 提升了系统性能.
因为事件采集是一种触发式的, 所以我们在进程间通信上采用的是应用内广播, 广播的优势是耦合度低, 对子进程影响较小, 同时性能相对来说可以接受. 应用场景是技术选择的重要参考依据, 所以这里并没有用 Socket 或者是 AIDL 来处理进程间通信.
2.6.2 多进程界面圈选
考虑到圈选是一个实时, 持续的过程, 所以 SDK 采用 Socket 方式实现进程间通信, 所有子进程都将页面快照信息发给主进程, 由主进程和服务端交互.
三, 现存问题
当然, 现阶段无埋点技术采用的字节码插桩方案还是存在一些短板, 需要我们后续探索和解决.
click 监听如果是在 layout 中使用
android:onClick="xxxMethod"
设置的暂时无法进行采集. 这个设置监听的方法是利用 Java 的反射原理, 去寻找对应的 Method, 在 WMDA 中是通过拦截 OnClickListener 点击事件来进行监听的, 因此无法实现监听.
由于目前采用的是编译时插入埋点, 所以不支持目前比较流行的 RN 框架.
同样因为是编译时插入埋点, 所以对热更新的补丁支持可能也不到位.
四, 总结
本文主要介绍了 58 无埋点数据采集技术在 Android 端实践. 包括字节码插桩在无埋点的使用, 对采集事件的处理等. 同时现阶段无埋点技术还是存在一些问题需要我们后续探索和解决, 欢迎感兴趣的同学和我们一起交流.
来源: https://juejin.im/entry/5b2400bc51882574b55e4fc7