手把手教你通过 AGP + ASM 实现 Android 应用插桩

2,387 阅读14分钟

手把手教你通过 AGP + ASM 实现 Android 应用插桩

首先要了解一下 AGP 和 ASM,AGP 的全称是 Android Gradle Plugin,这是 Google 为 apk 和 aar 打包在 gradle 平台上开发的一款插件,简单来说你通过 Android Studio 打出的 apk 和 aar 包都是由它完成的,AGP 还为其他的插件提供了 transform 接口来实现 JVM 字节码或者其他资源的处理;ASM 是处理 JVM 字节码知名的库,它可以很简单的读取和修改 JVM 字节码。

在开始之前我们有必要了解一下我们的插桩是工作在打包的哪一步骤中,只从 Java 相关的代码来看,打包过程中 AIDL 和 Java/Kotlin 的注解处理器都会生成 Java/Kotlin 源码,他们会和我们应用中的源码一样交给 Java/Kotlin 编译器编译成 JVM 字节码,AGP 的 transform 接口会把这些 class 字节码和 aar/jar 依赖库 (包含所有的库中的字节码和其中的资源文件)交由我们添加的 transform 对象来处理,添加的 transfrom 对象就像一个个拦截器节点,input 就表示拦截器的输入,output 就表示拦截器的输出,外部输入的 class/jar 文件都放在 input 中,修改完成后我们需要重新写入到 output 中,前一个拦截节点的 output 就是下一个节点的 input,当所有的 transform 拦截器节点都处理完后,就需要把最终处理过的 JVM 字节码交给 D8/R8 编译器,D8/R8 会对这些 JVM 字节码做脱糖、混淆 、字节码优化和重新编译 dex 字节码等等一系列操作就得到了 Dex 字节码文件供 Android 虚拟机使用。

通过我们对上面打包过程的了解,我们能够知道 transform 接口能够处理注解处理器、AIDL 生成的代码,也能够处理我们应用所依赖 jar / aar 的库中的字节码。transform 的工作在 Kotlin/Java 与 D8/R8 编译器之间,所以我们拿到的字节码是没有混淆和脱糖等操作的。

创建一个 Gradle 插件项目

Gradle 配置

首先添加 AGP 和 ASM 依赖:

dependencies {
    // ...
    implementation 'com.android.tools.build:gradle:4.1.1'

    api 'org.ow2.asm:asm:9.1'
    api 'org.ow2.asm:asm-commons:9.1'

}

添加 java-gradle-plugin 的插件:


plugins {
    // ...
    id 'java-gradle-plugin'
    // ...
}

指定我们的插件的 id 和实现类:

gradlePlugin {
    plugins {
        // 这个 `apmCore` 是随便填的
        apmCore {
            id =  // 填写自己项目的插件 id
            implementationClass = // 填写自己项目的插件实现类
        }
    }
}

Plugin 实现类

class ApmCorePlugin : Plugin<Project> {

    override fun apply(project: Project) {
        // ...
        if (project.plugins.hasPlugin("com.android.application")) {
            val appExtension = project.extensions.getByName("android") as AppExtension
            appExtension.registerTransform(
                ApmCoreTransform(
                    project = project,
                    apmCorePlugin = this
                )
            )
        }
        //...
    }
}

这个 Plugin 实现的类要和 gradle 中的配置对应,apply() 函数为入口函数,通过 AGP 的插件我们能够拿到 AppExtension 对象,然后调用其 registerTransform 方法就可以完成自定义 transfrom 的添加。

Transform 接口

基础方法介绍

class ApmCoreTransform(val project: Project, val apmCorePlugin: ApmCorePlugin) : Transform() {

    override fun getName(): String = "ApmTransform"

    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> = TransformManager.CONTENT_CLASS

    override fun getScopes(): MutableSet<in QualifiedContent.Scope> = TransformManager.SCOPE_FULL_PROJECT

    override fun isIncremental(): Boolean = true

    override fun transform(transformInvocation: TransformInvocation?) {
        // ...
    }
}

