1#class 文件结构

class文件里只有两种数据结构:无符号数和表;
无符号数:属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者字符串(UTF-8 编码)。
:表是由多个无符号数或者其他表作为数据项构成的复合数据类型,class文件中所有的表都以“_info”结尾。其实,整个 Class 文件本质上就是一张表。

而这些无符号数和表就组成了 class 中的各个结构。
Cgq2xl6Qc1OACWWHAADJjiKcHuI014.png
这些结构按照预先规定好的顺序紧密的从前向后排列,相邻的项之间没有任何间隙。
Cgq2xl6DCV2AehqNAAD5VToVKKE770.png
魔数 magic number
image.png 魔数是 class 文件的标志,也就是说它是判断一个文件是不是 class 格式文件的标准, 如果开头四个字节不是 0XCAFEBABE, 那么就说明它不是 class 文件, 不能被 JVM 识别或加载。

版本号

image.png
前两个字节 0000 代表次版本号(minor_version),后两个字节 0034 是主版本号(major_version),对应的十进制值为 52,也就是说当前 class 文件的主版本号为 52,次版本号为 0。所以综合版本号是 52.0,也就是 jdk1.8.0

常量池(重点)

紧跟在版本号之后的是一个叫作常量池的表(cp_info)。在常量池中保存了类的各种相关信息,比如类的名称、父类的名称、类中的方法名、参数名称、参数类型等,这些信息都是以各种表的形式保存在常量池中的。
常量池中的每一项都是一个表,其项目类型共有 14 种,如下表所示:
Cgq2xl6DCV6AcrLKAAIl1RRQwuM068.png
CONSTANT_Utf8_info 表具体结构如下:

  1. table CONSTANT_utf8_info {
  2. u1 tag;
  3. u2 length;
  4. u1[] bytes;
  5. }
  6. //tag:值为1,表示是 CONSTANT_Utf8_info 类型表。
  7. //length:length 表示 u1[] 的长度,比如 length=5,则表示接下来的数据是 5 个连续的 u1 类型数据。
  8. //bytes:u1 类型数组,长度为上面第 2 个参数 length 的值。

在java代码中声明的String字符串最终在class文件中的存储格式就 CONSTANT_utf8_info。因此一个字符串最大长度也就是u2所能代表的最大值65536个,但是需要使用2个字节来保存 null 值,因此一个字符串的最大长度为 65536 - 2 = 65534.
因为开发者平时定义的 Java 类各式各样,类中的方法与参数也不尽相同。所以常量池的元素数量也就无法固定,因此 class 文件在常量池的前面使用 2 个字节的容量计数器,用来代表当前类中常量池的大小。如下图所示:
image.png
红色框中的 001d 转化为十进制就是 74,也就是说常量计数器的值为 74。其中下标为 0 的常量被 JVM 留作其他特殊用途,因此 Test.class 中实际的常量池大小为这个计数器的值减 1,也就是 73个。
第一个常量,如下所示:
image.png
0a 转化为 10 进制后为 10,通过查看常量池 14 种表格图中,可以查到 tag=10 的表类型为 CONSTANT_Methodref_info,因此常量池中的第一个常量类型为方法引用表。其结构如下:

  1. CONSTANT_Methodref_info {
  2. u1 tag = 10;
  3. u2 class_index; // 指向此方法的所属类
  4. u2 name_type_index; // 指向此方法的名称和类型
  5. }

也就是说在“0a”之后的 2 个字节指向这个方法是属于哪个类,紧接的 2 个字节指向这个方法的名称和类型。它们的值分别是:
0011: 十进制 17,表示指向常量池中的第 17 个常量
0030: 十进制 48,表示指向常量池中的第 48 个常量
至此,第 1 个常量就解读完毕了。紧接着的就是第 2 个常量 07,
image.png
tag 07 表示是字段引用表 CONSTANT_Class_info ,其结构如下:

  1. CONSTANT_Class_info {
  2. u1 tag;
  3. u2 name_index;
  4. }

0031 : 十进制 49,表示指向常量池中的第 49 个常量
到现在为止我们已经解析出了常量池中的两个常量。剩下的 21 个常量的解析过程也大同小异,这里就不一一解析了。实际上我们可以借助 javap 命令来帮助我们查看 class 常量池中的内容:
image.png

正如我们刚才分析的一样,常量池中第一个常量是 Methodref 类型,指向下标 17 和下标 48 的常量。其中下标 48 的常量类型为 NameAndType,它对应的数据结构如下:

  1. CONSTANT_NameAndType_info{
  2. u1 tag;
  3. u2 name_index; 指向某字段或方法的名称字符串
  4. u2 type_index; 指向某字段或方法的类型字符串
  5. }

而下标在 48 的 NameAndType 的 name_index 和 type_index 分别指向了 36 和 37,
也就是“”和“()V”。
image.png
可以看出Test.class 文件中常量池的第 1 个常量保存的是 Object 中的默认构造器方法。

访问标志(access_flags)

