Android-编译时插桩Transform

Android-编译时插桩Transform

1. Transform简介

通过 Android 的打包流程可知,Android 除了将 Java 源文件编译成 class 文件之外,还需要将 class 文件转换为 dex 文件,而 Gradle 就为这个过程提供了一个注入节点,允许在转换为 dex 前修改编译生成的 class 文件,因此 Transform 针对的是编译后的产物


2. 自定义Transform

2.1 注册Transform

Gradle 编译的执行单元是 Plugin,因此 Transform 的执行也需要借助 Plugin;将 Transform 注册到 Plugin 后,当 Plugin 被执行时就会根据注册的顺序执行 Transform 任务。

(1)创建一个 Transform 类并继承自 Gradle 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
class DemoTransform extends Transform {
/**
* Transform 任务名,编译时会在日志中显示执行的任务。
*/
@Override
String getName() {
return "DemoTransform"
}

/**
* 指定该 Transform 处理的内容类型为 class 文件。
*/
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}

/**
* 指定该 Transform 扫描的范围,与 #getInputTypes 搭配用于过滤输入的文件内容。
* SCOPE_FULL_PROJECT 表示扫描所有项目。
*/
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}

/**
* 是否支持增量处理,如果支持,则 #transform 传入的文件需要根据俄文件状态判断是否需要处理。
*/
@Override
boolean isIncremental() {
return false
}

/**
* Transform 执行时将通过 transformInvocation 传入过滤后需要处理的内容。
*/
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
// 判断文件状态:
// checkFileState(...)

// 处理输入的文件:
// handleTransform(...)
}
}

(2)创建一个 Plugin 并注册 Transform 任务(Plugin 的注册方式可参考上篇文章):

1
2
3
4
5
6
7
8
class DemoPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
// 实例化并注册至 Plugin 中:
DemoTransform demoTransform = new DemoTransform()
project.android.registerTransform(demoTransform)
}
}

2.2 Transform处理流程

Transform 的工作流程简单来说就是以下几步:

  • (1)通过包名筛选符合条件的 Class 文件,其中 Class 有两种可能的文件来源:
  • (2)通过读取 Class 文件包含的类信息(例如接口、注解等)进一步筛选符合条件的 Class 文件;
  • (3)对最终符合条件的 Class 做处理(修改字节码、插桩等);
  • (4)将产物拷贝至 Transform 的输出目录,作为下一个 Transform 的输入;

2.2.1 过滤Class包名

Gradle 处理的 Class 文件有两种可能的来源:

  • Java 文件源码编译生成的 ClassDir 下的 Class 文件;
  • Jar、AAR 依赖包中已经集成的 Class 文件;

TransformInput 提供了分别读取两种来源的 Class 的接口。此外上文提到 Transform 可以指定是否开启增量模式,增量模式可以通过忽略未发生变更的文件来优化编译速度。因此可以在 JarInputDirectoryInput 两个接口中先分别判断一下文件状态:

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
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation)

// 如果不是增量模式,需要先删除所有之前生成的产物再重新生成。
if (!transformInvocation.isIncremental()) {
transformInvocation.outputProvider.deleteAll()
}

transformInvocation.inputs.each { ->
// 解析 Jar 中的 Class:
it.jarInputs.each { JarInput eachJarInput ->
// 也可以单独判断 Jar 中 Class 的文件状态:
Status jarClassStatus = eachJarInput.getStatus();
......
}

// 解析 ClassDir 中的 Class:
it.directoryInputs.each { DirectoryInput eachDirectoryInput ->
// 也可以单独判断 ClassDir 中 Class 的文件状态:
Map<File, Status> classStatusMap = eachDirectoryInput.getChangedFiles();
}
}
}

public enum Status {
/**
* The file was not changed since the last build.
*/
NOTCHANGED,
/**
* The file was added since the last build.
*/
ADDED,
/**
* The file was modified since the last build.
*/
CHANGED,
/**
* The file was removed since the last build.
*/
REMOVED;
}

2.2.2 过滤Class类信息

由于 Transform 是以文件形式接收 Class 输入,因此第一层过滤只能通过文件路径反推包名来大致过滤包名符合条件的 Class 文件,但要确定一个 Class 是否为 Transform 的处理目标,还需要读取 Class 文件中包含的类信息,例如接口、注解等,此时需要借助 ClassWriter、ClassReader 等 ASM 工具。

2.2.3 读写Class字节码

借助 ASM 工具可以读写文件形式的 Class。

2.2.4 拷贝Transform的产物

由于 Transform 的输入和输出是「流式」的,每个 Transform 的输出即作为下一个 Transform 的输入,所以为了让下一个 Transform 仍有机会处理所有的类(而不论当前 Transform 是否已经处理过),通常会将每个原始输入(包含 Jar 或 ClassDir)都拷贝至输出目录下。

当然,Transform 也可以根据是否增/删/改了某个 Class 的情况自由调整拷贝的内容,只需切记 Transform 的输出将作为下一个 Transform 的输入 即可。


