Android字节码插桩
# 背景
源自一个内部需求: 在底层没有源码的库中插入一段统计逻辑,这种情况下无法直接在编码环节进行干预,只能在编译阶段插入,Android字节码插桩技术刚好能够帮助到我们
# 整体流程
# 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的输入
# 关键方法
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的具体值,它们在具体位置如下
# 实际操作
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())
}
}