第03讲:字节码层面分析 class 类文件结构
本课时我们从字节码层面分析 class 类文件结构。首先来看一道面试题:
java中 String 字符串的长度有限制吗?
平时项目开发中,我们经常会用到 String 来声明字符串,比如 String str = “abc”, 但是你可能从来没有想过等于号之后的字符串常量到底有没有长度限制。要彻底答对这道题,就需要先学会今天所讲的内容——class 文件。
class 的来龙去脉
Java 能够实现"一次编译,到处运行”,这其中 class 文件要占大部分功劳。为了让 Java 语言具有良好的跨平台能力,Java 独具匠心的提供了一种可以在所有平台上都能使用的一种中间代码——字节码类文件(.class文件)。有了字节码,无论是哪种平台(如:Mac、Windows、Linux 等),只要安装了虚拟机都可以直接运行字节码。
并且,有了字节码,也解除了 Java 虚拟机和 Java 语言之间的耦合。这句话你可能不是很理解,这种解耦指的是什么?
其实,Java 虚拟机当初被设计出来的目的就不单单是只运行 Java 这一种语言。目前 Java 虚拟机已经可以支持很多除 Java 语言以外的其他语言了,如 Groovy、JRuby、Jython、Scala 等。之所以可以支持其他语言,是因为这些语言经过编译之后也可以生成能够被 JVM 解析并执行的字节码文件。而虚拟机并不关心字节码是由哪种语言编译而来的。如下图所示:
上帝视角看 class 文件
如果从纵观的角度来看 class 文件,class 文件里只有两种数据结构:无符号数和表。
- 无符号数:属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者字符串(UTF-8 编码)。
- 表:表是由多个无符号数或者其他表作为数据项构成的复合数据类型,class文件中所有的表都以“_info”结尾。其实,整个 Class 文件本质上就是一张表。
这两者之间的关系可以用下面这张张图来表示:
可以看出,在一张表中可以包含其他无符号数和其他表格。伪代码可以如下所示:
// 无符号数
u1 = byte[1];
u2 = byte[2];
u4 = byte[4];
u8 = byte[8];
// 无符号数
u1 = byte[1];
u2 = byte[2];
u4 = byte[4];
u8 = byte[8];
// 表
class_table {
// 表中可以引用各种无符号数,
u1 tag;
u2 index2;
…
// 表中也可以引用其它表
method_table mt;
…
}
class 文件结构
刚才我们说在 class 文件中只存在无符号数和表这两种数据结构。而这些无符号数和表就组成了 class 中的各个结构。这些结构按照预先规定好的顺序紧密的从前向后排列,相邻的项之间没有任何间隙。如下图所示:
当 JVM 加载某个 class 文件时,JVM 就是根据上图中的结构去解析 class 文件,加载 class 文件到内存中,并在内存中分配相应的空间。具体某一种结构需要占用大多空间,可以参考下图:
看到这里你可能会有点概念混淆,分不清无符号数、表格以及上面的结构是什么关系。其实可以举一个简单的例子:人类的身体是由 H、O、C、N 等元素组成的。但是这些元素又是按照一定的规律组成了人类身体的各个器官。class 文件中的无符号数和表格就相当于人类身体中的 H、O、C、N 等元素,而 class 结构图中的各项结构就相当于人类身体的各个器官。并且这些器官的组织顺序是有严格顺序要求的,毕竟眼睛不能长在屁股上。
实例分析
理清这些概念之后,接下来通过一个 Java 代码实例,来看一下上面这几个结构的详细情况。首先编写一个简单的 Java 源代码 Test.java,如下所示:
import java.io.Serializable;
public class Test implements Serializable, Cloneable{
private int num = 1;
public int add(int i) {
int j = 10;
num = num + i;
return num;
}
}
import java.io.Serializable;
public class Test implements Serializable, Cloneable{
private int num = 1;
public int add(int i) {
int j = 10;
num = num + i;
return num;
}
}
通过 javac 将其编译,生成 Test.class 字节码文件。然后使用 16 进制编辑器打开 class 文件,显示内容如下所示:
上图中都是一些 16 进制数字,每两个字符代表一个字节。乍看一下各个字符之间毫无规律,但是在 JVM 的视角里这些 16 进制字符是按照严格的规律排列的。接下来就一步一步看下 JVM 是如何解析它们的。
魔数 magic number
如上图所示,在 class 文件开头的四个字节是 class 文件的魔数,它是一个固定的值--0XCAFEBABE。魔数是 class 文件的标志,也就是说它是判断一个文件是不是 class 格式文件的标准, 如果开头四个字节不是 0XCAFEBABE, 那么就说明它不是 class 文件, 不能被 JVM 识别或加载。
版本号
紧跟在魔数后面的四个字节代表当前 class 文件的版本号。前两个字节 0000 代表次版本号(minor_version),后两个字节 0034 是主版本号(major_version),对应的十进制值为 52,也就是说当前 class 文件的主版本号为 52,次版本号为 0。所以综合版本号是 52.0,也就是 jdk1.8.0
常量池(重点)
紧跟在版本号之后的是一个叫作常量池的表(cp_info)。在常量池中保存了类的各种相关信息,比如类的名称、父类的名称、类中的方法名、参数名称、参数类型等,这些信息都是以各种表的形式保存在常量池中的。
常量池中的每一项都是一个表,其项目类型共有 14 种,如下表所示:
可以看出,常量池中的每一项都会有一个 u1 大小的 tag 值。tag 值是表的标识,JVM 解析 class 文件时,通过这个值来判断当前数据结构是哪一种表。以上 14 种表都有自己的结构,这里不再一一介绍,就以 CONSTANT_Class_info 和 CONSTANT_Utf8_info 这两张表举例说明,因为其他表也基本类似。
首先,CONSTANT_Class_info 表具体结构如下所示:
table CONSTANT_Class_info {
u1 tag = 7;
u2 name_index;
}
解释说明。
- tag:占用一个字节大小。比如值为 7,说明是 CONSTANT_Class_info 类型表。
- name_index:是一个索引值,可以将它理解为一个指针,指向常量池中索引为 name_index 的常量表。比如 name_index = 2,则它指向常量池中第 2 个常量。
接下来再看 CONSTANT_Utf8_info 表具体结构如下:
table CONSTANT_utf8_info {
u1 tag;
u2 length;
u1[] bytes;
}
table CONSTANT_utf8_info {
u1 tag;
u2 length;
u1[] bytes;
}
解释说明:
- tag:值为1,表示是 CONSTANT_Utf8_info 类型表。
- length:length 表示 u1[] 的长度,比如 length=5,则表示接下来的数据是 5 个连续的 u1 类型数据。
- bytes:u1 类型数组,长度为上面第 2 个参数 length 的值。
而我们在java代码中声明的String字符串最终在class文件中的存储格式就 CONSTANT_utf8_info。因此一个字符串最大长度也就是u2所能代表的最大值65536个,但是需要使用2个字节来保存 null 值,因此一个字符串的最大长度为 65536 - 2 = 65534。参考 Java String最大长度分析。
不难看出,在常量池内部的表中也有相互之间的引用。用一张图来理解 CONSTANT_Class_info 和 CONSTANT_utf8_info 表格之间的关系,如下图所示:
理解了常量池内部的数据结构之后,接下来就看一下实例代码的解析过程。因为开发者平时定义的 Java 类各式各样,类中的方法与参数也不尽相同。所以常量池的元素数量也就无法固定,因此 class 文件在常量池的前面使用 2 个字节的容量计数器,用来代表当前类中常量池的大小。如下图所示:
红色框中的 001d 转化为十进制就是 29,也就是说常量计数器的值为 29。其中下标为 0 的常量被 JVM 留作其他特殊用途,因此 Test.class 中实际的常量池大小为这个计数器的值减 1,也就是 28个。
第一个常量,如下所示:
0a 转化为 10 进制后为 10,通过查看常量池 14 种表格图中,可以查到 tag=10 的表类型为 CONSTANT_Methodref_info,因此常量池中的第一个常量类型为方法引用表。其结构如下:
CONSTANT_Methodref_info {
u1 tag = 10;
u2 class_index; 指向此方法的所属类
u2 name_type_index; 指向此方法的名称和类型
CONSTANT_Methodref_info {
u1 tag = 10;
u2 class_index; 指向此方法的所属类
u2 name_type_index; 指向此方法的名称和类型
}
也就是说在“0a”之后的 2 个字节指向这个方法是属于哪个类,紧接的 2 个字节指向这个方法的名称和类型。它们的值分别是:
- 0006:十进制 6,表示指向常量池中的第 6 个常量。
- 0015:十进制 21,表示指向常量池中的第 21 个常量。
至此,第 1 个常量就解读完毕了。紧接着的就是第 2 个常量,如下所示:
tag 09 表示是字段引用表 CONSTANT_FIeldref_info ,其结构如下:
CONSTANT_Fieldref_info{
u1 tag;
u2 class_index; 指向此字段的所属类
u2 name_type_index; 指向此字段的名称和类型
CONSTANT_Fieldref_info{
u1 tag;
u2 class_index; 指向此字段的所属类
u2 name_type_index; 指向此字段的名称和类型
}
同样也是 4 个字节,前后都是两个索引。
- 0005:指向常量池中第 5 个常量。
- 0016:指向常量池中第 22 个常量。
到现在为止我们已经解析出了常量池中的两个常量。剩下的 21 个常量的解析过程也大同小异,这里就不一一解析了。实际上我们可以借助 javap 命令来帮助我们查看 class 常量池中的内容:
javap -v Test.class
javap -v Test.class
上述命令执行后,显示结果如下:
正如我们刚才分析的一样,常量池中第一个常量是 Methodref 类型,指向下标 6 和下标 21 的常量。其中下标 21 的常量类型为 NameAndType,它对应的数据结构如下:
CONSTANT_NameAndType_info{
u1 tag;
u2 name_index; 指向某字段或方法的名称字符串
u2 type_index; 指向某字段或方法的类型字符串
}
而下标在 21 的 NameAndType 的 name_index 和 type_index 分别指向了 13 和 14,也就是“<init>”和“()V”。因此最终解析下来常量池中第 1 个常量的解析过程以及最终值如下图所示:
仔细解析层层引用,最后我们可以看出,Test.class 文件中常量池的第 1 个常量保存的是 Object 中的默认构造器方法。
访问标志(access_flags)
紧跟在常量池之后的常量是访问标志,占用两个字节,如下图所示:
访问标志代表类或者接口的访问信息,比如:该 class 文件是类还是接口,是否被定义成 public,是否是 abstract,如果是类,是否被声明成 final 等等。各种访问标志如下所示:
我们定义的 Test.java 是一个普通 Java 类,不是接口、枚举或注解。并且被 public 修饰但没有被声明为 final 和 abstract,因此它所对应的 access_flags 为 0021(0X0001 和 0X0020 相结合)。
类索引、父类索引与接口索引计数器
在访问标志后的 2 个字节就是类索引,类索引后的 2 个字节就是父类索引,父类索引后的 2 个字节则是接口索引计数器。如下图所示:
可以看出类索引指向常量池中的第 5 个常量,父类索引指向常量池中的第 6 个常量,并且实现的接口个数为 2 个。再回顾下常量池中的数据:
![在这里插入图片描述](https://s2.51cto.com/images/blog/202407/11105858_668f4a7202b88809.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_30,g_se,x_10,y_10,shadow_20,type_ZmFuZ3poZW5naGVpdGk=#pic_center)
从图中可以看出,第 5 个常量和第 6 个常量均为 CONSTANT_Class_info 表类型,并且代表的类分别是“Test”和“Object”。再看接口计数器,因为接口计数器的值是 2,代表这个类实现了 2 个接口。查看在接口计数器之后的 4 个字节分别为:
- 0007:指向常量池中的第 7 个常量,从图中可以看出第 7 个常量值为"Serializable"。
- 0008:指向常量池中的第 8 个常量,从图中可以看出第 8 个常量值为"Cloneable"。
综上所述,可以得出如下结论:当前类为 Test 继承自 Object 类,并实现了“Serializable”和“Cloneable”这两个接口。
字段表
紧跟在接口索引集合后面的就是字段表了,字段表的主要功能是用来描述类或者接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。
同样, 一个类中的变量个数是不固定的,因此在字段表集合之前还是使用一个计数器来表示变量的个数,如下所示:
0002 表示类中声明了 2 个变量(在 class 文件中叫字段),字段计数器之后会紧跟着 2 个字段表的数据结构。
字段表的具体结构如下:
CONSTANT_Fieldref_info{
u2 access_flags 字段的访问标志
u2 name_index 字段的名称索引(也就是变量名)
u2 descriptor_index 字段的描述索引(也就是变量的类型)
u2 attributes_count 属性计数器
attribute_info
}
继续解析 Text.class 中的字段表,其结构如下图所示:
字段访问标志
对于 Java 类中的变量,也可以使用 public、private、final、static 等标识符进行标识。因此解析字段时,需要先判断它的访问标志,字段的访问标志如下所示:
字段表结构图中的访问标志的值为 0002,代表它是 private 类型。变量名索引指向常量池中的第 9 个常量,变量名类型索引指向常量池中第 10 个常量。第 9 和第 10 个常量分别为“num”和“I”,如下所示:
因此可以得知类中有一个名为 num,类型为 int 类型的变量。对于第 2 个变量的解析过程也是一样,就不再过多介绍。
注意事项:
- 字段表集合中不会列出从父类或者父接口中继承而来的字段。
- 内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
对于以上两种情况,建议你可以自行定义一个类查看并手动分析一下。
方法表
字段表之后跟着的就是方法表常量。相信你应该也能猜到了,方法表常量应该也是以一个计数器开始的,因为一个类中的方法数量是不固定的,如图所示:
上图表示 Test.class 中有两个方法,但是我们只在 Test.java 中声明了一个 add 方法,这是为什么呢?这是因为默认构造器方法也被包含在方法表常量中。
方法表的结构如下所示:
CONSTANT_Methodref_info{
u2 access_flags; 方法的访问标志
u2 name_index; 指向方法名的索引
u2 descriptor_index; 指向方法类型的索引
u2 attributes_count; 方法属性计数器
attribute_info attributes;
}
CONSTANT_Methodref_info{
u2 access_flags; 方法的访问标志
u2 name_index; 指向方法名的索引
u2 descriptor_index; 指向方法类型的索引
u2 attributes_count; 方法属性计数器
attribute_info attributes;
}
可以看到,方法也是有自己的访问标志,具体如下:
我们主要来看下 add 方法,具体如下:
从图中我们可以看出 add 方法的以下字段的具体值:
- access_flags = 0001 也就是访问权限为 public。
- name_index = 0X0011 指向常量池中的第 17 个常量,也就是“add”。
- type_index = 0X0012 指向常量池中的第 18 个常量,也即是 (I)。这个方法接收 int 类型参数,并返回 int 类型参数。
属性表
在之前解析字段和方法的时候,在它们的具体结构中我们都能看到有一个叫作 attributes_info 的表,这就是属性表。
属性表并没有一个固定的结构,各种不同的属性只要满足以下结构即可:
CONSTANT_Attribute_info{
u2 name_index;
u2 attribute_length length;
u1[] info;
}
CONSTANT_Attribute_info{
u2 name_index;
u2 attribute_length length;
u1[] info;
}
JVM 中预定义了很多属性表,这里重点讲一下 Code 属性表。
- Code属性表
我们可以接着刚才解析方法表的思路继续往下分析:
可以看到,在方法类型索引之后跟着的就是“add”方法的属性。0X0001 是属性计数器,代表只有一个属性。0X000f 是属性表类型索引,通过查看常量池可以看出它是一个 Code 属性表,如下所示:
Code 属性表中,最主要的就是一些列的字节码。通过 javap -v Test.class 之后,可以看到方法的字节码,如下图显示的是 add 方法的字节码指令:
JVM 执行 add 方法时,就通过这一系列指令来做相应的操作。
总结:
本课时我们主要了解了一个 class 文件内容的数据结构到底长什么样子,并通过 Test.class 来模拟演示Java虚拟机解析字节码文件的过程。其中 class 常量池部分是重点内容,它就相当于是 class 文件中的资源仓库,其他的几种结构或多或少都会最终指向到这个资源仓库中。实际上平时我们不太会直接用一个 16 进制编辑器去打开一个 .class 文件。我们可以使用 javap 等命令或者是其他工具,来帮助我们查看 class 内部的数据结构。只不过自己亲手操作一遍是很有助于理解 JVM 的解析过程,并加深对 class 文件结构的记忆。
第04讲:编译插桩操纵字节码,实现不可能完成的任务
本课时我们讲解如何编译插桩操纵字节码。
上一课时我们介绍了 Java 字节码文件的格式,并通过一个 demo 手动模拟了 JVM 解析 class 文件的过程。所有的理论知识都是为了在项目中实践做准备。本课时我们就来看下,对于 class 文件我们还有什么其他玩法。
相信做过 Android 开发的工程师大多都遇到过这种需求:
记录每一个页面的打开和关闭事件,并通过各种 DataTracking 的框架上传到服务器,用来日后做数据分析。
面对这样的需求,一般人都会想到,这其实就是在每一个 Activity 的 onCreate 和 onDestroy 方法中,分别添加页面打开和页面关闭的逻辑。常见的做法有以下两种:
- 修改项目中现有的每一个 Activity,这样显然不够高大上,并且如果项目以后需要添加新的页面,这套逻辑需要重新拷贝一遍,非常容易遗漏。
- 将项目中所有的 Activity 继承自 BaseActivity,将页面打开和关闭的逻辑添加在 BaseActivity中,这种方案看起来比第 1 种方案高级得多,并且后续项目中有新的 Activity,直接继承 BaseActivity 即可。但是这种方案对第三方依赖库中的界面则无能为力,因为我们没有第三方依赖库的源码。
就是在这种环境下,一种更加优雅更加完整的方案应运而生:编译插桩。
编译插桩是什么
顾名思义,所谓编译插桩就是在代码编译期间修改已有的代码或者生成新代码。实际上,我们项目中经常用到的 Dagger、ButterKnife 甚至是 Kotlin 语言,它们都用到了编译插桩的技术。
理解编译插桩之前,需要先回顾一下 Android 项目中 .java 文件的编译过程:
从上图可以看出,我们可以在 1、2 两处对代码进行改造。
- 在 .java 文件编译成 .class 文件时,APT、AndroidAnnotation 等就是在此处触发代码生成。
- 在 .class 文件进一步优化成 .dex 文件时,也就是直接操作字节码文件,也是本课时主要介绍的内容。这种方式功能更加强大,应用场景也更多。但是门槛比较高,需要对字节码有一定的理解。
本课时主要介绍第 2 种实现方式,用一张图来描述如下过程,其中红色虚框包含了本课时要讲的所有内容。
一般情况下,我们经常会使用编译插桩实现如下几种功能:
- 日志埋点;
- 性能监控;
- 动态权限控制;
- 业务逻辑跳转时,校验是否已经登录;
- 甚至是代码调试等。
插桩工具介绍
目前市面上主要流行两种实现编译插桩的方式:
AspectJ
AspectJ 是老牌 AOP(Aspect-Oriented Programming)框架,如果你做过 J2EE 开发可能对这个框架更加熟悉,经常会拿这个框架跟 Spring AOP 进行比较。其主要优势是成熟稳定,使用者也不需要对字节码文件有深入的理解。
ASM
目前另一种编译插桩的方式 ASM 越来越受到广大工程师的喜爱。通过 ASM 可以修改现有的字节码文件,也可以动态生成字节码文件,并且它是一款完全以字节码层面来操纵字节码并分析字节码的框架(此处可以联想一下写汇编代码时的酸爽)。
举个例子,在 Java 中如果实现两个数相加操作,可以如下实现:
但是如果使用 ASM 直接编写字节码指令,则有可能是如下几个字节码指令:
虽然上面的代码看起来很恐怖,但是没必要太过担心,因为有各种工具帮我们生成这些字节码指令。
本课时就使用 ASM 来实现简单的编译插桩效果,通过插桩实现课时开始讲的需求,在每一个 Activity 打开时输出相应的 log 日志。
实现思路
过程主要包含两步:
- 遍历项目中所有的 .class 文件
如何找到项目中编译生成的所有 .class 文件,是我们需要解决的第一个问题。众所周知,Android Studio 使用 Gradle 编译项目中的 .java 文件,并且从 Gradle1.5.0 之后,我们可以自己定义 Transform,来获取所有 .class 文件引用。但是 Transform 的使用需要依赖 Gradle Plugin。因此我们第一步需要创建一个单独的 Gradle Plugin,并在 Gradle Plugin 中使用自定义 Transform 找出所有的 .class 文件。
- 遍历到目标 .class 文件 (Activity)之后,通过 ASM 动态注入需要被插入的字节码
如果第一步进行顺利,我们可以找出所有的 .class 文件。接下来就需要过滤出目标 Activity 文件,并在目标 Activity 文件的 onCreate 方法中,通过 ASM 插入相应的 log 日志字节码。
具体实现
创建 ASMLifeCycleDemo 项目
创建主项目 ASMLifeCycleDemo,当前项目中只有一个 MainActivity,如下:
创建自定义 Gradle 插件
首先在 ASMLifeCycleDemo 项目中创建一个新的 module,并选择 Android Library 类型,命名为 asm_lifecycle_plugin。
将 asm_lifecycle_plugin module 中除了 build.gradle 和 main 文件夹之外的所有内容都删除。然后在 main 目录下分别创建 groovy 和 java 目录,结构如下:
因为 Gradle 插件是使用 groovy 语言编写的,所以需要新建一个 groovy 目录,用来存放插件相关的.groovy类。 但 ASM 是 java 层面的框架,所以在 java 目录里存放 ASM 相关的类。
然后,在 groovy 中创建目录 danny.jiang.plugin,并在此目录中创建类 LifeCyclePlugin.groovy 文件。在 LifeCyclePlugin 中重写 apply 方法,实现插件逻辑,因为是 demo 演示,所以我只是简单的打印 log 日志。
目录结构与代码如下:
可以看出 LifeCyclePlugin 实现了 gradle api 中的 Plugin 接口。当我们在 app module 的 build.gradle 文件中使用此插件时,其 LifeCyclePlugin 的 apply 方法将会被自动调用。
接下来,将 asm_lifecycle_plugin module 的 build.gradle 中的内容全部删掉,改为如下内容:
group 和 version 都需要在 app module 引用此插件时使用。
所有的插件都需要被部署到 maven 库中,我们可以选择部署到远程或者本地。这里只是演示,所以只是将插件部署到本地目录中。具体地址通过 repository 属性配置,如图所示我将其配置在项目根目录下的 asm_lifecycle_repo 目录下。
最后一步,创建 properties 文件。
在 plugin/src/main 目录下新建目录 resources/META-INF/gradle-plugins,然后在此目录下新建一个文件:danny.asm.lifecycle.properties,其中文件名 danny.asm.lifecycle 就是我们自定义插件的名称,稍后我们在 app module 中会使用到此名称。
在 .properties 文件中,需要指定我们自定义的插件类名 LifeCyclePlugin,如下所示:
至此,自定义 Gradle 插件就已经写完,现在可以在 Android Studio 的右边栏找到 Gradle 中点击 uploadArchives,执行 plugin 的部署任务:
可以看到,构建成功之后,在 Project 的根目录下将会出现一个 repo 目录,里面存放的就是我们的插件目标文件。
测试 asm_lifecycle_plugin
为了测试自定义的 Gradle 插件是否可用,可以在 app module 中的 build.gradle 中引用此插件。
图中 ① 处就是在自定义 Gradle 插件中 properties 的文件名 (danny.asm.lifecycle)。
图中 ② 处 dependencies 中的 classpath 是 group 值 + module 名 + version。
然后在命令行中使用 gradlew 执行构建命令,如果打印出我们自定义插件里的 log,则说明自定义 Gradle 插件可以使用:
其实现在已经有了一些比较成熟的三方 Gradle 插件,比如 hiBeaver。如果不喜欢从头创建 Gradle 插件,可以考虑尝试使用。
自定义 Transform,实现遍历 .class 文件
自定义 Gradle 插件已经写好,接下来就需要实现遍历所有 .class 的逻辑。这部分功能主要依赖 Transform API。
什么是 Transform ?
Transform 可以被看作是 Gradle 在编译项目时的一个 task,在 .class 文件转换成 .dex 的流程中会执行这些 task,对所有的 .class 文件(可包括第三方库的 .class)进行转换,转换的逻辑定义在 Transform 的 transform 方法中。实际上平时我们在 build.gradle 中常用的功能都是通过 Transform 实现的,比如混淆(proguard)、分包(multi-dex)、jar 包合并(jarMerge)。
自定义 Transform
在 danny.jiang.plugin 目录中,新建 LifeCycleTransform.groovy,并继承 Transform 类。
可以看到,LifeCycleTransform 需要实现抽象类 Transform 中的抽象方法,具体有如下几个方法需要实现:
解释说明:Transform 主要作用是检索项目编译过程中的所有文件。通过这几个方法,我们可以对自定义 Transform 设置一些遍历规则,具体如下:
getName:
设置我们自定义的 Transform 对应的 Task 名称。Gradle 在编译的时候,会将这个名称显示在控制台上。比如:Task :app:transformClassesWithXXXForDebug。
getInputType:
在项目中会有各种各样格式的文件,通过 getInputType 可以设置 LifeCycleTransform 接收的文件类型,此方法返回的类型是 Set<QualifiedContent.ContentType> 集合。
ContentType 有以下 2 种取值。
- CLASSES:代表只检索 .class 文件;
- RESOURCES:代表检索 java 标准资源文件。
getScopes()
这个方法规定自定义 Transform 检索的范围,具体有以下几种取值:
isIncremental() 表示当前 Transform 是否支持增量编译,我们不需要增量编译,所以直接返回 false 即可。
transform()
在 自定义Transform 中最重要的方法就是 transform()。在这个方法中,可以获取到两个数据的流向。
- inputs:inputs 中是传过来的输入流,其中有两种格式,一种是 jar 包格式,一种是 directory(目录格式)。
- outputProvider:outputProvider 获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做,否则编译会报错。
我们可以实现一个简易 LifeCycleTransform,功能是打印出所有 .class 文件。代码如下:
解释说明:
- 自定义的 Transform 名称为 LifeCycleTransform;
- 检索项目中 .class 类型的目录或者文件;
- 设置当前 Transform 检索范围为当前项目;
- 设置过滤文件为 .class 文件(去除文件夹类型),并打印文件名称。
将自定义的 LifeCycleTransform 注册到 Gradle 插件中
在 LifeCyclePlugin 中添加如下代码:
再次在命令行中执行 build 命令,可以看到 LifeCycleTransform 检索出的所有 .class 文件。
从图中可以看出,Gradle 编译时多了一个我们自定义的 LifeCycleTransform 类型的任务,并且将所有 .class 文件名打印出来,其中包含了我们需要的目标文件 MainActivity.class。
使用 ASM,插入字节码到 Activity 文件
ASM 是一套开源框架,其中几个常用的 API 如下:
- ClassReader:负责解析 .class 文件中的字节码,并将所有字节码传递给 ClassWriter。
- ClassVisitor:负责访问 .class 文件中各个元素,还记得上一课时我们介绍的 .class 文件结构吗?ClassVisitor 就是用来解析这些文件结构的,当解析到某些特定结构时(比如类变量、方法),它会自动调用内部相应的 FieldVisitor 或者 MethodVisitor 的方法,进一步解析或者修改 .class 文件内容。
- ClassWriter:继承自 ClassVisitor,它是生成字节码的工具类,负责将修改后的字节码输出为 byte 数组。
添加 ASM 依赖
在 asm_lifecycle_plugin 的 build.gradle 中,添加对 ASM 的依赖,如下:
创建自定义 ASM Visitor 类
在 asm_lifecycle_plugin module 中的 src/main/java 目录下创建包 danny.jiang.asm,并分别创建 LifecycleClassVisitor.java 和 LifecycleMethodVisitor.java。代码如下:
LifecycleClassVisitor.java
红框中,在 visitMethod 方法中,过滤出继承自 AppCompatActivity 的文件,并在 LifeCycleMethodVisitor.java 中对 onCreate 进行改造。
LifeCycleMethodVisitor.java
图中红框内是真正执行插入字节码的逻辑。可以看出 ASM 都是直接以字节码指令的方式进行操作的,所以如果想使用 ASM,需要程序员对字节码有一定的理解。如果对字节码不是很了解,也可以借助三方工具 ASM Bytecode Outline 来生成想要的字节码。
修改 LifeCycleTransform 的 transform 方法,使用 ASM
各种 Visitor 都定义好之后,我们就可以修改 LifeCycleTransform 的 transform 方法,并将需要插桩的字节码插入到 MainActivity.class 文件中:
重新部署自定义 Gradle 插件,并运行主项目
上面几步如果一切执行顺利,那接下来就可以在点击 uploadArchives 重新部署 LifeCyclePlugin。
注意:重新部署时,需要先在 app module 的 build.gradle 中将插件依赖注释,否则报错。
部署成功之后,重新在 app 中依赖自定义插件并运行主项目,当 MainActivity 被打开时,会在 logcat 中看到如下日志:
后续如果我们有新的 Activity,比如新建一个 BActivity.java 如下:
并在 MainActivity 中设置点击事件跳转到 BActivity 中:
那么 Logcat 中的日志如下:
虽然我们在 MainActivity 和 BActivity 中并没有添加任何 log 日志逻辑,但是在编译期间,自定义的 LifeCyclePlugin 会自动为每一个 Activity 的 onCreate 方法中添加 log 日志逻辑。
读到这里你可能会有疑虑,如果在项目中打开了混淆,那注入的字节码还会正常 work 吗? 其实无需担心,因为混淆其实也是一个 Transform,叫作 ProguardTransform,它是在自定义的 Transform 之后执行。
总结
本课时主要通过一个 Demo,详细操作了一遍编译插桩的流程。期间涉及了几个知识点:
- Android APK 打包编译过程;
- 自定义 Gradle 插件;
- Transform API 的使用;
- ASM 的使用。
本课时作为一篇对编译插桩的入门指导,并没有对以上几个知识点做深入分析。你课后如果感兴趣,可以自行查阅相关资料。最后以一句话结束这一课时:
对技术的追求不仅仅要停留在会用 API,会写基本功能上,要想在技术上有更高的造诣,就需要深入到原理层面去认识代码运行的机制。