getName(): 当前 transform 的名字,能够在打包过程中看到它对应生成的 gradle task。
getInputTypes(): 指定要处理的输入类型,上面的设置就表示只处理字节码,不处理资源。
getScope(): 表示处理输入的范围,上面的设置表示整个项目的所有 module 和所有的依赖。 isIncremetal(): 表示是否支持增量更新,最好选择是。如果是否的话,每次打包都相当于清除缓存后重新打包;如果是是的话,只会处理修改后的文件。
transform(): 方法为处理插桩的入口函数。

处理 transform 的 input 和 output

处理插桩的入口函数是 transform,所有需要的参数也都是从 TransformInvocation 中获取的。

val outputProvider = transformInvocation.outputProvider
val isIncremental = transformInvocation.isIncremental
if (!isIncremental) {
    outputProvider.deleteAll()
}
// 需要 Hook 的Jar文件和Classes文件, 供后续 Hook 使用。
val jarsInputMapOutput = ConcurrentHashMap<File, File>()
val classInputMapOutput = ConcurrentHashMap<File, File>()
// 全部的Jar文件和Classes 文件, 供后续 Pre Scan 使用
val jarsAllInputFiles = LinkedBlockingDeque<File>()
val classesAllInputFiles = LinkedBlockingDeque<File>()

开始做了一些准备工作,outputProvider 和后续的 output 文件/文件夹创建相关,jarsInputMapOutput 与 classInputMapOutput 分别表示要处理的 input jar/class 文件与 output 文件的映射。jarsAllInputFiles 与 classesAllInputFiles 表示所有输入的 input 的 jar 和 class 文件。

查找映射需要处理的 input 与 output 的 jar 与 class 文件:

// 1. 映射和创建Output文件
for (inputs in transformInvocation.inputs) {
    // 处理jar文件
    for (jarInput in inputs.jarInputs) {
        // ...
    }

    // 处理class文件
    for (dirInput in inputs.directoryInputs) {
        // ...
    }
}

由于处理 jar 和 class 的逻辑比较长,我把他们分为两段来看。
处理 jar:

for (jarInput in inputs.jarInputs) {
    val outputFile = outputProvider.getContentLocation(
        jarInput.file.absolutePath,
        jarInput.contentTypes,
        jarInput.scopes,
        Format.JAR
    )
    if (isIncremental) {
        when (jarInput.status) {
            Status.NOTCHANGED -> {
                jarsAllInputFiles.add(jarInput.file)
            }
            Status.REMOVED -> {
                if (outputFile.exists()) {
                    outputFile.delete()
                }
            }
            Status.ADDED, Status.CHANGED, null -> {
                if (!outputFile.exists()) {
                    outputFile.parentFile.mkdirs()
                    outputFile.createNewFile()
                }
                jarsInputMapOutput[jarInput.file] = outputFile
                jarsAllInputFiles.add(jarInput.file)
            }
        }
    } else {
        if (!outputFile.exists()) {
            outputFile.parentFile.mkdirs()
            outputFile.createNewFile()
        }
        jarsInputMapOutput[jarInput.file] = outputFile
        jarsAllInputFiles.add(jarInput.file)
    }
}

首先通过 outputProvider#getContentnLocation() 方法获取到 output 的 jar 的文件路径。
首先会判断当前的构建是不是增量编译,如果不是那就处理简单,就认为所有的 input 都要处理,映射所有的 input 到 output。
如果是增量编译,那么就要单独判断这个 input 的 jar 文件的状态。其中包括:Status.NOTCHANGED (没有改变)、 Status.REMOVED (被删除)、Status.ADDED (添加) 和 Status.CHANGED(改变) 等状态。只有当状态是 Status.ADDED 和 Status.CHANGED 时,我们才会处理这个这个文件,创建 input 和 output 的文件映射。
处理 class:

for (dirInput in inputs.directoryInputs) {
    val dirInputFile = dirInput.file
    val dirOutputFile = outputProvider.getContentLocation(
        dirInputFile.absolutePath,
        dirInput.contentTypes,
        dirInput.scopes,
        Format.DIRECTORY
    )
    if (!dirOutputFile.exists()) {
        dirOutputFile.mkdirs()
    }
    if (isIncremental) {
        val changedFiles = dirInput.changedFiles
        for ((f, status) in changedFiles) {
            val name = f.absolutePath.replaceFirst(dirInputFile.absolutePath, "")
            val outputFile = File("${dirOutputFile.absolutePath}$name")
            when (status) {
                Status.NOTCHANGED -> {
                    classesAllInputFiles.put(f)
                }
                Status.REMOVED -> {
                    if (outputFile.exists()) {
                        outputFile.delete()
                    }
                }
                Status.CHANGED, Status.ADDED, null -> {
                    if (!outputFile.exists()) {
                        outputFile.parentFile.mkdirs()
                        outputFile.createNewFile()
                    }
                    classInputMapOutput[f] = outputFile
                    classesAllInputFiles.put(f)
                }
            }
        }
    } else {
        dirInputFile.scanFiles { f ->
            val name = f.absolutePath.replaceFirst(dirInputFile.absolutePath, "")
            val outputFile = File("${dirOutputFile.absolutePath}$name")

            if (!outputFile.exists()) {
                outputFile.parentFile.mkdirs()
                outputFile.createNewFile()
            }
            classInputMapOutput[f] = outputFile
            classesAllInputFiles.put(f)
        }
    }
}

这里是直接输入的一个文件夹,这个文件夹下面的文件就是对应的 class 文件,同样的我们借助 outputProvider#getContentLocation() 方法获取最终 output 的文件夹的路径。
如果不是增量编译,遍历输入的文件夹,通过输出的路径和文件名创建新的文件,然后添加到 class 的 input 与 output 映射。
如果是增量编译和 jar 的处理方式一样,只是映射新添加和已经修改的 input 的 class 文件到 output。

如果你的插桩需要知道类的继承关系,那么你需要在插桩前把所有的输入都扫描一遍,这个过程中需要借助 ASM, 我们后面再讲,当然也不仅仅是继承关系,别的你需要的信息也是可以的。

// 2. 执行PreScan
val scanParentsStartTime = System.currentTimeMillis()
ApmCorePlugin.removeClassPreScanInterceptor(PreScanClassInfoInterceptor::class.java)
findClasses.clear()
ApmCorePlugin.addClassPreScanInterceptor(PreScanClassInfoInterceptor())
preScanJars(transformInvocation, jarsAllInputFiles.toList())
preScanClasses(transformInvocation, classesAllInputFiles.toList())
val scanParentEndTime = System.currentTimeMillis()
Log.d(TAG, "PreScan 耗时: ${scanParentEndTime - scanParentsStartTime} ms, 总共 ${findClasses.size} Classes")

这里的 PreScan 只是读 input 中的数据,而不会有输出。

最后根据上面步骤构建的 input 和 output 映射来执行插桩处理了。

// 3. Hook
val hookStartTime = System.currentTimeMillis()
hookJars(transformInvocation, jarsInputMapOutput)
hookClasses(transformInvocation, classInputMapOutput)
val hookEndTime = System.currentTimeMillis()
Log.d(TAG, "Hook 耗时: ${hookEndTime - hookStartTime} ms")

其中 hookJars() 和 hookClasses() 方法就是我用来处理插桩的方法。

hookJars():