3. Transform过滤Class

3.1 从Jar文件中筛选Class

筛选 Jar 文件实际上就是将 Jar 解包后读取其中的 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
@Override
void transform(TransformInvocation transformInvocation) {
super.transform(transformInvocation)

transformInvocation.inputs.each { ->

// 解析 Jar 中的 Class:
it.jarInputs.each { JarInput eachJarInput ->
// 1. 从 JarInput 转为原始 Jar 文件:
File srcJarFile = eachJarInput.file
// 2. 计算 Transform 执行后即将被转换的新 Jar 文件:
File targetJarFile = getTargetJarFile(transformInvocation, eachJarInput)
// 3. 排除 Android 原生包下的文件:
if (isJarInvalid(eachJarInput)) {
continue;
}
// 4. 筛选 Jar 包内解析的 Class:
enumerateJarEntryFromJarFile(srcJarFile)
// 5. 拷贝 Jar 文件:
// 由于 Transform 的输入和输出是「流式」的,每一个 Transform 的输出都将作为下一个 Transform 的输入,
// 因此为了确保下一个 Transform 仍然有机会处理每个 Jar(不论当前 Transform 是否已经处理过),
// 都需要把 Jar 重新拷贝一份到当前 Transform 的输出目录下,
// targetJarFile 就是就是根据原始 Jar 计算出的当前 Transform 处理后「将要」生成的输出文件。
FileUtils.copyFile(jarSrcFile, jarTargetFile)
}

// 解析 ClassDir 中的 Class:
it.directoryInputs.each { DirectoryInput eachDirectoryInput ->
......
}
}
}

