Android编译期修改Class字节码

Android 打包过程

先放一张官网的图和描述

  1. 编译器将源代码转换成DEX文件,将所有其他内容转换成编译资源。
  2. APK打包器将DEX文件和已编译资源合并成单个APK。
  3. APK打包器使用DEBUG或者RELEASE的签名文件签署APK。
  4. 在生成最终APK之前,打包器会使用zipalign工具对APK进行优化,减少设备运行时的内存占用。

再放一张较为详细的图和描述

  1. 通过AAPT打包resource资源文件,编译成R.java、resource.arsc等资源相关文件。
  2. 通过aidl工具处理源码中的.aidl文件,生成对用的java文件。
  3. 通过Java Compiler(javac)工具编译.java文件生成.class文件。
  4. 通过dx工具,将第三方jar、.class文件以及上一步生成的.class文件以及R.class文件打包成dex文件(Android可执行文件)。
  5. 通过apkbuilder工具将dex以及第一步的资源文件打包成apk。
  6. 通过apksigner工具,使用DEBUG或者RELEASE签名文件,签署apk文件。
  7. 通过zipalign工具,对其优化apk。

再放一张更为详细的图和描述

从上面的这些图来看,如果想要在编译期修改.class文件就必须需要在dx工具将.class文件打包成dex文件之前去修改才行,我们发现最详细的那张图中,在生成dex文件之前,经过了proguard混淆加密的过程,它就是一个修改.class文件的过程,我们可以参考produard是如何实现的。

Gradle插件

Proguard是通过Transfrom Api实现的,而Transform是依附于Gradle插件之上的,所以先介绍自定义Gradle插件。AndroidStudio中实现自定义Gradle插件有三种方式,一种是.gradle脚本文件,一种buildSrc模块,最后一种是独立工程。实现插件可以选择使用groovy、java以及kotlin语言。Groovy语言ide高亮支持的不是很好,没有引入的类库不会标红,难受,所以我写插件一般选择java或者kotlin。

.gradle脚本

gradle文件,构建脚本内,不过这种只能在文件内使用。

单独Module的插件

可以随意一个工程去引用,前提是需要发布到本地仓库或者maven私服。

buildSrc

可以在自己的工程内随意使用,但是其他工程就无法使用。这种模式和单独Module的插件的唯一区别就是单独Module插件需要上传,工程中无法直接引用。单独Module的插件中的build.gradle文件需要有上传的脚本代码。这里看一下buildSrc形式插件的具体结构目录:

  • build.gradle
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    apply plugin: 'java' // groovy: apply plugin: 'groovy'

    dependencies {
    implementation gradleApi() // gradle sdk
    }

    repositories {
    google()
    jcenter()
    }

如果是单独module插件形式就多了上传的代码。

  • TestEntryPlugin.java
    插件的入口文件。

    1
    2
    3
    4
    5
    6
    public class TestEntryPlugin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
    System.out.println("custom plugin"); // 打印
    }
    }
  • resources下的.properties
    指明插件的入口文件。

    1
    implementation-class=me.jiahuan.plugin.TestEntryPlugin

以上就是最基本的插件的实现,实现完成之后就需要应用当前插件,只需要在app module的build.gradle中添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apply plugin: 'me.jiahuan.plugin'
```
添加完成后,当点击sync project with grale files按钮就可在build panel中看到代码中的打印信息:
![](https://ws4.sinaimg.cn/large/006tNc79ly1fz1mtij6uhj30gg07kjsl.jpg)

简单说一下Gradle Project的概念,在Gradle中一个build.gradle就代表一个project。可以通过org.gradle.api.Project对象的Project.getExtensions().create(name,class)方法获取build.gradle中写的DSL。

Gradle插件在Android开发中担负中比较重要的角色,最熟悉的插件就是`com.android.application`,这是开发Android的基础。像一些比较成熟的第三方框架,也会开会插件去让开发者更方便的接入,也有一些组件化的方案会通过插件去解耦等等。


## Gradle Transform Api
Transform Api是Android Gradle Plugin 1.5版本 之后引入的概念。第三方可以构建Gradle插件来使用Transform Api去修改编译之后的.class文件。Android中的proguard就是以此实现的,翻阅[TaskManager](https://android.googlesource.com/platform/tools/base/+/studio-master-dev/build-system/gradle-core/src/main/java/com/android/build/gradle/internal/TaskManager.java)发现Android Gradle Plugin中还有很多的transform,如jacoco、proguard、multi-dex以及InstantRun等等。

### Transform基本原理
Transform可以有很多个,他们形成责任链模式。Transform有输入和输出,输入是上一个transfrom的输入。因此我们可以在自定义的transfrom中修改.class文件,输出给下一个transform。第一个transfrom的输入就是编译打包过程中通过Java Compiler(javac)工具编译.java文件生成.class文件以及R.class文件。
![](https://ws3.sinaimg.cn/large/006tNc79ly1fz1n0dikrjj30mb03owei.jpg)

说完Transform的传递,来简单说一下transform输入数据的过滤。transform从两个维度过滤数据。
- ContentType
```java
CONTENT_CLASS
CONTENT_JARS
CONTENT_RESOURCES
CONTENT_NATIVE_LIBS
CONTENT_DEX
CONTENT_DEX_WITH_RESOURCES
DATA_BINDING_BASE_CLASS_LOG_ARTIFACT

