全埋点是指无需 Android 应用程序开发工程师写代码或者只写少量的代码,就能预先自动收集用户的所有行为数据,然后就可以根据实际的业务分析需求从中筛选出所需行为数据并进行分析。
本文来主要介绍 $AppClick 全埋点方案:Javassist,更多全埋点方案请关注《Android 全埋点解决方案》一书。
关键技术
Javassist
Java 字节码以二进制的形式存储在 .class 文件中,每一个.class 文件包含一个Java类或接口。Javaassist 框架就是一个用来处理 Java 字节码的类库。它可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码方面有深入的了解。
Javassist 可以绕过编译,直接操作字节码,从而可以实现代码的注入。所以使用 Javassist 框架的时机就是在构建工具 Gradle 将源文件编译成 .class 文件之后,在将 .class 打包成 .dex 文件之前。
Javassist 基础
我们下面介绍一些关于Javassist 的相关基础知识。
读写字节码
在Javassist框架中,.class文件是用类 Javassist.CtClass 表示的。一个 CtClass 对象可以处理一个 .class 文件。
下面列举一个简单的示例。
ClassPool pool = ClassPool.getDefault();
CtClass aClass=
pool.get(“com.sensorsdata.analytics.android.sdk.SensorsDataAutoTrackHelper”)
aClass.setSuperclass(“java.lang.Object”)
aClass.writeFile()
在上面这个示例中,我们首先获取一个 ClassPool 对象。ClassPool 是 CtClass 对象的容器。
它可以按需读取类文件用来创建 CtClass 对象,并且保存 CtClass 对象以便以后可能会被使用到。
为了修改类的定义,首先需要使用 ClassPool.get() 方法从 ClassPool 中获得一个 CtClass 对象。使用 getDefault() 方法获取的 ClassPool 对象使用的是默认系统的类搜索路径。
ClassPool 是一个存储 CtClass 的 Hash 表,类的名称作为 Hash 表的 key。ClassPool 的 get() 方法会从 Hash 表查找 key 对应的 CtClass 对象。如果根据对应的 Key 没有找到 CtClass 对象,get() 方法就会创建并返回一个新的 CtClass 对象,这个对象同时也会保存在 Hash 表中。
从 ClassPool 中获取的 CtClass 对象是可以被修改的。比如上面的这个例子:
com.sensorsdata.analytics.android.sdk.SensorsDataAutoTrackHelper 的父类被设置为 java.lang.Object。
调用 writeFile() 方法后,这项修改会被写入原始类文件中。writeFile() 方法会将 CtClass 对象转换成类文件并写到本地磁盘。同时,也可以使用 toBytecode() 方法来获取修改过的字节码。比如:
byte[]b = aClass.toBytecode();
也可以使用 toClass() 函数直接将 CtClass 对象转换成 Class 对象,比如:
Classclazz = aClass.toClass();
toClass() 请求当前线程的 ClassLoader 加载 CtClass 对象所代表的类文件,它返回此类文件的 java.lang.Class 对象。
冻结类
一个 CtClass 对象通过 writeFile()、toClass()、toBytecode() 等方法被转换成一个类文件,此 CtClass 对象就会被冻结起来,不允许再被修改,这是因为一个类只能被 JVM 加载一次。
其实,一个冻结的 CtClass 对象也可以被解冻,比如:
CtClassaClass = …;
……
aClass.writeFile();
aClass.defrost();
//因为类已经被解冻,所以这里是可以被修改成功的
aClass.setSuperClass(…);
此处调用 defrost() 方法之后,这个 CtClass 对象就又可以被修改了。
类搜索路径
通过 ClassPool.getDefault() 获取的 ClassPool 是使用 JVM 的类搜索路径。如果程序运行在 JBoss 或者 Tomcat 等 Web 服务器上,ClassPool 可能无法找到用户的类,因为 Web 服务器使用多个类加载器作为系统类加载器。在这种情况下,ClassPool 必须添加额外的类搜索路径。比如:
ClassPoolpool = ClassPool.getDefault();
pool.insertClassPath(newClassClassPath(this.getClass()));
在上面的代码示例中,将 this 指向的类添加到 ClassPool 的类加载路径中。你可以使用任意 Class 对象来代替 this.getClass(),从而将 Class 对象添加到类加载路径中。
同时,也可以注册一个目录作为搜索路径。比如:
ClassPoolpool = ClassPool.getDefault();
pool.insertClassPath(“/usr/local/Library/”);
上面的例子是将 “/usr/local/Library/”目录添加到类搜索路径中。
ClassPool
ClassPool 是 CtClass 对象的容器。因为编译器在编译引用 CtClass 代表的 Java 类的源代码时,可能会引用 CtClass 对象,所以一旦一个 CtClass 被创建,它就会被保存在 ClassPool 中。
避免内存溢出
如果 CtClass 对象的数量变得非常多,ClassPool 有可能会导致巨大的内存消耗。为了避免这个问题,我们可以从 ClassPool 中显式删除不必要的 CtClass 对象。如果对 CtClass 对象调用 detach() 方法,那么该 CtClass 对象将会被从 ClassPool 中删除。比如:
CtClassaClass = …;
aClass.writeFile();
aClass.detach();
在调用 detach() 方法之后,就不能再调用这个 CtClass 对象的任何有关方法了。如果调用 ClassPool 的 get() 方法,ClassPool 会再次读取这个类文件,并创建一个新的 CtClass 对象。
在方法体中插入代码
CtMethod 和 CtConstructor 均提供了 insertBefore()、insertAfter() 及 addCatch() 等方法。
它们可以把用 Java 编写的代码片段插入到现有的方法体中。Javassist 包括一个用于处理源代码的小型编译器,它接收用 Java 编写的源代码,然后将其编译成 Java 字节码,并内联到方法体中。
也可以按行号来插入代码段(如果行号表包含在类文件中)。向 CtMethod 和 CtConstructor 中的 insertAt() 方法提供源代码和原始类定义中的源文件的行号,就可以将编译后的代码插入到指定行号位置。
insertBefore()、insertAfter()、addCatch() 和 insertAt() 等方法都能接收一个表示语句或语句块的 String 对象。一个语句是一个单一的控制结构,比如 if 和 while 或者以分号结尾的表达式。语句块是一组用大括号 {} 包围的语句。
语句和语句块可以引用字段和方法。但不允许访问在方法中声明的局部变量,尽管在块中声明一个新的局部变量是允许的。
传递给方法insertBefore() 、insertAfter() 、addCatch()和 insertAt() 的 String 对象是由Javassist 的编译器编译的。由于编译器支持语言扩展,所以以 $ 开头的几个标识符都有特殊的含义:
$0, $1, $2, …
传递给目标方法的参数使用 $1,$2,… 来访问,而不是原始的参数名称。 $1 表示第一个参数,$2 表示第二个参数,以此类推。 这些变量的类型与参数类型相同。$0 等价于 this 指针。如果方法是静态的,则 $0 不可用。
$args
变量 $args 表示所有参数的数组。该变量的类型是 Object 类型的数组。如果参数类型是原始类型(如 int、boolean 等),则该参数值将被转换为包装器对象(如java.lang.Integer)以存储在 $args 中。 因此,如果第一个参数的类型不是原始类型,那么 $args[0] 等于 1 。 注 意 1。注意 1。注意args[0] 不等于 $0,因为 $0 表示 this。
$$
变量 $$ 是所有参数列表的缩写,用逗号分隔。
$_
CtMethod 中的 insertAfter() 是在方法的末尾插入编译的代码。传递给 insertAfter() 的语句中,不但可以使用特殊符号如 $0,$1。也可以使用 $_ 来表示方法的结果值。
该变量的类型是方法的返回结果类型(返回类型)。如果返回结果类型为 void,那么 的 类 型 为 O b j e c t , _ 的类型为Object, 的类型为Object,_ 的值为 null。
虽然由 insertAfter() 插入的编译代码通常在方法返回之前执行,但是当方法抛出异常时,它也可以执行。要在抛出异常时执行它,insertAfter() 的第二个参数 asFinally 必须为 true。
如果抛出异常,由 insertAfter() 插入的编译代码将作为 finally 子句执行。 的 值 0 或 n u l l 。 在 编 译 代 码 的 执 行 终 止 后 , 最 初 抛 出 的 异 常 被 重 新 抛 出 给 调 用 者 。 注 意 , _ 的值 0 或 null。在编译代码的执行终止后,最初抛出的异常被重新抛出给调用者。注意, 的值0或null。在编译代码的执行终止后,最初抛出的异常被重新抛出给调用者。注意,_ 的值不会被抛给调用者,它将被丢弃。
addCatch
addCatch() 插入方法体抛出异常时执行的代码,控制权会返回给调用者。在插入的源代码中,异常用 $e 表示。
CtMethod m = …;
CtClass etype =ClassPool.getDefault().get(“java.io.IOException”);
m.addCatch("{ System.out.println($e);throw $e; }", etype);
转换成对应的 java 代码如下:
try {
// the original method body
} catch (java.io.IOException e) {
System.out.println(e);
throw e;
}
请注意,插入的代码片段必须以 throw 或 return 语句结束。
注解(Annotations)
CtClass、CtMethod、CtField 和 CtConstructor 均提供了 getAnnotations() 方法,用于读取对应类型上添加的注解。它返回一个注解类型的对象数组。
我们目前只介绍当前全埋点方案会用到的关于 Javassist 的相关基础知识,关于 Javassist 更详细的用法,可点击文末阅读原文参考。
原理概述
在自定义的 plugin 里,我们可以注册一个自定义的 Transform,从而可以分别对当前应用程序的所有源码目录和 jar 包进行遍历。在遍历的过程中,利用Javassist 框架的 API 可以对满足特定条件的方法进行修改,比如插入相关埋点代码。整个原理与使用 ASM 框架类似,此时只是把操作 .class 文件的框架由 ASM 换成 Javassist 了。
实现步骤
《Android全埋点解决方案》一书中,有完整的项目源码。
如何拥有这本书?
1.关注公众号【用户行为洞察研究院】ID:SDResearch
2.长按参与抽奖
3.具体内容可点击下文链接,6月8日公布结果
链接: https://mp.weixin.qq.com/s?__biz=MzU5NzM4OTYzNg==&mid=2247485389&idx=1&sn=1d9543757750239833c19d8213b9475c&chksm=fe557fe6c922f6f0d562cd4198a81f7c1f3f2fbab5f760c48aae849eaeb8f70e498a53604661&token=38707885&lang=zh_CN#rd.