58无埋点数据采集技术在Android端实践

本文转载自公众号 58架构师,已经作者授权!

作为国内最大分类信息生活服务平台,58集团旗下各个产品都会投入大量人力进行用户行为的分析,来提升运营效率。但是各个产品对用户行为的分析需求基本是相似的。在这样的背景下,我们自研了WMDA 无埋点用户行为分析平台,并提供对PC、M、APP三端支持,帮助各个业务线更好的挖掘用户真实行为。

对于SDK的使用,业务方不需要手动埋点,几行代码,即可实现数据的全量采集。对于移动端SDK来说,采集数据的准确性、及时性、全面性等因素直接决定后续用户行为的分析。本文将从技术选型、技术实现方案角度详细介绍Android端无埋点数据采集技术。

一、技术选型

首先,技术是为需求提供服务的,WMDA的定位是采用无埋点技术来实现用户行为的分析。同时辅助解决手动埋点不易维护,容易出现错埋、漏埋等痛点问题。所以SDK在采集用户行为数据的同时,对开发效率、采集性能、准确性、实时性等有很高的要求,而且需要支持数据的可回溯。

通过对市面上现有埋点技术调研,目前技术方案上大体分为三类:

  1. 传统代码埋点 实现方案:Coding阶段手动埋点。 代表解决方案:友盟、百度统计。 优点:灵活、准确,可以定制化。 缺点:业务埋点量非常大,开发成本高,不易维护,如果要修改、新增埋点,需要重新发版。
  2. 动态埋点 实现方案:利用AccessibilityDelegate对每个view实例设置代理,监听控件点击事件。 代表方案:Github上开源的Mixpanel 优点:无需手动埋点,通过可视化圈选,动态下发配置监听指定控件。 缺点:不支持数据可回溯,采集不到Fragment页面数据,只支持API 14及以上,同时该监听方式对app性能影响严重,每个控件都需要动态绑定,在界面变更时,需要重新刷新ViewTree,效率低下。
  3. 编译时字节码插桩埋点 实现方案:利用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,然后在事件处理模块处理,将其格式化成事件结构,并进行存储上报。

@Override
public void onActivityResumed(Activity activity) {
    // 页面浏览事件采集处理
    PageManager.getInstance().onActivityResumed(activity);
}

不过该方案有个适配问题,在Android4.0(API14)以下,系统并不支持该方法。

注:目前App最低版本基本都是android 4.0及以上

在低版本中,我们也可以通过Hook方式拦截。通过拦截主线程的Instrumentation实例,来实现低版本页面的监听。这块同时还需要考虑第三方插件也Hook该实例的情况,执行Hook前对应方法,保证对app中其他插件没有影响。缺点是如果其他SDK也使用了这种方式,可能会影响我们的拦截。

@Override
public 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/Fragment
android/support/v4/app/Fragment

对Fragment相关方法注入代码示例:

@Override
MethodVisitor 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方式对事件拦截处理。核心实现思路如下:

插件代码

@Override
MethodVisitor 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无埋点用户行为分析实践之路这篇的圈选部分,这里就不做重复介绍了。

通常,我们圈选时会在一个页面停留较长时间,这时其实是不需要一直将当前页面快照数据发送给服务端的,因为页面并没有变化。这块有一个优化策略,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端实践。包括字节码插桩在无埋点的使用、对采集事件的处理等。同时现阶段无埋点技术还是存在一些问题需要我们后续探索和解决,欢迎感兴趣的同学和我们一起交流。

原文发布于微信公众号 - BaronTalk(BaronTalk)

原文发表时间:2018-06-15

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏张善友的专栏

zookeeper 分布式锁服务

分布式锁服务在大家的项目中或许用的不多,因为大家都把排他放在数据库那一层来挡。当大量的行锁、表锁、事务充斥着数据库的时候。一般web应用很多的瓶颈都在数据库上,...

23280
来自专栏腾讯移动品质中心TMQ的专栏

【浅谈Chromium中的设计模式(一)】——Chromium中模块分层和进程模型

“EP”(中文:工程生产力)是目前项目中提升研发能力的一个很重要的衡量指标。笔者重点学习了Chromium产品是如何从代码和设计层面来保证快速高效的工程生产力。...

54180
来自专栏数据科学与人工智能

【Python环境】Python爬虫入门(2):爬虫基础了解

1.什么是爬虫 爬虫,即网络爬虫,大家可以理解为在网络上爬行的一直蜘蛛,互联网就比作一张大网,而爬虫便是在这张网上爬来爬去的蜘蛛咯,如果它遇到资源,那么它就会抓...

28590
来自专栏Keegan小钢

App架构经验总结(三)

原文链接:http://keeganlee.me/post/architecture/20160303 版权声明:本文刊载在《程序员》杂志2016年3期,版权归...

15250
来自专栏Golang语言社区

【Go 语言社区】关于Golang 数据缓存到redis内存数据库遇到的问题

首先, 简单的说下,redis 在项目中的一个作用;针对与大数据在内存操作数据和子数据库操作数据可能都不是一个数量级的,redis在项目中主要是起到...

421130
来自专栏Golang语言社区

Go 1.11 Beta 3 发布

版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

10420
来自专栏我的安全视界观

[一起玩蛇】Python代码审计中的器II

32670
来自专栏Android 开发者

[译]Android 生命周期备忘录 — 第一部分:单一 Activities

21330
来自专栏zhisheng

Python爬虫入门二之爬虫基础了解

1.什么是爬虫 爬虫,即网络爬虫,大家可以理解为在网络上爬行的一直蜘蛛,互联网就比作一张大网,而爬虫便是在这张网上爬来爬去的蜘蛛咯,如果它遇到资源,那么它就会抓...

35760
来自专栏乐百川的学习频道

在虚拟机安装OpenSuse Tumbleweed

一说起滚动发行版Linux,大家想到的常常是ArchLinux和Gentoo。滚动发行版的优点是不存在固定版本号,所有软件都可以独立更新,所以整个系统都是最新的...

35870

扫码关注云+社区

领取腾讯云代金券