紧跟在常量池之后的常量是访问标志,占用两个字节,如下图所示:
访问标志代表类或者接口的访问信息,比如:该 class 文件是类还是接口,是否被定义成 public,是否是 abstract,如果是类,是否被声明成 final 等等。各种访问标志如下所示:
Ciqah16DCV-ANSgGAAFbz25EF8Y890.png
我们定义的 Test.java 是一个普通 Java 类,不是接口、枚举或注解。并且被 public 修饰但没有被声明为 final 和 abstract,因此它所对应的 access_flags 为 0021(0X0001 和 0X0020 相结合)

类索引、父类索引与接口索引计数器

在访问标志后的 2 个字节就是类索引,类索引后的 2 个字节就是父类索引,父类索引后的 2 个字节则是接口索引计数器。如下图所示:
image.png
类索引为2:
image.png
父类索引为17:
image.png
查看在接口计数器之后的 2 个字节为:0012 ,十进制18
image.png
综上所述,可以得出如下结论:当前类为 MinorGCTest 继承自 Object 类,并实现了“Serializable”接口

字段表

紧跟在接口索引集合后面的就是字段表了,字段表的主要功能是用来描述类或者接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。
同样, 一个类中的变量个数是不固定的,因此在字段表集合之前还是使用一个计数器来表示变量的个数,如下所示:
image.png
0002 表示类中声明了 5 个变量(在 class 文件中叫字段),字段计数器之后会紧跟着 2 个字段表的数据结构。

  1. CONSTANT_Fieldref_info{
  2. u2 access_flags 字段的访问标志
  3. u2 name_index 字段的名称索引(也就是变量名)
  4. u2 descriptor_index 字段的描述索引(也就是变量的类型)
  5. u2 attributes_count 属性计数器
  6. attribute_info
  7. }

image.png

字段访问标志

对于 Java 类中的变量,也可以使用 public、private、final、static 等标识符进行标识。因此解析字段时,需要先判断它的访问标志,字段的访问标志如下所示:
Cgq2xl6DCWCAe5MBAAEjBXwl4wA351.png
001a代表”private”,”static”,”final”
PS
1)字段表集合中不会列出从父类或者父接口中继承而来的字段
2)内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段

方法表

字段表之后跟着的就是方法计数器和方法表常量。
image.png
上面有3个方法,默认构造器方法也被包含在方法表常量中。
方法表的结构如下所示:

  1. CONSTANT_Methodref_info{
  2. u2 access_flags; 方法的访问标志
  3. u2 name_index; 指向方法名的索引
  4. u2 descriptor_index; 指向方法类型的索引
  5. u2 attributes_count; 方法属性计数器
  6. attribute_info attributes;
  7. }

可以看到,方法也是有自己的访问标志,具体如下:
Ciqah16DCWCAAdkIAAFVaPL8OfA302.png
image.png这是removeUnuesdCacheSoftReference方法的描述
0002: 代表private
002b:image.png
0025:image.png无参,无返回类型

属性表

在之前解析字段和方法的时候,在它们的具体结构中我们都能看到有一个叫作 attributes_info 的表,这就是属性表,属性表并没有一个固定的结构,各种不同的属性只要满足以下结构即可:

  1. CONSTANT_Attribute_info{
  2. u2 name_index;
  3. u2 attribute_length length;
  4. u1[] info;
  5. }

image.png
0X0001 是属性计数器,代表只有一个属性。0X0026 是属性表类型索引,通过查看常量池可以看出它是一个 Code 属性表,如下所示:
image.png
Code 属性表中,最主要的就是一些列的字节码。通过 javap -v MinorGCTest.class 之后,可以看到方法的字节码。

2#编译插桩

编译插桩就是在代码编译期间修改已有的代码或者生成新代码。实际上,我们项目中经常用到的 Dagger、ButterKnife 甚至是 Kotlin 语言,它们都用到了编译插桩的技术,Android的编译过程
Ciqah16FrD2AcPLbAABSfiJwMz0698.png
1.在 .java 文件编译成 .class 文件时,APT、AndroidAnnotation 等就是在此处触发代码生成。
2.在 .class 文件进一步优化成 .dex 文件时,也就是直接操作字节码文件,这种方式功能更加强大,应用场景也更多,但是需要对字节码有一定的理解,原理如下Cgq2xl6FrD2ABAAgAACZzFsVdz4155.png

使用场景

1)日志埋点;
2)性能监控;
3)动态权限控制;
4)业务逻辑跳转时,校验是否已经登录;
5)甚至是代码调试等。

插桩工具

AspectJ:老牌 AOP(Aspect-Oriented Programming)框架,其主要优势是成熟稳定,使用者也不需要对字节码文件有深入的理解。
ASM:可以修改现有的字节码文件,也可以动态生成字节码文件,并且它是一款完全以字节码层面来操纵字节码并分析字节码的框架

下面就插桩实现在每一个 Activity 打开时输出相应的 log 日志。

1.自定义gradle插件

