Android字节码插桩

9/16/2022 框架

# 背景

源自一个内部需求: 在底层没有源码的库中插入一段统计逻辑,这种情况下无法直接在编码环节进行干预,只能在编译阶段插入,Android字节码插桩技术刚好能够帮助到我们

# 整体流程

gradle build

# Gradle Plugin

# 1. 在build.gradle中直接编写

最简单的一种方式

优点:简单,不需要单独创建文件 缺点:可拓展性差,不适合复杂的场景

apply plugin: TestPlugin

class TestPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.task("myTask") {
            doLast {
                // ...
            }
        }
    }
}

# 2. 在buildSrc中编写

最快捷的一种方式

Gradle在运行任务前会检查项目根目录中是否存在buildSrc目录,如果存在的话,会先编译和测试其中的代码并将其加入到构建脚本的classpath中

优点:独立维护,修改方便,且默认引入gradle api相关依赖 缺点:项目间的复用仍需要移植源码,且buildSrc的变更会导致整个项目的状态更改为OUT-OF-DATE,也就是说增量编译会失效

class TestPlugin: Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.getByType(AppExtension::class.java)
        extension.registerTransform(AsmTransform())
    }
}

这种方式编写的插件需要在resources/META-INF/gradle-plugins下定义一个类型为properties的文件,文件名将作为插件名,文件内容为类的全路径implementation-class=com.aopmeta.build.plugin.TestPlugin,高版本的gradle可直接在build.gradle中进行注册

gradlePlugin {
    plugins {
        TestPlugin {
            id = 'com.aopmeta.build.plugin'
            implementationClass = 'com.aopmeta.build.plugin.TestPlugin'
        }
    }
}

# 3. 在独立模块中编写

最易复用的一种方式

在独立模块中编写插件与buildSrc中编写类似,但需要自行依赖gradleApi(),另外插件编写完成后需要提交到maven仓库中其他模块才能正常使用

优点:独立维护,复用简单 缺点:依赖maven仓库,每次修改后都需要重新提交

# Android Transform

Transform Api是包含在Android Gradle插件中,它运行的第三方插件在编译后class文件转换为dex文件之前做相关的处理操作,它既可以处理class文件也可以处理Java标注的resource资源以及本地和远程依赖的jar包

每个注册的Transform都对应这一个Task,TaskManager会将每个Transform Task串联起来,前一个Transform Task的输出将作为下一个Transform Task的输入

transform

build-process

# 关键方法

class AsmTransform: Transform() {
    /**
     * Transform名称,在任务执行时会看到
     */
    override fun getName(): String = "AOPMETA"

    /**
     * 指定要处理的数据类型,类型包括class文件和resources文件
     */
    override fun getInputType(): MutableSet<QualifiedContent.ContentType> {
        return mutableSetOf(DefaultContentType.CLASSES)
    }

    /**
     * 制定作用域
     * 
     * PROJECT            // 只处理当前项目,也就是依赖了该插件的项目
     * SUB_PROJECTS       // 只处理子项目
     * EXTERNAL_LIBRARIES // 只处理外部依赖库
     * TESTED_CODE        // 只处理测试代码
     * PROVIDED_ONLY      // 只处理本地或远程compileOnly依赖的库(只编译不打包)
     */
    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        return mutableSetOf(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
    }

    /**
     * 是否开启增量编译,增量编译用于加快编译速度
     */
    override fun isIncremental(): Boolean = true

    /**
     * 处理转换过程
     */
    override fun transform(invocation: TransformInvocation?)
}

当执行任意task时会在build日志中找到我们定义的Transform

> Task :app:transformClassesWithAOPMETAForDebug UP-TO-DATE //UP-TO-DATE表明开启了增量编译

由于我们只需要处理外部依赖的class文件,所以getInputTypes只需要指定为CLASSES,getScopes只需要指定为EXTERNAL_LIBRARIES,且开启增量编译

# 转换细节

override fun transform(invocation: TransformInvocation?) {
    invocation?.outputProvider?.let { outputProvider ->
        // 非增量请求先清空输出目录防止文件冲突
        if (!invocation.isIncremental) {
            outputProvider.deleteAll()
        }

        // 遍历所有输入
        invocation.inputs?.forEach { transformInput ->
            // 每条输入都包含有jar和class文件目录
            transformInput.jarInputs.forEach {...}
            for (directoryInput in transformInput.directooryInputs) {...}
        }
    }
}

