转载

Android全埋点解决方案之Javassist

Android全埋点解决方案之Javassist

Javassist

Java 字节码以二进制的形式存储在 .class 文件中,每一个 .class 文件包含一个 Java 类或接口。Javaassist 就是一个用来 处理 Java 字节码的类库。它可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码方面有深入的了解。

Javassist 可以绕过编译,直接操作字节码,从而实现代码注入。所以使用 Javassist 的时机就是在构建工具 Gradle 将源 文件编译成 .class 文件之后,在将 .class 打包成 .dex 文件之前。

Javassist 基础

• 读写字节码

在 Javassist 中,.class 文件是用类 Javassist.CtClass 表示。一个 CtClass 对象可以处理一个 .class 文件。

Android全埋点解决方案之Javassist

在上面这个示例中,先获取一个 ClassPool 对象 。ClassPool 是 CtClass 对象的容器。它按需读取类文件来创建 CtClass 对象,并且保存 CtClass 对象以便以后会被使用到。

为了修改类的定义,首先需要使用 ClassPool.get() 方法从 ClassPool 中获得一个 CtClass 对象。使用 getDefault() 方法获 取的 ClassPool 对象使用的是默认系统的类搜索路径。

ClassPool 是一个存储 CtClass 的 Hash 表,类的名称作为 Hash 表的 key。ClassPool 的 get() 函数会从 Hash 表查找 key 对应的 CtClass 对象。如果没有找到,get() 函数会创建并返回一个新的 CtClass 对象,这个对象会保存在 Hash 表中。

从 ClassPool 中获取的 CtClass 对象是可以被修改的。在上面的例子中,com.sensorsdata.analytics.android.sdk.Sen- sorsDataAutoTrackHelper 的父类被设置为 java.lang.Object。调用 writeFile() 后,这项修改会被写入原始类文件中。

writeFile() 会将 CtClass 对象转换成类文件并写到本地磁盘。同时,也可以使用 toBytecode() 函数来获取修改过的字节码 :

byte[] b = aClass.toBytecode();

也可以使用 toClass() 函数直接将 CtClass 转换成 Class 对象:

Class clazz = aClass.toClass();

toClass() 请求当前线程的 ClassLoader 加载 CtClass 所代表的类文件,它返回此类文件的 java.lang.Class 对象。

• 冻结类

如果一个 CtClass 对象通过 writeFile()、toClass()、toBytecode() 等方法被转换成一个类文件,此 CtClass 对象就会被冻 结起来,不允许再被修改,这是因为一个类只能被 JVM 加载一次。

其实,一个冻结的 CtClass 对象也可以被解冻,比如: Android全埋点解决方案之Javassist

此处调用 defrost() 方法之后,这个 CtClass 对象就又可以被修改了。

• 类搜索路径

通过 ClassPool.getDefault() 获取的 ClassPool 使用 JVM 的类搜索路径。如果程序运行在 JBoss 或者 Tomcat 等 Web 服 务器上,ClassPool 可能无法找到用户的类,因为 Web 服务器使用多个类加载器作为系统类加载器。在这种情况下, ClassPool 必须添加额外的类搜索路径。 Android全埋点解决方案之Javassist

上面的代码示例,将 this 指向的类添加到 ClassPool 的类加载路径中。你可以使用任意 Class 对象来代替 this.getClass(),

从而将 Class 对象添加到类加载路径中。同时,也可以注册一个目录作为搜索路径。比如: Android全埋点解决方案之Javassist

上面的例子是将“/usr/local/Library/”目录添加到类搜索路径中。

• ClassPool

ClassPool 是 CtClass 对象的容器。因为编译器在编译引用 CtClass 代表的 Java 类的源代码时,可能会引用 CtClass 对象, 所以一旦一个 CtClass 被创建,它就会被保存在 CtClass 中。

• 避免内存溢出

如果 CtClass 对象的数量变得非常多,ClassPool 有可能会导致巨大的内存消耗。为了避免这个问题,我们可以从 ClassPool 中显式删除不必要的 CtClass 对象。如果对 CtClass 对象调用 detach() 方法,那么该 CtClass 对象将会被从 ClassPool 中删除。比如:

Android全埋点解决方案之Javassist

在调用 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。注意 $args[0] 不等于 $0,因为 $0 表示 this。

$

变量 $$ 是所有参数列表的缩写,用逗号分隔。

$_

CtMethod 中的 insertAfter() 是在方法的末尾插入编译的代码。传递给 insertAfter() 的语句中,不但可以使用特殊符号如 $0,$1。也可以使用 $_ 来表示方法的结果值。

该变量的类型是方法的返回结果类型(返回类型)。如果返回结果类型为 void,那么 $_ 的类型为 Object,$_ 的值为 null。

虽然由 insertAfter() 插入的编译代码通常在方法返回之前执行,但是当方法抛出异常时,它也可以执行。要在抛出异常时 执行它,insertAfter() 的第二个参数 asFinally 必须为 true。

如果抛出异常,由 insertAfter() 插入的编译代码将作为 finally 子句执行。$_ 的值 0 或 null。在编译代码的执行终止后, 最初抛出的异常被重新抛出给调用者。注意,$_ 的值不会被抛给调用者,它将被丢弃。

• addCatch

addCatch() 插入方法体抛出异常时执行的代码,控制权会返回给调用者。在插入的源代码中,异常用 $e 表示。

Android全埋点解决方案之Javassist

转换成对应的 java 代码如下:

Android全埋点解决方案之Javassist

请注意,插入的代码片段必须以 throw 或 return 语句结束。

• 注解(Annotations)

CtClass、CtMethod、CtField 和 CtConstructor 均提供了 getAnnotations() 方法,用于读取注解。它返回一个注解类型 的对象数组。

我们目前只介绍当前全埋点方案会用到的关于 Javassist 的相关基础知识,关于 Javassist 更详细的用法,可以参考: https://github.com/jboss-javassist/javassist/wiki/Tutorial-1

原理概述

在自定义的 plugin 里,注册一个自定义的 Transform,然后可以分别对目录和 jar 包进行遍历。在遍历的过程中,利用 Javassist 的 API 来对满足特定条件的方法进行修改,插入相关埋点代码。原理与 ASM 类似,只是把操作 .class 文件的库 由 ASM 换成 Javassist。

实现步骤

完整的项目源码后续会 release 给大家。

缺点

• 暂时没有什么发现缺点

知识点

• 汇编相关知识

参考资料

[1] https://www.jianshu.com/p/43424242846b

[2]https://blog.csdn.net/Deemons/article/details/78473874

[3]https://blog.csdn.net/yulong0809/article/details/77752098

[4] https://juejin.im/post/58fea36bda2f60005dd1b7c5

[5] https://www.jianshu.com/p/417589a561da

[6] http://www.javassist.org

[7] https://github.com/jboss-javassist/javassist

[8]https://github.com/jbossjavassist/javassist/wiki/Tutorial-1

[9]https://github.com/jbossjavassist/javassist/wiki/Tutorial-2

[10]https://github.com/jbossjavassist/javassist/wiki/Tutorial-3

注:该内容来自神策数据用户行为洞察研究院出品的《Android 全埋点解决方案》白皮书,查看完整白皮书可点击 《Android 全埋点解决方案》

更多白皮书、报告、干货和案例,可以关注“神策数据”和“用户行为洞察研究院”公众号了解~ Android全埋点解决方案之Javassist

原文  http://www.sensorsdata.cn/blog/20181206-10/
正文到此结束
Loading...