一、前言
埋点是数据采集领域的一个术语,它是指针对特定用户行为或事件进行捕获、处理、上报的过程。埋点技术实质就是在合适的时机去采集行为数据,同时获取必要的上下文信息,最后将行为数据上报到指定的服务端。埋点获取到的业务数据可以为产品后续的迭代方向和评判营销价值提供有力、可靠的数据支撑。
常见的埋点方式主要包括全埋点和代码埋点(又称自定义埋点)。其中,全埋点可以满足 UV、PV、点击量等常见指标统计的需求,适用于以较小的埋点代价收集尽可能多的用户行为数据的场景。
下面将首先针对神策 AndroidSDK 全埋点功能作简要的介绍,然后重点讲解 Android 全埋点插件的作用和实现原理。
二、全埋点介绍
2.1 基本概念
全埋点,也叫无埋点、无码埋点、无痕埋点、自动埋点等。全埋点,是指无需应用程序开发工程师写代码或者只写少量的代码,即可预先自动收集用户的所有或者绝大部分的行为数据,然后再根据实际的业务分析需求从中筛选出所需行为数据并进行分析。神策 Android SDK 全埋点采集的事件目前包括以下四种(事件名称前面的 $ 符号,是指该事件是预置事件):
- $AppStart 事件
是指应用程序启动事件,包括冷启动和热启动场景。冷启动是指在系统中没有该应用的进程时启动应用程序,热启动是指在系统中已有该进程时启动应用程序,热启动也可以理解为从后台打开 App。
- $AppEnd 事件
是指应用程序退出事件,常见的退出场景包括应用程序的正常退出、进入后台、应用程序被强杀、应用程序崩溃等场景。这里需要注意的是神策Android SDK 为了应对多进程、强杀等场景,加入了 30 秒的 session 机制,即用户退出 App 到后台 30 秒的时候才会触发退出事件。
- $AppViewScreen 事件
是指应用程序页面浏览事件,对于 Android 应用程序来说,就是指切换 Activity 或 Fragment。
- $AppClick 事件
是指应用程序控件(View)点击事件,例如:点击 Button、ImageView 等。
2.2 实现原理
实现 App 启动、退出和页面浏览(Activity)的全埋点较为简单,事件的采集围绕 Activity 生命周期展开即可。Android 官方在 Android 4.0 及以上版本提供了Application.ActivityLifecycleCallbacks 接口,调用 Application.registerActivityLifecycleCallbacks 方法并传入 Application.ActivityLifecycleCallbacks 接口的实现类,就可以在实现类中获取到之后所有 Activity 生命周期的回调。这样,我们只需要做一些简单判断就可以实现以上全埋点事件的采集。
实现 App 浏览页面(Fragment)和点击的全埋点则要复杂的多,虽然这两个事件要埋点的位置很清楚(例如:Button 的 OnClickListener.onClick 方法触发就可以视为 Button 的点击),但是并没有一个像 Application.ActivityLifecycleCallbacks 一样全局托管的接口。因此,我们需要利用一些技术在原处理逻辑中 “插入” 我们想要的埋点代码,从而实现自动埋点的效果。神策的 Android 全埋点插件正是为了解决这个问题而推出的。
三、全埋点插件的实现原理
想要自动在我们规定的位置插入特定的埋点代码,需要先了解 Android 的构建流程,如图 3-1 所示:
通过上图可以知道,Compilers 会将源码转化成 DEX 文件,其他内容转化成编译后的资源。实际上,Compilers 到 DEX 文件这一步会先将源码编译成字节码文件,再通过 dex 命令将字节码文件处理成 classes.dex。而我们需要做的就是:在转化成 dex 之前对字节码文件做处理,遍历所有的字节码文件并在特定的逻辑处进行插码。进一步细化思路可以分为两个步骤:
(1)在转化成 dex 之前获取到全量、可处理的字节码文件流;
(2)识别字节码文件中的特定逻辑并插入自定义的埋点代码。
注意:对于上述的第二步,如果你写过 Xposed 插件或者了解过 Spring 框架原理的话就会觉得非常熟悉,这里使用到了面向切面编程的思想,即 AOP。按照 AOP 的思想,我们可以把要插入代码的地方抽象成切入点,然后在切入点处添加埋点代码就可以了。
神策在实现这个功能的时候用到了以下关键技术:
(1)Gradle 插件:Gradle 是一个非常优秀的项目构建工具,它的 DSL(领域特定语言)基于 Groovy 实现。Gradle 构建的大部分功能通过插件的方式来实现,并且支持自定义 Gradle 插件。把插件应用到项目中,插件会扩展项目的功能,帮助你在项目的构建过程中做很多事情,例如:测试、编译、打包等;
(2)Transform API:是一组封装好的类,通过 Transform API 允许第三方以插件(plugin)的形式,在 Android 应用程序打包成 .dex 文件之前的编译过程中操作字节码文件;
(3)ASM:是一个通用的 Java 字节码操纵框架,它能被用来动态生成类或者增强既有类的功能。
3.1 Transform API
Google 从 Android Gradle 1.5.0 开始,提供了 Transform API,允许第三方插件在 Android App 打包成 .dex 文件之前的编译过程中操作字节码文件。我们只要实现一套 Transform,遍历字节码文件的所有方法之后进行修改,最终替换原文件即可达到插入代码的目的。
我们先了解一下 Transform 的两个概念:
(1)TransformInput:是指输入文件的抽象,它包含 DirectoryInput 集合(代表以源码方式参与项目编译的所有目录结构及其目录下的源码文件)与 JarInput 集合(以 jar 包方式参与项目编译的所有本地 jar 包和远程 jar 包)两部分;
(2)TransformOutputProvider:是指 Transform 的输出,通过它可以获取输出路径。
下面我们再了解一下 Transform 类的定义,作为一个抽象类它主要包含以下的部分:
class Transform {
...
public abstract Set getInputTypes();
public abstract Set super Scope> getScopes();
public void transform(@NonNull TransformInvocation transformInvocation)
throws TransformException, InterruptedException, IOException {
}
...
}
(1)getInputTypes:指定 Transform 要处理的数据类型,例如:处理编译后的字节码或者标准的 Java 资源;
(2)getScopes:指定 Transform 的作用域,例如:只处理当前项目、只处理子项目等;
(3)transform:处理字节码文件流的切入点,通过入参transformInvocation 我们就可以拿到上面概念中提到的TransformInput 和 TransformOutputProvider。
可以看到 Transform API 的结构是非常简单、清晰的,它的使用可以概括为:先从 TransformInput 接收我们需要的流,再从 TransformOutputProvider 输出我们处理过的流,具体的处理逻辑需要实现自定义的 Transform 类。getInputTypes、getScopes 等方法用于粗粒度的约束、筛选输入流,transform 完成接收流、处理流、输出流的过程。transform 的方法逻辑是大同小异的,因为真正处理流的过程是通过 ASM 去实现,如果对 Transform API 这部分有兴趣的话可以直接参考神策 Android 全埋点插件的源码。
3.2 ASM
ASM 是一个通用的 Java 字节码操作和分析框架。它可以用来修改现有的类,或者直接以二进制形式动态生成类。ASM 提供了一些常见的字节码转换和分析算法,可以根据这些算法构建定制的复杂转换和代码分析工具。ASM 提供了与其他 Java字节码框架类似的功能,并且效率更高。不过效率高的前提是该库的语法更接近字节码层面,因此学习成本更大。
我们先来了解下 ASM 框架的两个核心类:
(1)ClassVisitor:主要负责 “visit” 类的成员信息,包括标记在类上的注解、构造方法、属性、方法、静态代码块。在这里我们可以通过自定义 ClassVisitor 处理 Transform API 提供的字节码文件流,例如:如果我们需要采集 Button 的点击事件,那么就只需要对继承了 View.OnClickListener 接口的类进行处理。
(2)MethodVisitor:主要负责 “visit” 方法的信息,用来进行具体的字节码操作。“插入” 代码的过程便是通过 MethodVisitor 的方法来完成。
下面我们再来看一下 ClassVisitor 类的定义,ClassVisitor 类的结构如图 3-2 所示:
可以看到 ClassVisitor 中定义了很多 visit* 的方法,用于处理一个字节码文件的不同内容。其中,最常用到的是 visit 与 visitMethod 这两个方法:
void visit(int version, int access, String name, String signature, String superName, String[] interfaces)
该方法是扫描类时的第一个 “visit”的方法,主要用于类声明使用,其中参数的释义如下:
(1)version:标识类版本,例如:51,表示当前字节码文件的版本是 JDK 1.7;
(2)access:类的修饰符,例如:ACC_PUBLIC( 对应 public );
(3)name:类的全限定名,通常类的名称是由包名加类名构成并以 ‘.’ 隔开,全限定名就需要把名称里的 ‘.’ 替换成 ‘/’,例如:“java/lang/String”;
(4)signature:泛型信息;
(5)superName:父类的名称,java 是单根结构,即所有的类都继承自 “java.lang.Object”。即使你申明一个没有 extends 任何类的类,JDK 在编译的时候会为你加上 “extends Object”;
(6)interfaces:类实现的接口,一个 java 类可以实现多个接口,因此是一个数组。
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)
该方法是当扫描器扫描到类的方法时进行调用,其中参数的释义如下:
(1)access:方法的修饰符,例如:ACC_PRIVATE( 对应 private );
(2)name:方法名,在 ASM 中 “visitMethod” 方法会处理构造方法、静态代码块、私有方法、受保护的方法、公有方法、native 类型的方法。在这些方法中,构造方法的方法名为 “
(3)desc:方法签名,要了解方法签名需要先了解描述符相关的知识,下面会详细介绍;
(4)signature:泛型信息;
(5)exceptions:异常信息,表示方法可能抛出的异常,一个 java 方法可以抛出多个异常,因此是一个数组。
这里的 access、name、signature、exceptions 都很好理解,只有 desc(方法签名)不是很常见。其实方法签名的内容就是描述方法的返回值和入参类型列表,例如:void fun(int a,String b),它的 desc 就是 “(iLjava/lang/String;)V”。这里 desc 的组成规则是:“(入参类型描述符)返回值类型描述符”。类型与描述符的对照关系如表 3-1 所示:开发者通过自定义 ClassVisitor 的子类,重写对应的 visit 方法就能达到修改字节码的目的,在更底层的位置 Visitor 会根据 ASM 实现的算法去遍历类的各个角落。由于是在编译期间对字节码进行处理,ASM 即完成了完成控制类的任务且无明显性能代价,非常适合我们的需求。
3.3 运行流程
为了方便用户使用,神策 Android 全埋点插件提供了一些可配置项。插件会在开始时读取这些配置以决定具体的运行模式。除此以外就是 Transform API 和 ASM 一起实现动态插入埋点代码的功能,插件的整体运行流程如图 3-3 所示:四、全埋点插件的具体实现
4.1 SensorsAnalyticsTransform
SensorsAnalyticsTransform 是 3.1 节中提到的 Transform API 的实现类,SensorsAnalyticsTransform 的作用很明确:先把TransformInput 中包含的每一个字节码文件遍历并交给 ASM 框架处理,再把每一个 ASM 框架处理后的字节码文件输出到 TransformOutputProvider,供下一个 Gradle Task 使用。下面我们来看一下 SensorsAnalyticsTransform 的运行流程,如图 4-1 所示:图中的方法名和源码是一致的,整体流程先是经过 Transform 类的重写方法 transform(),之后分别开始 DirectoryInput 集合和 JarInput 集合的遍历,最终在 modifyClass() 方法中将遍历所得的字节码文件使用 ASM 框架进行处理。Transform API 是支持多线程编译以及增量编译的,神策 Android 全埋点插件也实现了这一部分的功能,因此可以看到在遍历DirectoryInput 集合和 JarInput 集合的时候有多层的嵌套处理。ASM 框架处理后的字节码文件还需要输出给 TransformOutputProvider,这部分的逻辑没有在图 4-1 中体现,不过也是在遍历的方法中去实现的。
从 transform() 到 modifyClass() 之间实现遍历功能的逻辑是相对固定的,没有太多业务含义,可以直接复用这部分代码。因此,这里就不深入分析源码了。我们来看下最终的 modifyClass() 方法的实现逻辑:
/**
* 真正修改类中方法字节码
*/
private byte[] modifyClass(byte[] srcClass, ClassNameAnalytics classNameAnalytics) {
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
ClassVisitor classVisitor = new SensorsAnalyticsClassVisitor(classWriter, classNameAnalytics, transformHelper)
ClassReader cr = new ClassReader(srcClass)
cr.accept(classVisitor, ClassReader.EXPAND_FRAMES + ClassReader.SKIP_FRAMES)
return classWriter.toByteArray()
}
ClassReader 类主要用来解析编译过的字节码文件,ClassWriter 类用来重新构建编译后的类,比如修改类名、属性以及方法。这两个类同样是 ASM 框架的核心类,不过在我们的需求中并不需要自定义实现,因此不再赘述。SensorsAnalyticsClassVisitor 类中实现了自定义 “插入” 代码的逻辑,下节中我们通过示例讲述如何实现。
4.2 SensorsAnalyticsClassVisitor
SensorsAnalyticsClassVisitor 是 3.2 节中提到的 ASM 框架 ClassVisitor 类的子类,它也是神策 Android 全埋点插件最核心的类,在这个类中我们实现了 Fragment 的页面浏览以及 App 点击的全埋点事件采集功能。由于要实现的业务逻辑多且复杂,本节不会直接分析 SensorsAnalyticsClassVisitor 的源码,而是通过示例(如何实现 Button 的点击事件采集)一窥究竟。
回顾第三节中提到的基本思路:在转化成 dex 之前对字节码文件做处理,遍历所有的字节码文件并在特定的逻辑处进行插码。其中,后半部分 “并在特定的逻辑处进行插码” 就是我们要实现的逻辑,这种思路对应了面向切面编程思想(AOP)。
AOP 有如下三个主要概念:
(1)切面(Aspect):切入点 + 通知;
(2)切入点(Pointcut):指具体被增强的类或者方法;
(3)通知(Advice):指在切入点执行的增强处理。
我们的实现过程就围绕着定义切入点和插入埋点逻辑。
4.2.1 定义切入点
要给一个 Button 对象设置点击事件,最普通的做法是调用 Button 对象的 setOnClickListener 方法,传入自己实现的 View.OnClickListener 对象,大致逻辑如下所示:
findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 触发该方法表示 Button 被点击
}
});
我们想要实现 Button 的点击事件采集的话,就需要在 OnClick 方法中插入我们的自定义代码。从图中代码可以推断出,我们需要的切入点规则是:
(1)该方法所在的类需要实现 View.onClickListener 接口;
(2)方法名为 onClick;
(3)方法签名是一个 View 类型的入参和 void 的方法返回值,也就是 “(Landroid/view/View;)V”。
根据 3.2 节关于 ASM 框架的介绍中我们可以得知:从 visit 方法的 interfaces 参数可以获取到当前类实现的接口数组;从 visitMethod 的 name 和 desc 可以分别获取到方法名和方法签名。
4.2.2 增强切入点
增强切入点也就是插入我们自定义的埋点逻辑,这里以插入一行日志为例:
class MyClassVisitor extends ClassVisitor {
private String[] interfaces
private String className;
private String superName;
MyClassVisitor(ClassVisitor cv) {
super(ASM6,cv)
}
@Override
void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
this.interfaces = interfaces
this.className = name
this.superName = superName
super.visit(version,access,name,signature,superName,interfaces)
}
@Override
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
def methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);
if(name.equals('onClick') && desc.equals('(Landroid/view/View;)V') && interfaces.contains('android/view/View$OnClickListener')){
println className + " " + superName + " " + access + " " + name + " " + desc
methodVisitor.visitLdcInsn('HookLog')
methodVisitor.visitVarInsn(ALOAD, 1)
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, 'android/view/View', 'toString', '()Ljava/lang/String;', false)
methodVisitor.visitMethodInsn(INVOKESTATIC, 'android/util/Log', 'e','(Ljava/lang/String;Ljava/lang/String;)I', false)
}
return methodVisitor
}
}
这里重写方法 visitMethod 中的 if 条件语句就对应了切入点规则,并且通过 MethodVisitor 在点击事件里插入了一行代码 “Log.e("HookLog", view.toString() );”,view 对象就是 onClick 方法的入参。
MethodVisitor 的使用需要涉及很多 Java 虚拟机和字节码指令相关的知识,不在本文讨论的范围,这里就不再展开了。为了快速上手 ASM,推荐一个插件 ASM Bytecode Viewer,它可以帮你快速把 Java 代码转化成对应的 ASM 代码。
五、总结
本文首先介绍了神策 Android 全埋点的基本概念和实现逻辑,然后重点讲述了 Android 全埋点插件的作用和实现原理。Transform API + ASM 的功能非常强大,往往可以起到意想不到的作用,值得大家深入研究。
最后,希望大家通过这篇文章可以对神策的 Android 全埋点插件有一个较为全面的了解。
注:本文参考文献如下:
Android Apk 构建流程图:https://developer.android.com/studio/build/index.html?hl=zh-cn#build-process
本文作者
顾鑫 神策数据 |SDK 技术顾问
我是顾鑫sh,神策数据 Android 技术顾问。神策数据是我就职的第一家公司,他非常的棒~学习中我喜欢做 Android 相关开发,也喜欢接触新兴技术,希望在开源社区能与你共同学习、共同进步。
本文著作权归「神策数据开源社区」所有,商业转载请联系我们获得授权;非商业转载请注明出处,并附上神策数据开源社区服务号二维码。