根据增量状态选择性的进行处理

val inputJar = jarInput.file
// 调用其内部方法帮助我们生成不会重复的文件
val ouotputJar: File = outputProvider.getContentLocation(
    jarInput.name,
    jarInput.contentTypes,
    jarInput.scopes,
    Format.JAR
)
// 增量情况下只处理新增的和变动过的
if (invocation.isIncremental) {
    when (jarInput.states) {
        Status.NOTCHANGED -> {}
        Status.ADDED, Status.CHANGED -> transformJar(inputJar, outputJar)
        Status.REMOVED -> FileUtils.delete(outpuptJar)
        else -> {}
    }
} else {
    transformJar(inputJar, outputJar)
}

// Jar包需要进行解压,解压后根据类型判断是否是class文件

Transform会在build/intermediates/transforms/AOPMETA/debug中生成文件名为0~N.jar的文件以及一个——content_.json文件,用于描述jar包内容,如下

[
    {
        "name": "android.local.jars:....jar:...",
        "index": 0,
        "scopes": [
            "EXTERNAL_LIBRARIES"
        ],
        "types": [
            "CLASSES"
        ],
        "format": "JAR",
        "present": true
    }
    ...
]

# 总结与后续

Android Transform赋予了我们在class转换为dex之前处理class的能力,我们根据作用域和类型对class进行筛选,而jar包也将解压为多个class文件,为下一步字节码处理做准备

但这里我们也发现Transform很明显的缺点:多次IO操作,编译时长明显增加

Android Gradle插件在7.0以上版本引入AsmClassVistoryFactory来简化并提升构建性能,它只需进行一次IO操作,且不需要单独解压jar包,但它与ASM字节码操作类库强绑定,损失了一些灵活性,但ASM相比于其他字节码操作类库有明显的性能优势,所以影响并不是很大

// 7.0以上操作
class AsmPlugin: Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
        extension.onVariants { variant ->
            variant.instrumentation.transformClassesWith(CustomClassVisitorFactory::class.java, InstrumentationScope.ALL) {
                it.writeTo.set(true)
            }
            variant.instrumentation.setAsmFramesComputationMode(FramesComputationMode.COPY_FRAMES)
        }
    }

    interface CustomParams: InstrumentationParameters {
        @get:Input
        val writeTo: Property<Boolean>
    }

    abstract class CustomClassVisitorFactory: AsmClassVisitorFactory<CustomParams> {
        override fun createClassVisitor(
            classContext: ClassContext,
            nextClassVisitor: ClassVisitor
        ): ClassVisitor {
            return CustomClassVisitor(Opcodes.ASM9, nextClassVisitor)
        }

        override fun isInstrumentable(classData: ClassData): Boolean {
            return classData.className == "xxx"
        }
    }
}

# Java Asm

Java Asm与其他字节码操作框架功能类似,但它更侧重于性能,它的设计和实现都尽可能小和快,它被用在很多项目中,包括OpenJDK、Gradle、CGLIB、Groovy和Kotlin编译器等等。Android Gradle插件将在8.0版本正式移除Transform,届时将只能采用Asm进行字节码操作。由于Asm是直接在字节码层面进行操作,所以相比其他类似Javassist的可直接编写源码的框架来说,学习成本也会相对高一些。

// 创建一个ClassReader用于读取原始class
val classReader = ClassReader(srcBytes)
// 创建一个ClassWriter用于重新组装新的class,将构造器的参数设置为COMPUTE_FRAMES
// COMPUTE_FRAMES 会自动计算栈的大小
val classWriter = ClassWriter(ClassWriter.COMPUTE_FRAMES)
// SKIP_DEBUG 减少调试信息,降低代码量
// SKIP_FRAMES 降低代码复杂度
classReader.accept(
    AsmClassVisitor(classWriter, classInfo.methodInfos),
    
    // 与COMPUTE_FRAMES配合让Asm帮助我们重新计算frame信息
    ClassReader.SKIP_FRAMES
)
classWriter.toByteArray()