/**
* 获取原始 Jar 文件对应的 Transform 执行后的文件。
*
* 该方法实际上并不会创建一个新的文件,
* 而是通过 TransformOutputProvider 提前获取到了某个 Jar 文件被 Transform 处理后「将要」生成的文件信息。
*/
private static File getTargetJarFile(TransformInvocation transformInvocation, JarInput jarInput) {
// 截取原始 Jar 文件的 ShortName:
String srcJarFileName = jarInput.name
if (srcJarFileName.toLowerCase().endsWith(".jar")) {
srcJarFileName = srcJarFileName.substring(0, srcJarFileName.length() - 4)
}
// 计算原始 Jar 文件的 MD5 拼接成一个新的唯一文件名,仅仅是为了作为唯一区分,直接用原始文件名一般也可行:
String srcJarFileMd5 = DigestUtils.md5Hex(jarInput.file.absolutePath)
String targetJarFileName = srcJarFileName + '_' + srcJarFileMd5
// 可以粗略地理解为,每一个 Jar 文件如果被 Transform 处理则会对应产生一个新的文件,这一步就是提前拿到将要产生的新文件信息(但不会提前创建)。
return transformInvocation
.outputProvider
.getContentLocation(targetJarFileName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
}

/**
* 跳过 Android 原生包下的 Jar。
*/
private static boolean isJarInvalid(JarInput jarInput) {
return (pathName.contains("com.android.support")
|| pathName.contains("/android/m2repository")
|| pathName.contains("androidx."))
}

/**
* 遍历 Jar 文件内的每个元素,找出包名符合规则的 Class。
*/
private static void enumerateJarEntryFromJarFile(File srcJarFile) {
def jarFile = new JarFile(jarSrcFile)
Enumeration allElementsInJar = jarFile.entries()
while (allElementsInJar != null && allElementsInJar.hasMoreElements()) {
JarEntry eachJarEntry = (JarEntry) allElementsInJar.nextElement()
// 判断 Jar 中的元素是否满足条件:
if (eachJarEntry.getName().startsWith("priv/demo")
&& eachJarEntry.getName().toLowerCase().endsWith(".class")) {
// 找到包名满足条件的 Class 后,还要判断其类信息(例如接口、注解等)是否也满足条件:
// scanClassFromJarEntry(...)
}
}
jarFile.close()
}

3.2 从编译目录中筛选Class

筛选 Class 目录实际上就是遍历目录中包含的每个 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
@Override
void transform(TransformInvocation transformInvocation) {
super.transform(transformInvocation)

transformInvocation.inputs.each { ->

// 解析 Jar 中的 Class:
it.jarInputs.each { JarInput eachJarInput ->
......
}

// 解析 ClassDir 中的 Class:
it.directoryInputs.each { DirectoryInput eachDirectoryInput ->
// 1. 从 DictionaryInput 转换为原始目录文件:
File srcClassDir = directoryInput.file
// 2. 计算 Transform 执行后即将被转换的新目录文件:
File targetClassDir = getTargetClassDirectoryFile(transformInvocation, eachDirectoryInput)
// 3. 筛选 Class 目录内的 Class:
enumerateClassFromClassDir(srcClassDir)
// 4. 拷贝 Class 目录:
// 同理,为了确保下一个 Transform 仍然有机会处理每个 ClassDir(不论当前 Transform 是否已经处理过),
// 都需要把 ClassDir 重新拷贝一份到当前 Transform 的输出目录下,
// targetClassDir 就是就是根据原始 ClassDir 计算出的当前 Transform 处理后「将要」生成的目录文件。
FileUtils.copyDirectory(srcClassDir, targetClassDir)
}
}
}

/**
* 获取原始 Class 目录对应的 Transform 处理后的目录。
*
* 该方法实际上并不会创建一个新的目录,
* 而是通过 TransformOutputProvider 提前获取到了某个 Class 目录被 Transform 处理后「将要」生成的目录信息。
*/
private static File getTargetClassDirectoryFile(TransformInvocation transformInvocation, DirectoryInput directoryInput) {
return transformInvocation
.outputProvider
.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
}

/**
* 遍历 Class 目录内的每个文件,找出包名符合规则的 Class。
*/
private static void enumerateClassFromClassDir(File srcClassDir) {
srcClassDir.eachFileRecurse { File eachClassFile ->
if (!eachClassFile.isFile()) {
continue;
}
// 根据每个 Class 文件的路径反推 Class 的包名:
String eachClassShortPath = getClassShortPathFromFile(srcClassDir, eachClassFile)
// 判断每个 Class 是否满足条件:
if (eachClassShortPath.startsWith("priv/demo")
&& eachClassShortPath.endsWith(".class")) {
// 找到包名满足条件的 Class 后,还要判断其类信息(例如接口、注解等)是否也满足条件:
// scanClassFromClassFile(...)
}
}
}

/**
* 根据 Class 文件以及所在目录反推 Class 的文件短路径。
*
* 因为每个 Java 类会根据包名创建对应的目录结构,但在不同构建设备上绝对路径并不一定相同,
* 例如:priv.demo.DemoClass.java 绝对路径可能为 XXX/Project/src/main/java/priv/demo/DemoClass.java
* 只有包名对应的短路径一定相同:priv/demo/DemoClass.Java,所以只能通过短路径反推包名是否符合条件。
*/
private static String getClassShortPathFromFile(File srcClassDir, File srcClassFile) {
// 先拿出类所在目录的绝对路径:
String srcClassDirPath = srcClassDir.absolutePath
if (!srcClassDirPath.endsWith(File.separator)) {
srcClassDirPath += File.separator
}
// 从类的绝对路径中删掉前面所在目录的部分:
// Class 目录是一个大目录,其路径不包含 Class 包名对应目录。
String classShortPath = srcClassFile.absolutePath.replace(srcClassDirPath, '')
// 由于构建机系统并不确定,还需要将路径转换为统一的标准,此处均以 Unix 路径为标准:
if (File.separator != '/') {
classShortPath = classShortPath.replace("\\\\", "/")
}
return classShortPath;
}

3.3 判断Class文件的类信息

上文在筛选 Jar 及 ClassDir 时,实际上都是通过 enumerateXXX 读取了文件/目录内的每个元素,但:

  • Jar 文件遍历的目标是 JarEntry
  • ClassDir 遍历的目标是 Class 对应的 File

本质上它们都是文件类型,而仅仅判断包名通常并不足以满足条件,有时还需要读取文件获取与 Class 相关的信息(例如接口、注解等)进一步判断,此时可以通过 ClassVisitor 读取:

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
/**
* 创建一个自定义的 ClassVisitor,用于读取 Class 文件的类信息。
*/
class ClassScannerClassVisitor extends ClassVisitor {

static scanFromFileInputStream(InputStream classFileInputStream) {
ClassReader classReader = new ClassReader(classFileInputStream)
ClassWriter classWriter = new ClassWriter(classReader, 0)
ClassScannerClassVisitor scannerClassVisitor = new ClassScannerClassVisitor(Opcodes.ASM5, classWriter)
classReader.accept(scannerClassVisitor, ClassReader.EXPAND_FRAMES)
}

ClassScannerClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor)
}

@Override
void visit(int version, int access, String className, String signature,
String superName, String[] interfaces) {
super.visit(version, access, className, signature, superName, interfaces)

// 示例 - 读取类的接口信息:
interfaces.each { interfaceName ->
}
}
}

/**
* 通过自定义的 ClassScannerClassVisitor 从 JarEntry 中读取类信息。
*/
private static void scanClassFromJarEntry(File srcJarFile, JarEntry jarEntry) {
InputStream srcJarFileInputStream = srcJarFile.getInputStream(jarEntry)
ClassScannerClassVisitor.scanFromFileInputStream(srcJarFileInputStream)
srcJarFileInputStream.close()
}

/**
* 通过自定义的 ClassScannerClassVisitor 从 Class 文件中读取类信息。
*/
private static void scanClassFromClassFile(File classFile) {
FileInputStream classFileInputStream = new FileInputStream(classFile)
ClassScannerClassVisitor.scanFromFileInputStream(classFileInputStream)
classFileInputStream.close()
}

4. Transform字节码插桩


参考文献