internal fun ApmCoreTransform.hookJars(transformInvocation: TransformInvocation, inputOutputFiles: Map<File, File>) {
    val tasks = mutableListOf<Future<*>>()
    for ((inputFile, outputFile) in inputOutputFiles) {
        if (inputFile.name.endsWith(".jar") && outputFile.name.endsWith(".jar")) {
            tasks.add(apmExecutor.submit {
                val inputZipFile = ZipFile(inputFile)
                val inputEntries = inputZipFile.entries()
                ZipOutputStream(FileOutputStream(outputFile)).use { outputZipStream ->
                    while (inputEntries.hasMoreElements()) {
                        val entry = inputEntries.nextElement()
                        if (entry.name.endsWith(".class")) {
                            inputZipFile.getInputStream(entry).use { inputStream ->
                                val classReader = ClassReader(inputStream)
                                val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS)
                                classReader.accept(
                                    ApmCoreClassVisitor(
                                        writer = classWriter,
                                        transform = this,
                                        transformInvocation = transformInvocation,
                                        // 复制每个拦截器,解决多线程问题.
                                        interceptors = ApmCorePlugin.getAllClassesHookInterceptors().map { it.clone() }
                                    ),
                                    ClassReader.EXPAND_FRAMES
                                )
                                outputZipStream.putNextEntry(ZipEntry(entry.name))
                                outputZipStream.write(classWriter.toByteArray())
                                outputZipStream.flush()
                                outputZipStream.closeEntry()
                            }
                        } else {
                            inputZipFile.getInputStream(entry).use { inputStream ->
                                outputZipStream.putNextEntry(ZipEntry(entry.name))
                                inputStream.copyTo(outputZipStream)
                                outputZipStream.flush()
                                outputZipStream.closeEntry()
                            }
                        }
                    }
                    outputZipStream.flush()
                    outputZipStream.finish()
                }
            })
        } else {
            tasks.add(apmExecutor.submit {
                FileUtils.copyFile(inputFile, outputFile)
            })
        }
    }

    for (t in tasks) {
        t.get()
    }
}

jar 文件是 zip 的压缩文件,我们可以直接用 jdk 中的处理 zip 的接口,不熟悉的同学可以去别的地方找找资料。 这里还开了一个线程池来处理,处理速度可以更快。 这里会读取 jar 中的 class 文件用 ASM 接口处理完后,把处理完的数据写入到 output 的 jar 文件中,如果不是 class 文件就直接复制到 output 中。
ApmCoreClassVisitor 就是我们通过 ASM 接口处理输入的 class 字节码的地方,最终的处理后的结果会写入到 ClassWriter 中,加入你不需要输出,这个 ClassWriter 也可以传递为空,比如上面说到的 PreScan 时只需要获取类的继承关系。

hookClasses:

internal fun ApmCoreTransform.hookClasses(transformInvocation: TransformInvocation, inputOutputFiles: Map<File, File>) {
    val tasks = mutableListOf<Future<*>>()
    for ((inputFile, outputFile) in inputOutputFiles) {
        if (inputFile.name.endsWith(".class") && outputFile.name.endsWith(".class")) {
            tasks.add(apmExecutor.submit {
                FileInputStream(inputFile).use { inputStream ->
                    val classReader = ClassReader(inputStream)
                    val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS)
                    classReader.accept(
                        ApmCoreClassVisitor(
                            writer = classWriter,
                            transform = this,
                            transformInvocation = transformInvocation,
                            // 复制每个拦截器,解决多线程问题.
                            interceptors = ApmCorePlugin.getAllClassesHookInterceptors().map { it.clone() }
                        ),
                        ClassReader.EXPAND_FRAMES
                    )
                    FileOutputStream(outputFile).buffered().use { outputStream ->
                        outputStream.write(classWriter.toByteArray())
                        outputStream.flush()
                    }
                }
            })
        } else {
            tasks.add(apmExecutor.submit {
                FileUtils.copyFile(inputFile, outputFile)
            })
        }
    }
    for (t in tasks) {
        t.get()
    }
}

class 文件的处理方式就更加简单了,没有 zip 文件的处理。

到这里就完成了一次插桩流程,不过我们还没有看 ASM 的接口,是如何修改字节码的,我们还需要继续。

ASM 修改字节码

通过 IDEA 中 ASM Viewer 插件可以将字节码转换成 ASM 的代码供你做插桩功能时的参考,强烈推荐大家学习一下 JVM 字节码,这会让你写 ASM 插桩时会得心应手,我之前有写过相关文章:JVM 字节码

ClassVisitor