# 参数解读

accept方法的第二个参数parsingOptions包含5个取值的组合

参数 含义
SKIP_CODE 忽略代码信息
SKIP_DEBUG 忽略调试信息
SKIP_FRAMES 忽略frame信息
EXPAND_FRAMES 对frame信息进行拓展

构造器的参数COMPUTE_FRAMES则让Asm帮助我们自动计算max stacks、max locals和stack map frames的具体值,它们在具体位置如下 COMPUTE_FRAMES

# 实际操作

class AsmClassVisitor(classVisitor: ClassVisitor, private val methodInfoList: Array<MethodInfo>) :
    ClassVisitor(Opcodes.ASM9, classVisitor) {
    override fun visitMethod(
        access: Int,
        name: String?, // 方法名
        descriptor: String?, // 方法描述符,有参数类型和返回类型确定
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val visitorMethod = super.visitMethod(access, name, descriptor, signature, exceptions)
        // 找到需要插桩的方法
        return methodInfoList.find {
            it.name == name && descriptor == it.descriptor
        }?.let {
            it.methodVisitorFactory(api, visitorMethod, access, name, descriptor)
        } ?: visitorMethod
    }
}

class TestHandleMethodVisitor(api: Int, methodVisitor: MethodVisitor?, access: Int, name: String?, descriptor: String?): AdviceAdapter(api, methodVisitor, access, name, descriptor) {
    override fun onMethodEnter() {
        // 插入一个静态方法
        mv.visitMethodInsn(INVOKESTATIC, "com/aopmeta/inject/Utils", "invoke", "()V", false)
        super.onMethodEnter()
    }
}

# 如何简化

利用TraceClassVistor帮助我们生成部分代码

ClassReader(inputStream).accept(
    TraceClassVisitor(
        null,
        ASMifier(),
        PrintWriter(System.out, true)
    ), ClassReader.SKIP_FRAMES or ClassReader.SKIP_DEBUG
)

首先利用javac生成class文件,再转换为输入流进行读取与打印

以一段简单的java为例

public int run() {
    int a = 315;
    int b = 5;
    int c = a + b;
    return c;
}

利用javap -c查看字节码的指令

Code:
    0: sipush    315
    3: istore_1
    4: iconst_5
    5: istore_2
    6: iload_1
    7: iload_2
    8: iadd
    9: istore_3
   10: iload_3
   11: ireturn

TraceClassVistor生成的代码

methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "run", "()I", null, null);
methodVisitor.visitCode(); // 方法开始
methodVisitor.visitIntInsn(SIPUSH, 315); // 将315压入操作数栈
methodVisitor.visitVarInsn(ISTORE, 1); // 出栈并存入局部变量表1的位置
methodVisitor.visitInsn(ICONST_5); // 将常量5压入操作数栈
methodVisitor.visitVarInsn(ISTORE, 2); // 出栈并存入局部变量表2的位置
methodVisitor.visitVarInsn(ILOAD, 1); // 将局部变量表1位置的数入栈
methodVisitor.visitVarInsn(ILOAD, 2); // 将局部变量表2位置的数入栈
methodVisitor.visitInsn(IADD); // 执行相加操作
methodVisitor.visitVarInsn(ISTORE, 3); // 出栈并存入局部变量表3的位置
methodVisitor.visitVarInsn(ILOAD, 3); // 将局部变量表3位置的数入栈
methodVisitor.visitInsn(IRETURN);  // 数据返回
methodVisitor.visitMaxs(2, 4); // 由于我们设置了COMPUTE_FRAMES,该处设置会被忽略
methodVisitor.visitEnd(); // 方法结束

最后需要注意的是当classwriter为COMPUTE_FRAMES时,有些类可能在查找依赖时无法通过Class.forName找到相应的类而报错,所以需要对ClassWriter类的getClassLoader进行重写

class AsmClassWriter(private val dependencyList: List<File>): ClassWriter(COMPUTE_FRAMES) {
    // 将所有依赖的jar包传递进来
    override fun getClassLoader(): ClassLoader {
        return URLClassLoader(dependencyList.map {
            it.toURI().toURL()
        }.toTypedArray())
    }
}