有些ContentType只能被Android Plugin使用。

  • Scope
    1
    2
    3
    4
    5
    6
    7
    8
    PROJECT_ONLY
    SCOPE_FULL_PROJECT
    SCOPE_FULL_WITH_IR_FOR_DEXING
    SCOPE_FULL_WITH_FEATURES
    SCOPE_FULL_WITH_IR_AND_FEATURES
    SCOPE_FEATURES
    SCOPE_FULL_LIBRARY_WITH_LOCAL_JARS
    SCOPE_IR_FOR_SLICING

同样的,我们只能用某些Scope。

Transform简单实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

public class TestTransform extends Transform {

private Project mProject;

public TestTransform(Project project) {
mProject = project;
}

@Override
public String getName() {
return "TestTransform";
}

@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS; // ContentType 过滤
}

@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT; // Scope 过滤
}

@Override
public boolean isIncremental() {
return false;
}

@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
// 当前是否为增量编译
boolean isIncremental = transformInvocation.isIncremental();
// 消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
Collection<TransformInput> inputs = transformInvocation.getInputs();
// OutputProvider管理输出路径,如果消费型输入为空,OutputProvider = null
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

//
if (!isIncremental) {
outputProvider.deleteAll();
}

// 获取输入
for (TransformInput input : inputs) {
// 获取输入 .jar文件
for (JarInput jarInput : input.getJarInputs()) {
// 这里可以修改文件达到目的
...
// 设置输出
File dest = outputProvider.getContentLocation(
jarInput.getFile().getAbsolutePath(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
FileUtils.copyFile(jarInput.getFile(), dest);
}

// 获取输入 .class文件
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
// 这里可以修改文件达到目的
...
// 设置输出
File dest = outputProvider.getContentLocation(
directoryInput.getName(),
directoryInput.getContentTypes(),
directoryInput.getScopes(),
Format.DIRECTORY);
FileUtils.copyDirectory(directoryInput.getFile(), dest);
}
}
}
}
  • TransformInput
    上一个transform的输出文件,分为JarInput和DirectoryInput。输入输出的.class文件都可以在build/intermediates/transforms中找到:

  • TransformOutputProvider
    transform输出,用来获取输出.class文件路径。

以上展示的transform代码,对.class文件没有任何修改的输出。Transform还有更高级的增量修改,可以看看这篇博客

Java字节码

理论就不说了。我们要掌握的是如何查看.java .kt文件字节码,然后参照源码和字节码一一对应。就像学习kotlin语言,你写好java版本的,然后用工具转换成kotlin版本参照学习。在AndroidStudio中居然没有什么比较简单的方式查看.java文件的字节码,插件也不是很好用,我们可以利用javap命令来查看:

1
javap -c XXX.class

kotlin文件到是在AndroidStuido中集成了工具,查看就会比较方便,如下:


很好用,鼠标点到哪里,就对应高亮哪一块对应的字节码。PS:如果在kotlin代码中使用协程,在decomile的时候,as会死掉。应该是什么bug。

ASM 字节码控制框架

正常来说修改.class文件就是文件的读写,你可以在任意位置插入任意字节码,但是想要在特定的地方插入特定的字节码还是相当复杂的。市面上有很多字节码的修改框架,本篇使用的是ASM。举个小栗子,上一部分的hello函数中的CostCalculator.startCal(“hello”),在.class文件中插入相同含义的字节码:

  1. 拿到代码对应的字节码

    1
    2
    3
    GETSTATIC me/jiahuan/gradle_plugin_sample/CostCalculator.INSTANCE : Lme/jiahuan/gradle_plugin_sample/CostCalculator;
    LDC "hello"
    INVOKEVIRTUAL me/jiahuan/gradle_plugin_sample/CostCalculator.startCal (Ljava/lang/String;)V
  2. 一一对应写ASM代码

    1
    2
    3
    mv.visitFieldInsn(GETSTATIC, "me/jiahuan/gradle_plugin_sample/CostCalculator", "INSTANCE", "Lme/jiahuan/gradle_plugin_sample/CostCalculator;");
    mv.visitLdcInsn("hello");
    mv.visitMethodInsn(INVOKEVIRTUAL, "me/jiahuan/gradle_plugin_sample/CostCalculator", "startCal", "(Ljava/lang/String;)V", false);