class ApmCoreClassVisitor(
    writer: ClassVisitor?,
    private val transform: ApmCoreTransform,
) : ClassVisitor(Opcodes.ASM8, writer) {

    private val needHandleInterceptors: LinkedBlockingDeque<ClassHookInterceptor> by lazy {
        LinkedBlockingDeque()
    }

    private var classInfoData: ClassInfoData? = null

    override fun visit(
        version: Int,
        access: Int,
        name: String?,
        signature: String?,
        superName: String?,
        interfaces: Array<out String>?
    ) {
        super.visit(version, access, name, signature, superName, interfaces)
    }

    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor? {
        val methodInfoData = MethodInfoData(
            methodAccess = access,
            methodName = name,
            methodDescriptor = descriptor,
            methodSignature = signature,
            methodExceptions = exceptions?.toList(),
            classInfoData = classInfoData!!
        )
        val methodInterceptor = needHandleInterceptors
            .fold(emptyList<MethodHookInterceptor>()) { mi, ci -> mi + ci.methodInterceptors() }
            .filter { it.needIntercept(methodInfoData, transform) }
        val fixedInfo =
            methodInterceptor.fold(methodInfoData) { mi, i -> i.methodInfoIntercept(mi) }

        val mv = super.visitMethod(
            fixedInfo.methodAccess,
            fixedInfo.methodName,
            fixedInfo.methodDescriptor,
            fixedInfo.methodSignature,
            fixedInfo.methodExceptions?.toTypedArray()
        )
        return ApmCoreMethodVisitor(
            interceptors = methodInterceptor,
            methodInfoData = fixedInfo,
            methodVisitor = mv
        )
    }

    override fun visitEnd() {
        super.visitEnd()
    }

    override fun visitField(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        value: Any?
    ): FieldVisitor {
        return super.visitField(access, name, descriptor, signature, value)
    }
    
    override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor? {
        return super.visitAnnotation(descriptor, visible)
    }
}

visit() 方法中会传递类的基本信息,包括类名,可见性,父类,实现的接口。我们也可以修改这些信息,比如继承的类和实现的接口,还可以收集基本的类的信息,比如我们前面说到的 PreScan 流程中需要找到所有的类的继承关系,就时通过 visit() 方法。

visitMethod() 方法是对方法的处理,这个方法是我们大部分时间插桩都要用到的,后面也会重点分析自定义 MethodVistor, 这个方法中也会有方法的可见性、方法名、方法签名和抛出的异常等,也可以通过 super.visitMethod() 方法来修改,这个方法还会返回一个 MethodVistor 对象,如果你不需要再修改这个方法的字节码,就直接返回这个 MethodVistor 对象就好了,如果你需要对这个方法的字节码作出修改就需要添加一个自定义的 MethodVistor,像我上面的 demo 中就添加了一个自定义的 ApmCoreMethodVistor 对象。

visitEnd() 表示当前 class 对象访问完了,在这个方法中也可以做一些逻辑的,例如在 visiMethod() 的回调中没有某个方法,我就可以在 visitEnd() 方法前手动添加这个方法。

visitField() 和 visitAnnotation() 分别表示访问成员变量和注解,他们和 visitMethod() 方法也是类似的,后续就不多讲他们了。

MethodVisitor

class ApmCoreMethodVisitor(
    private val methodInfoData: MethodInfoData,
    methodVisitor: MethodVisitor?
) : AdviceAdapter(
    Opcodes.ASM8,
    methodVisitor,
    methodInfoData.methodAccess,
    methodInfoData.methodName,
    methodInfoData.methodDescriptor
) {

    override fun onMethodEnter() {
        super.onMethodEnter()
       
    }

    override fun onMethodExit(opcode: Int) {
        super.onMethodExit(opcode)
    }

    override fun visitTypeInsn(opcode: Int, type: String?) {
        super.visitTypeInsn(opcode, type)
    }

    override fun visitFieldInsn(opcode: Int, owner: String?, name: String?, descriptor: String?) {
        super.visitFieldInsn(opcode, owner, name, descriptor)
    }

    override fun visitMethodInsn(
        opcodeAndSource: Int,
        owner: String?,
        name: String?,
        descriptor: String?,
        isInterface: Boolean
    ) {
        super.visitMethodInsn(opcodeAndSource, owner, name, descriptor, isInterface)
    }

    override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor? {
        return super.visitAnnotation(descriptor, visible)
    }
}