1)新建一个Android Library ,在build.gradle做相应的配置

  1. apply plugin: 'groovy'
  2. apply plugin: 'maven'
  3. dependencies {
  4. //gradle sdk
  5. implementation gradleApi()
  6. //groovy sdk
  7. implementation localGroovy()
  8. //ASM相关依赖
  9. implementation 'org.ow2.asm:asm:7.1'
  10. implementation 'org.ow2.asm:asm-commons:7.1'
  11. }
  12. repositories {
  13. mavenCentral()
  14. }
  15. //group和version在后面使用自定义插件的时候会用到
  16. group='com.csz.icp'
  17. version='1.0.0'
  18. uploadArchives {
  19. repositories {
  20. mavenDeployer {
  21. //提交到远程服务器:
  22. // repository(url: "http://www.xxx.com/repos") {
  23. // authentication(userName: "admin", password: "admin")
  24. // }
  25. //本地的Maven地址设置为D:/repos
  26. repository(url: uri('../repos'))
  27. }
  28. }
  29. }

2)在src/main下新建文件夹groovy和java分别存放插件和业务代码;
3)src/main 目录下新建目录 resources/META-INF/gradle-plugins,然后在此目录下新建一个文件:test.properties,其中文件名 test 就是我们自定义插件的名称,再指向我们的插件类

  1. implementation-class=com.csz.icp.TestPlugin

4)app module 中的 build.gradle 中引用此插件

  1. buildscript {
  2. repositories {
  3. /**
  4. * 必须添加谷歌仓库,下载gradle插件
  5. */
  6. google()
  7. jcenter()
  8. maven {//本地Maven仓库地址
  9. url uri('../repos')
  10. }
  11. }
  12. dependencies {
  13. //格式为-->group:module:version
  14. classpath 'com.csz.icp:icp:1.0.0'
  15. }
  16. }
  17. //com.hc.gradle为resources/META-INF/gradle-plugins下的properties文件名称
  18. apply plugin: 'test'

然后rebuild
image.png打印出相应的内容,成功!其实现在已经有了一些比较成熟的三方 Gradle 插件,比如 hiBeaver。如果不喜欢从头创建 Gradle 插件,可以考虑尝试使用。

2.遍历项目中所有的 .class 文件

使用自定义 Transform 找出所有的 .class 文件
什么是 Transform ?
Transform 可以被看作是 Gradle 在编译项目时的一个 task,在 .class 文件转换成 .dex 的流程中会执行这些 task,对所有的 .class 文件(可包括第三方库的 .class)进行转换,转换的逻辑定义在 Transform 的 transform 方法中。实际上平时我们在 build.gradle 中常用的功能都是通过 Transform 实现的,比如混淆(proguard)、分包(multi-dex)、jar 包合并(jarMerge)。
image.png
Transform是个抽象类,有几个方法需要我们区实现;
getName:设置我们自定义的 Transform 对应的 Task 名称。
getInputType:在项目中会有各种各样格式的文件,设置接受的文件类型,具体有两种CLASSES(代表只检索 .class 文件),RESOURCES(代表检索 java 标准资源文件)。
getScopes:规定自定义 Transform 检索的范围,有以下几种
Ciqah16FrECAc6aAAABxuVEoJS4898.png
isIncremental:表示当前 Transform 是否支持增量编译,我们不需要增量编译,所以直接返回 false 即可。
transform:最重要的方法在这个方法中,可以获取到两个数据的流向。
inputs:传过来的输入流,其中有两种格式,一种是 jar 包格式,一种是 directory(目录格式)。
outputProvider: 获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做,否则编译会报错。
PS:自定义的Transform不能用TestTransform命名(踩坑,头大)

3.使用 ASM,插入字节码到 Activity 文件

ASM 是一套开源框架,其中几个常用的 API 如下:
ClassReader:负责解析 .class 文件中的字节码,并将所有字节码传递给 ClassWriter。
ClassVisitor:负责访问 .class 文件中各个元素,还记得上一课时我们介绍的 .class 文件结构吗?ClassVisitor 就是用来解析这些文件结构的,当解析到某些特定结构时(比如类变量、方法),它会自动调用内部相应的 FieldVisitor 或者 MethodVisitor 的方法,进一步解析或者修改 .class 文件内容。
ClassWriter:继承自 ClassVisitor,它是生成字节码的工具类,负责将修改后的字节码输出为 byte 数组。

如果想使用 ASM,需要程序员对字节码有一定的理解。如果对字节码不是很了解,也可以借助三方工具 ASM Bytecode Outline 来生成想要的字节码。
创建ClassVisitor
image.png
创建MethodVisitor
image.png
修改transform
image.png
部署插件,重新在 app 中依赖自定义插件并运行主项目,打开主界面,logcat日志
image.png
PS:如果在项目中打开了混淆,那注入的字节码会受到影响吗? 其实无需担心,因为混淆其实也是一个 Transform,叫作 ProguardTransform,它是在自定义的 Transform 之后执行。

Demo实例参考

以上为全部内容!