一条对一条很简单。这里具体不讲解如何使用的,因为好难说清楚:),直接看下面的具体实际例子,看看代码。PS:asm插入的字节码不会影响原来代码的行数,所以不用担心报错的时候堆栈行数不一致。

实例:统计每个函数的耗时

有这样一个需求,需要统计每个函数消耗的时间,找出一些比较耗时的函数进行优化。如果是传统的怎么做?每个函数进入的时候记录个时间,然后最后return的时候记录个时间,相减一下。如果是个小项目,可能动手每个函数加一加没什么问题,但是大项目要这么做就有点过份了。那了解了编译器修改.class文件的话就比较简单啦,在编译期间,在每个函数的入口和出口插桩字节码,实现和人手写代码一样的效果。这里只展示一下修改字节码的代码,其他的上面都有提到过,最后也可以看完整源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public class TestDirectoryInputHandler {
public static void handle(File dir) {
System.out.println("class dir path = " + dir.getAbsolutePath());
if (dir.isDirectory() && dir.exists()) {
// 递归遍历
for (File file : dir.listFiles()) {
handle(file);
}
} else {
handleFile(dir);
}
}

private static void handleFile(File file) {
InputStream inputStream = null;
OutputStream outputStream = null;
try {
if (file.getName().equals("MainActivity.class")) {
System.out.println("find!!!!!!!!!!!");
inputStream = new FileInputStream(file);
ClassReader classReader = new ClassReader(inputStream);
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
// 自定义ClassVisitor
CallClassVisitor callClassVisitor = new CallClassVisitor(classWriter, file.getName());
classReader.accept(callClassVisitor, 0);
// 输出临时class文件路径
String tempFilePath = file.getParentFile().getAbsolutePath() + File.separator + file.getName() + ".opt";
File tempFile = new File(tempFilePath);
outputStream = new FileOutputStream(tempFilePath);
outputStream.write(classWriter.toByteArray());
if (tempFile.exists()) {
tempFile.renameTo(file);
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}

if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}


static class CallClassVisitor extends ClassVisitor implements Opcodes {

private String mClassName;

public CallClassVisitor(ClassVisitor cv, String className) {
super(ASM6, cv);
mClassName = className;
}

@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
System.out.println("method name = " + name + ", signature = " + signature + ", desc = " + desc);
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
return mv == null ? null : new CallMethodVisitor(mv, mClassName, name, desc);
}
}

static class CallMethodVisitor extends MethodVisitor implements Opcodes {

private String mName;
private String mDesc;
private String mClassName;

public CallMethodVisitor(MethodVisitor mv, String className, String name, String desc) {
super(ASM6, mv);
mClassName = className;
mName = name;
mDesc = desc;
}

@Override
public void visitCode() {
if (mName.startsWith("hello")) {
// GETSTATIC me/jiahuan/gradle_plugin_sample/CostCalculator.INSTANCE : Lme/jiahuan/gradle_plugin_sample/CostCalculator;
mv.visitFieldInsn(GETSTATIC, "me/jiahuan/gradle_plugin_sample/CostCalculator", "INSTANCE", "Lme/jiahuan/gradle_plugin_sample/CostCalculator;");
// LDC "haha"
mv.visitLdcInsn(mClassName + mName + mDesc);
// INVOKEVIRTUAL me/jiahuan/gradle_plugin_sample/CostCalculator.startCal (Ljava/lang/String;)V
mv.visitMethodInsn(INVOKEVIRTUAL, "me/jiahuan/gradle_plugin_sample/CostCalculator", "startCal", "(Ljava/lang/String;)V", false);
}
}

@Override
public void visitInsn(int opcode) {
if (mName.startsWith("hello")) {
if (opcode == RETURN) {
// GETSTATIC me/jiahuan/gradle_plugin_sample/CostCalculator.INSTANCE : Lme/jiahuan/gradle_plugin_sample/CostCalculator;
mv.visitFieldInsn(GETSTATIC, "me/jiahuan/gradle_plugin_sample/CostCalculator", "INSTANCE", "Lme/jiahuan/gradle_plugin_sample/CostCalculator;");
// LDC "haha"
mv.visitLdcInsn(mClassName + mName + mDesc);
// INVOKEVIRTUAL me/jiahuan/gradle_plugin_sample/CostCalculator.endCal (Ljava/lang/String;)V
mv.visitMethodInsn(INVOKEVIRTUAL, "me/jiahuan/gradle_plugin_sample/CostCalculator", "endCal", "(Ljava/lang/String;)V", false);
}
}
super.visitInsn(opcode);
}
}
}

以上实现的是查找MainActivity.class中hello开头的函数,在函数的头和尾插入的统计耗时。以下是原MainActivity.kt以及编译经过transform之后.class文件的逆向:

Sample完整源码

总结

此项技术可以应用于比较多的实用需求,比如埋点的插桩实现方式、方法性能检测以及日志监测等等需求。当然也可以直接生成某些类,实现和注解生成器一样的效果。