onMethodEnter() 和 onMethodExit() 表示一个方法的开始和结束,我们也可以根据需求做一些操作,加入我需要统计所有的方法耗时,我们就可以通过在 onMethodEnter() 中添加一个指令去调用我们准备好的方法通知方法进入了,通过 onMethodExit() 方法在调用我们准备好的另一个方法通知方法结束了。

visityTypeInsn() 表示执行 NEW, ANEWARRAY, CHECKCAST, INSTANCEOF 指令,熟悉字节码指令后这些应该非常熟悉。

visitFieldInsn() 表示执行 GETSTATIC, PUTSTATIC, GETFIELD, PUTFIELD 指令。

visitMethodInsn() 表示执行 INVOKEVIRTUAL, INVOKESPECIAL, INVOKESTATIC, INVOKEINTERFACE 指令,都是和方法调用有关,也是插桩常用的方法。

还有非常多的指令对应的方法,我这里就不再单独列出来了,点到为止,如果自己用到某个指令的方法,我没有列出来,自己再去 ASM 的文档中找就好了,都是类似的。

ASM 插桩实战

插桩有一个非常重要的原则,就是你所修改后的代码和修改前的代码在执行后他们的操作数栈和本地变量表是不会改变的,如果发生了大量的改变,处理起来将会非常麻烦,目前我还没有处理过这样的插桩。

解决 context.getColor() 在低版本上导致的崩溃

在低版本上调用 context 中的以下版本会导致崩溃:

    @ColorInt
    public final int getColor(@ColorRes int id) {
        return getResources().getColor(id, getTheme());
    }

通常需要使用 ContextCompat 中的以下方法替换:

    @SuppressWarnings("deprecation")
    @ColorInt
    public static int getColor(@NonNull Context context, @ColorRes int id) {
        if (Build.VERSION.SDK_INT >= 23) {
            return Api23Impl.getColor(context, id);
        } else {
            return context.getResources().getColor(id);
        }
    }

这两个方法所需要的操作数栈都是一样的,而且返回值也是一样的,那 hook 起来简直不要太简单。

    companion object {
        const val ANDROID_CONTEXT_COLOR_METHOD_NAME = "getColor"
        const val ANDROID_CONTEXT_COLOR_METHOD_DES = "(I)I"

        const val ANDROID_CONTEXT_COMPACT_CLASS_NAME = "androidx/core/content/ContextCompat"
        const val ANDROID_CONTEXT_COMPACT_COLOR_METHOD_NAME = "getColor"
        const val ANDROID_CONTEXT_COMPACT_COLOR_METHOD_DES = "(Landroid/content/Context;I)I"
    }

    override fun visitMethodInsn(
        opcodeAndSource: Int,
        owner: String?,
        name: String?,
        descriptor: String?,
        isInterface: Boolean
    ) {
        if (owner!!.isAndroidContext(owner)
            && name == ANDROID_CONTEXT_COLOR_METHOD_NAME
            && descriptor == ANDROID_CONTEXT_COLOR_METHOD_DES) {
            super.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                ANDROID_CONTEXT_COMPACT_CLASS_NAME,
                ANDROID_CONTEXT_COMPACT_COLOR_METHOD_NAME,
                ANDROID_CONTEXT_COMPACT_COLOR_METHOD_DES, 
                isInterface)
        } else {
            super.visitMethodInsn(
                opcodeAndSource,
                owner,
                name,
                descriptor,
                isInterface
            )
        }
    }

首先判断 owner 必须是 Android 中的 Context 方法,方法名必须是 getColor,方法的描述必须是 (I)I,如果满足上面的条件就可以把它替换成 ContextCompat#getColor()。

我们也可以用同样的方法替换 Android 中的 Log 打日志相关的方法,把它替换成你需要的日志方法,同样的必须让其和 Android 中的 Log 消耗的操作数栈,返回值也要一样。

Dialog,PopupWindow 的 badToken 问题也可以通过这种方式解决,同时也可以顺便监听 Dialog / PopupWindow 的显示与消失,拿 Dialog 举例,Dialog#show() 表示显示,Dialog#dismiss() 表示消失,我可以静态方法 DialogCompat#show(d: Dialog) 来代理显示,用 DialogCompat#dismiss(d: Dialog) 来代理消失,我在显示的时候就可以判断对应的 Activity 是否存活,如果存活我就显示,不存活就不显示,而且还可以监听他们的生命周期。

我还用这个方法 hook 过 U3d 库中的敏感权限导致上架失败的问题,这个方法很简单,但是很实用,能够解决很多的问题。

自动初始化库

假如我想要我们的库在在 Application#attachBaseContext() 的时候完成初始化,这个时候就有两种情况,一种是 Application 重写了 attachBaseContext() 方法,一种没有重写。

无论哪种情况都必须保证当前的 class 是 Application 的子类:

    var isApplication: Boolean = false
    override fun visit(
        version: Int,
        access: Int,
        name: String?,
        signature: String?,
        superName: String?,
        interfaces: Array<out String>?
    ) {
        isApplication = transform.hasTargetParentClass(name ?: "",
            "android/app/Application"
        )
        super.visit(version, access, name, signature, superName, interfaces)
    }

这也是为什么我要在 hook 前要先扫描一次类的继承关系。

我们先看简单的,重写了 attachBaseContext(), 需要在 MethodVisitor#onMethodEnter() 添加以下代码:

       override fun onMethodEnter() {
        super.onMethodEnter()
        if (isApplication) {
            if (name == "attachBaseContext" && descriptor == "(Landroid/content/Context;)V") {
                mv?.visitMethodInsn(
                    Opcodes.INVOKESTATIC,
                    "com/gmlive/common/apm/apmcore/baseplugins/startup/StartupHook",
                    "onApplicationStarted",
                    "()V",
                    false
                )
            }
        }
    }

当找到 attachbaseContext() 方法后就在方法的最开头调用了我们自己定义的 onApplicationStarted() 方法,这个静态方法没有参数也没有返回值,不会对原有方法的变量表和操作数栈照成影响。

假如没有找到 attachbaseContext() 方法,那就需要我们在 ClassVisitor 的 visitEnd() 回调中手动添加这个方法:

var donotFindAttachBase: Boolean = true
    override fun visitEnd() {
        if (isApplication && donotFindAttachBase) {

            val mv = cv?.visitMethod(
                Opcodes.ACC_PROTECTED,
                "attachBaseContext",
                "(Landroid/content/Context;)V",
                null,
                null
            )
            mv?.visitCode()
            mv?.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                "com/gmlive/common/apm/apmcore/baseplugins/startup/StartupHook",
                "onApplicationStarted",
                "()V",
                false
            )
            mv?.visitVarInsn(Opcodes.ALOAD, 0)
            mv?.visitVarInsn(Opcodes.ALOAD, 1)
            mv?.visitMethodInsn(
                Opcodes.INVOKESPECIAL,
                "android/app/Application",
                "attachBaseContext",
                "(Landroid/content/Context;)V",
                false
            )
            mv?.visitInsn(Opcodes.RETURN)
            mv?.visitMaxs(2, 2)
            mv?.visitEnd()
        }
        super.visitEnd()
    }

我这里还调用了我自己的 hook 方法后,还调用了 super.attachBaseContext() 方法。

计算方法的耗时

计算耗时的插桩不想贴代码了,有点累了。。。。。。
在 MethodVisitor#onMethodEnter() 方法中插入 hook 的 methodStart() 方法,在 MethodVistor#onMethodExit() 方法中插入 hook 的 methodEnd() 方法,那我们怎么判断调用的方法是哪个呢?在 Java 中非常简单,直接拿方法栈就好了,上一个方法就是调用的方法。
同一个线程中方法的调用就像是 xml 一样,方法的调用就是呈现一个树状。每一个线程都是一棵树,然后我们能够统计每一个方法节点的耗时。

总结

我当时第一次写出插桩的时候非常激动,感觉发现了新大陆,但是他也不能滥用,他出现了问题比较难找原因,推荐在 hook 类的时候加上本地日志,自己也好查,然后通过 jadx 反编译来看看插桩后的代码是否正确,然后大量测试慢慢验证。