Gradle Transform + ASM 探索

前言

使用 Gradle Transform + ASM 实现代码插桩的使用已经很是广泛。本文试图探索如何更加快速简洁的利用 Transform 实现代码插桩,并尝试实现java

  • 经过注解对任意类当中全部的方法实现计算方法耗时的插桩
  • 经过配置实现对任意类(主要是针对第三方库)当中指定方法的实现计算方法耗时的插桩
  • 对工程中全部的点击事件进行插桩,方便埋点或肯定代码位置
  • ......

Transform + ASM 能作什么

简单来讲就是利用 AGP 提供的 Transform 接口,在应用打包的流程中,对 java/kotlin 编译生成的 class 文件进行二次写操做,插入一些自定义的逻辑。这些逻辑通常是重复且有规律的,而且大几率和业务逻辑无关的。android

一些统计应用数据的 SDK,会在页面展示和退出的生命周期函数里,在应用编译期插入统计相关的逻辑,统计页面展示数据;这种对开发者很是透明的实现,一方面接入成本很是低,另外一方面也减小了三方库对现有工程的显示侵入,尽量的减小了耦合。git

也有常见的代码耗时统计的实现,在方法体开始的时候,利用 System.currentTimeMillis() 方法记录开始时间,在方法返回以前,进行统计。固然,这样的功能早在 2013 年已经由JakeWharton 大神用 aspectj的方案 实现过了github

Transform 基本流程

关于如何建立一个基于的 Gradle 插件项目,以及如何在 Plugin 中注册的具体实现就不展开了,网上能够找到好多这种教程,这里从 Transform 的实现提及。shell

能够看到实现一个自定义的 Transform 须要作的事情仍是很是有规律的。继承 Transform 这个抽象类,覆写这几个方法通常来讲就够用了。每一个方法具体的功能从方法名就能够了解了。缓存

  • getName 这个transform 的名称,一个应用内能够由多个 Transform,所以须要一个名称标记,方便后面调试。
  • getInputTypes 输入类型,ContentType 是一个枚举,这个输入类型是什么意思呢?其实看一下这个枚举的定义你就明白了。
ContentType 点击展开
enum DefaultContentType implements ContentType {
        /** * The content is compiled Java code. This can be in a Jar file or in a folder. If * in a folder, it is expected to in sub-folders matching package names. */
        CLASSES(0x01),

        /** The content is standard Java resources. */
        RESOURCES(0x02);

        private final int value;

        DefaultContentType(int value) {
            this.value = value;
        }

        @Override
        public int getValue() {
            return value;
        }
    }
复制代码

这里能够注意一下,使用 Transform 咱们还能够对 resources 文件作处理,你应该据说过或者用过 AndResGuard 来混淆资源文件吧,看到这里你是否是以为本身也有点思路了呢。性能优化

  • isIncremental 是否支持增量编译。对于一个稍微庞大点儿的项目,Gradle 现有的构建流程其实已经很耗时了,对于耗时这件事归根结底惟一的解决方法就是并行和缓存,可是 Gradle 的不少任务是有依赖关系的,因此并行在很大程度上受到了限制。所以,缓存就成为了惟一能够去突破的方向。一个自定义的 Transform 在可能的状况,支持增量编译,能够节省报一些编译时间和资源,固然,因为 Transform 要实现功能的限制,必须每一次全量编译,那么必定要记得删除上一次编译编译的产物,以避免产生 bug。关于如何实现这些细节,后面会有介绍。服务器

  • getScopes 定义这个 Transform 要处理那些输入文件。ScopeType 一样是一个枚举,看一下他的定义。闭包

ScopeType 点击展开
enum Scope implements ScopeType {
        /** Only the project (module) content */
        PROJECT(0x01),
        /** Only the sub-projects (other modules) */
        SUB_PROJECTS(0x04),
        /** Only the external libraries */
        EXTERNAL_LIBRARIES(0x10),
        /** Code that is being tested by the current variant, including dependencies */
        TESTED_CODE(0x20),
        /** Local or remote dependencies that are provided-only */
        PROVIDED_ONLY(0x40),

        /** * Only the project's local dependencies (local jars) * * @deprecated local dependencies are now processed as {@link #EXTERNAL_LIBRARIES} */
        @Deprecated
        PROJECT_LOCAL_DEPS(0x02),
        /** * Only the sub-projects's local dependencies (local jars). * * @deprecated local dependencies are now processed as {@link #EXTERNAL_LIBRARIES} */
        @Deprecated
        SUB_PROJECTS_LOCAL_DEPS(0x08);

        private final int value;

        Scope(int value) {
            this.value = value;
        }

        @Override
        public int getValue() {
            return value;
        }
    }
复制代码

能够预知,这个范围定义的越小,咱们的 Transform 须要处理的输入就越少,执行也就越快。app

  • transform(transformInvocation: TransformInvocation?) 进行输入内容的处理。

这里须要再次强调一点:一个工程内会有多个 Transform,你定义的 Transform 在处理的是上一个 Transform 通过处理的输出,而通过你处理的输出,会由下一个 Transform 进行处理。全部的 transform 任务通常都在 app/build/intermediates/transform/ 这个目录下能够看到。

transform() 深刻

transform()方法的参数 TransformInvocation 是一个接口,提供了一些关于输入的一些基本信息。利用这些信息咱们就能够得到编译流程中的 class 文件进行操做。

从上图能够看到,transform 处理输入的思路仍是很简单的,就是从TransformInvocation 获取到总的输入后,分别按照 class目录 和 jar文件 集合的方式进行遍历处理。(这里简单讨论广泛状况,固然 TransformInvocation 接口还提供了 getReferencedInputs,getSecondaryInputs 这些接口,让使用者处理一些特殊的输入,上图并无体现,暂时不展开讨论)

transform 的核心难点有如下几个点:

  • 正确、高效的进行文件目录、jar 文件的解压、class 文件 IO 流的处理,保证在这个过程当中不丢失文件和错误的写入
  • 高效的找到要插桩的结点,过滤掉无效的 class
  • 支持增量编译

实践

上面说了一些流程和概念,下面就经过一个实例 (参考自 Koala) 来具体看一下一个基于注解,在 transform 任务执行的过程当中经过 ASM 插入统计方法耗时、参数、输出的实现。很是感谢 Koala,感谢 lijiankun24 的开源。

效果

为了方便后期叙述,这里首先看一下使用方式和最终效果。

添加注解

咱们在 MainActivity 中的部分方法添加注解

点击展开详细
class MainActivity : AppCompatActivity() {

    @Cat
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        test()
        test2("a",100)
        test3()
        test4()
        val result = Util.dp2Px(10)
    }

    @Cat
    private fun test() {
        println("just test")
    }

    @Cat
    private fun test2(para1: String, para2: Int): Int {
        return 0
    }

    @Cat
    private fun test3(): View {
        return TextView(this)
    }

    private fun test4(){
        println("nothing")
    }
}
复制代码

MainActivity 中除了 test4()以外的全部方法,都打上了 @Cat 注解,而且全部方法都会被调用。

输出日志

点击展开详细
2020-01-04 11:32:13.784 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.784 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.784 E: │ method's name:      test
2020-01-04 11:32:13.785 E: │ method's arguments: []
2020-01-04 11:32:13.785 E: │ method's result:    null
2020-01-04 11:32:13.791 E: │ method cost time:   1ms
2020-01-04 11:32:13.791 E: └───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.791 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.791 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.792 E: │ method's name:      test2
2020-01-04 11:32:13.792 E: │ method's arguments: [a, 100]
2020-01-04 11:32:13.792 E: │ method's result:    0
2020-01-04 11:32:13.793 E: │ method cost time:   0ms
2020-01-04 11:32:13.793 E: └───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.794 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.795 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.795 E: │ method's name:      test3
2020-01-04 11:32:13.796 E: │ method's arguments: []
2020-01-04 11:32:13.796 E: │ method's result:    android.widget.TextView{8a9397d V.ED..... ......ID 0,0-0,0}
2020-01-04 11:32:13.796 E: │ method cost time:   1ms
2020-01-04 11:32:13.796 E: └───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.797 E: ┌───────────────────────────────────------───────────────────────────────────------
2020-01-04 11:32:13.797 E: │ class's name:       com/engineer/android/myapplication/MainActivity
2020-01-04 11:32:13.797 E: │ method's name:      onCreate
2020-01-04 11:32:13.798 E: │ method's arguments: [null]
2020-01-04 11:32:13.798 E: │ method's result:    null
2020-01-04 11:32:13.798 E: │ method cost time:   156ms
2020-01-04 11:32:13.798 E: └───────────────────────────────────------───────────────────────────────────------
复制代码

能够看到,日志输出了除 test4() 方法以外全部方法的方法耗时、方法参数、方法名称、方法返回值等信息,下面就来看看实现细节。

实现细节

主动调用

首先,对于一个上述的功能,若是用咱们直接手写代码的方式,应该是很简单的。

打印的日志有一些方法的信息,所以须要一个类来承载这些信息。

  • MethodInfo
data class MethodInfo(
    var className: String = "",
    var methodName: String = "",
    var result: Any? = "",
    var time: Long = 0,
    var params: ArrayList<Any?> = ArrayList()
)
复制代码

按照常规思路,咱们须要在方法开始的时候,记录一下开始时间,方法 return 以前再次记录一下时间,而后计算出耗时。

  • MethodManager
object MethodManager {

    private val methodWareHouse = ArrayList<MethodInfo>(1024)

    @JvmStatic
    fun start(): Int {
        methodWareHouse.add(MethodInfo())
        return methodWareHouse.size - 1
    }

    @JvmStatic
    fun end(result: Any?, className: String, methodName: String, startTime: Long, id: Int) {
        val method = methodWareHouse[id]
        method.className = className
        method.methodName = methodName
        method.result = result
        method.time = System.currentTimeMillis() - startTime
        BeautyLog.printMethodInfo(method)
    }

}
复制代码

这里定义了两个方法 start 和 end ,顾名思义就是在方法开始和结束的时候调用,并经过参数传递一些关键信息,最后打印这些信息。

这样咱们能够在任何一个方法中调用这些方法

fun foo(){
        val index =MethodManager.start()
        val start = System.currentTimeMillis()
        
        // some thing foo do
        
        MethodManager.end("",this.localClassName,"foo",start,index)
    }
复制代码

诚然这样的代码写起来很简单,可是一方面这些代码和 foo 方法原本要作的事情是没有关系的,若是为了单次测试方法耗时加上去,有点丑陋;再有就是若是有多个方法须要检测耗时,须要把这样的代码写不少次。所以,便有了经过 Transform + ASM 实现代码插桩的需求。

插桩实现

在一个方法内,咱们本身写上述代码很简单,代开 IDE 找到对应的类文件,定位到要计算耗时的方法,在方法体开始和结束以前插入代码。可是,对于编译器来讲,这些没有规律的事情是很是麻烦的。所以,为了方便,咱们经过定义注解的方式,方便编译器在代码编译阶段能够快速定位要插桩的位置。

这里定义了一个注解 Cat, 为啥取名 Cat,由于猫很萌啊。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface Cat {
}
复制代码

按照上图 transform(transformInvocation: TransformInvocation?) 处理输入流程的流程,咱们能够对全部的 class 文件进行处理。

这里以处理 directoryInputs 为例

input.directoryInputs.forEach { directoryInput ->
                if (directoryInput.file.isDirectory) {
                    FileUtils.getAllFiles(directoryInput.file).forEach {
                        val file = it
                        val name = file.name
                        println("directory")
                        println("name ==$name")
                        if (name.endsWith(".class") && name != ("R.class")
                            && !name.startsWith("R\$") && name != ("BuildConfig.class")
                        ) {

                            val reader = ClassReader(file.readBytes())
                            val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
                            val visitor = CatClassVisitor(writer)
                            reader.accept(visitor, ClassReader.EXPAND_FRAMES)

                            val code = writer.toByteArray()
                            val classPath = file.parentFile.absolutePath + File.separator + name
                            val fos = FileOutputStream(classPath)
                            fos.write(code)
                            fos.close()
                        }
                    }
                }

                val dest = transformInvocation.outputProvider?.getContentLocation(
                    directoryInput.name,
                    directoryInput.contentTypes,
                    directoryInput.scopes,
                    Format.DIRECTORY
                )


                FileUtils.copyDirectoryToDirectory(directoryInput.file, dest)
            }
复制代码

这里的操做很简单,就是遍历全部的 class 文件,对全部符合条件的 Class 经过 ASM 提供的接口进行处理,经过访问者模式,提供一个自定义的 ClassVisitor 便可。这里咱们的自定义 ClassVisitor 就是 CatClassVisitor,在 CatClassVisitor 内部的 visitMethod 实现中再次使用访问者的模式,返回一个自定义的 CatMethodVisitor,在其内部咱们会根据方法注解,肯定当前方法是否须要进行插桩。

override fun visitAnnotation(desc: String, visible: Boolean): AnnotationVisitor {
        // 当前方法的注解,是不是咱们定义的注解。
        if (Constants.method_annotation == desc) {
            isInjected = true
        }
        return super.visitAnnotation(desc, visible)
    }

  override fun onMethodEnter() {
        if (isInjected) {
            
            methodId = newLocal(Type.INT_TYPE)
            mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                Constants.method_manager,
                "start",
                "()I",
                false
            )
            mv.visitIntInsn(Opcodes.ISTORE, methodId)

            ... more details ...
        }
    }

  override fun onMethodExit(opcode: Int) {
        if (isInjected) {

            ... other details ...

            mv.visitMethodInsn(
                Opcodes.INVOKESTATIC,
                Constants.method_manager,
                "end",
                "(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;JI)V",
                false
            )
        }
    }
复制代码

能够看到这样咱们就肯定了要进行代码插桩的位置,关于 ASM 代码插桩的具体细节已在当 Java 字节码遇到 ASM 有过介绍,这里再也不展开。此处具体实现能够查看源码

固然,咱们还须要处理输入为 jarInputs 的场景。在组件化开发的时候,不少时候,咱们是经过依赖 aar 包的方式,依赖其余小伙伴提供的业务组件或基础组件。或者是当咱们依赖第三方库的时候,其实也是在依赖 aar。这时候,若是缺乏了对 jarInputs 的处理,会致使插桩功能的缺失。可是从上面的流程图能够看到,对 jarInputs 的处理只是多了解压缩的过程,后续仍是对 class 文件的遍历写操做。

增量编译

说到 Transform 必需要谈的一个点就是增量编译,其实关于增量编译的实现,经过查看 AGP 自带的几个 Transform 能够看到其实很简单。

if (transformInvocation.isIncremental) {
                    when (jarInput.status ?: Status.NOTCHANGED) {
                        Status.NOTCHANGED -> {
                        }
                        Status.ADDED, Status.CHANGED -> transformJar(
                            function,
                            inputJar,
                            outputJar
                        )
                        Status.REMOVED -> FileUtils.delete(outputJar)
                    }
                } else {
                    transformJar(function, inputJar, outputJar)
                }
复制代码

全部的输入都是带状态的,根据这些状态作不一样的处理就行了。固然,也能够根据前面提到的 getSecondaryInputs 提供的输入进行处理支持增量编译。

简化 Transform 流程

回顾上面提到的 transform 处理流程及三个关键点,参考官方提供的 CustomClassTransform 咱们能够抽象出一个更加通用的 Transform 基类。

默认支持 增量编译,处理文件 IO 的操做

abstract class BaseTransform : Transform() {

    // 将对 class 文件的 asm 操做,处理完以后的再次复制,抽象为一个 BiConsumer
    abstract fun provideFunction(): BiConsumer<InputStream, OutputStream>?

    // 默认的 class 过滤器,处理 .class 结尾的全部内容 (Maybe 能够扩展)
    open fun classFilter(className: String): Boolean {
        return className.endsWith(SdkConstants.DOT_CLASS)
    }

    // Transform 使能开关
    open fun isEnabled() = true

    ... else function ...

    // 默认支持增量编译
    override fun isIncremental(): Boolean {
        return true
    }
   

    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)

        val function = provideFunction()

        ......

        if (transformInvocation.isIncremental.not()) {
            outputProvider.deleteAll()
        }

        for (ti in transformInvocation.inputs) {
            for (jarInput in ti.jarInputs) {
                 ......
                if (transformInvocation.isIncremental) {
                    when (jarInput.status ?: Status.NOTCHANGED) {
                        Status.NOTCHANGED -> {
                        }
                        Status.ADDED, Status.CHANGED -> transformJar(
                            function,
                            inputJar,
                            outputJar
                        )
                        Status.REMOVED -> FileUtils.delete(outputJar)
                    }
                } else {
                    transformJar(function, inputJar, outputJar)
                }
            }
            for (di in ti.directoryInputs) {

                ......
                
                if (transformInvocation.isIncremental) {
                    for ((inputFile, value) in di.changedFiles) {

                        ......

                        transformFile(function, inputFile, out)

                        ......
                    }
                } else {
                    for (`in` in FileUtils.getAllFiles(inputDir)) {
                        if (classFilter(`in`.name)) {
                            val out =
                                toOutputFile(outputDir, inputDir, `in`)
                            transformFile(function, `in`, out)
                        }
                    }
                }
            }
        }
    }


    @Throws(IOException::class)
    open fun transformJar( function: BiConsumer<InputStream, OutputStream>?, inputJar: File, outputJar: File ) {
        Files.createParentDirs(outputJar)
        FileInputStream(inputJar).use { fis ->
            ZipInputStream(fis).use { zis ->
                FileOutputStream(outputJar).use { fos ->
                    ZipOutputStream(fos).use { zos ->
                        var entry = zis.nextEntry
                        while (entry != null && isValidZipEntryName(entry)) {
                            if (!entry.isDirectory && classFilter(entry.name)) {
                                zos.putNextEntry(ZipEntry(entry.name))
                                apply(function, zis, zos)
                            } else { // Do not copy resources
                            }
                            entry = zis.nextEntry
                        }
                    }
                }
            }
        }
    }

    @Throws(IOException::class)
    open fun transformFile( function: BiConsumer<InputStream, OutputStream>?, inputFile: File, outputFile: File ) {
        Files.createParentDirs(outputFile)
        FileInputStream(inputFile).use { fis ->
            FileOutputStream(outputFile).use { fos -> apply(function, fis, fos) }
        }
    }


    @Throws(IOException::class)
    open fun apply( function: BiConsumer<InputStream, OutputStream>?, `in`: InputStream, out: OutputStream ) {
        try {
            function?.accept(`in`, out)
        } catch (e: UncheckedIOException) {
            throw e.cause!!
        }
    }
}

复制代码

以上对 transform 处理流程中,文件 IO,增量编译的细节进行了封装处理。把对 class 的写操做和二次复制,统一为 InputStream 和 OutoutStream 对象的处理。

使用注解实现类中全部方法的插桩

前面咱们经过定义注解 Cat 的方式,详细实现了一次方法耗时的插桩。可是这个注解的使用范围被限定在了方法上,若是咱们想要对一个类里多个方法的耗时同时进行检测的时候,就比较繁琐了。所以,咱们能够就这个注解简单升级一下,实现一个支持 Class 内全部方法耗时检测的插桩实现。

注解定义 Tiger

Tiger 顾名思义,这里的实现就是在照猫画虎。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface Tiger {
}
复制代码

Transform 实现

class TigerTransform : BaseTransform() {

    override fun provideFunction(): BiConsumer<InputStream, OutputStream>? {
        return BiConsumer { t, u ->
            val reader = ClassReader(t)
            val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
            val visitor = TigerClassVisitor(writer)
            reader.accept(visitor, ClassReader.EXPAND_FRAMES)
            val code = writer.toByteArray()
            u.write(code)
        }
    }

    override fun getName(): String {
        return "tiger"
    }
}
复制代码

经过直接继承刚才定义的 Transform 抽象类,咱们能够把精力集中在如何处理 Class 文件的写入和输入上,也就是这里的 InputStream 和 OutputStream 的处理,直接和 ASM 的 ClassReader 以及 ClassWriter 接口交互。没必要再关心增量编译,TransformInvocation 的输出和输入的 IO 这些内部细节了。

咱们看一下 TigerClassVisitor

class TigerClassVisitor(classVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM6, classVisitor) {

    private var needHook = false
    private lateinit var mClassName: String

    override fun visit( version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array<String>? ) {
        super.visit(version, access, name, signature, superName, interfaces)
        println("hand class $name")
        mClassName = name
    }

    override fun visitAnnotation(desc: String?, visible: Boolean): AnnotationVisitor {
        if (desc.equals(Constants.class_annotation)) {
            println("find $desc ,start hook ")
            needHook = true
        }
        return super.visitAnnotation(desc, visible)
    }

    override fun visitMethod( access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>? ): MethodVisitor {


        var methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)

        if (needHook) {
            .... hook visitor ...
        }

        return methodVisitor
    }
}
复制代码

这里的关键就是 visitAnnotation 方法,在这个回调方法里,咱们能够获取到当前 Class 的注解,而当这个 Class 的注解和咱们定义的 Tiger 注解相等时,咱们就能够对这个类当中的全部方法进行耗时检测代码的插桩了,在 visitMethod 方法内耗时代码的插桩,上面已经实现过了。

咱们能够到应用的 build 目录下查看插桩代码是否生效,好比 app/build/intermediates/transforms/tiger/{flavor}/{packageName}/xxx/ 目录下找到编译产物。

点击展开
@Tiger
public class Util {
    private static final float DENSITY;

    public Util() {
        int var1 = MethodManager.start();
        long var2 = System.nanoTime();
        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "<init>", var2, var1);
    }

    public static int dp2Px(int dp) {
        int var1 = MethodManager.start();
        MethodManager.addParams(new Integer(dp), var1);
        long var2 = System.nanoTime();
        int var10000 = Math.round((float)dp * DENSITY);
        MethodManager.end(new Integer(var10000), "com/engineer/android/myapplication/Util", "dp2Px", var2, var1);
        return var10000;
    }

    public static void sleep(long seconds) {
        int var2 = MethodManager.start();
        MethodManager.addParams(new Long(seconds), var2);
        long var3 = System.nanoTime();

        try {
            Thread.sleep(seconds);
        } catch (InterruptedException var6) {
            var6.printStackTrace();
        }

        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "sleep", var3, var2);
    }

    public static void nothing() {
        int var0 = MethodManager.start();
        long var1 = System.nanoTime();
        System.out.println("do nothing,just test");
        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "nothing", var1, var0);
    }

    static {
        int var0 = MethodManager.start();
        long var1 = System.nanoTime();
        DENSITY = Resources.getSystem().getDisplayMetrics().density;
        MethodManager.end((Object)null, "com/engineer/android/myapplication/Util", "<clinit>", var1, var0);
    }
}
复制代码

能够看到这个打了 Tiger 注解的Util类,其全部方法内部都已经有插桩代码了。以后这些方法被调用的时候,就能够看到方法耗时了。若是有其余类的方法也须要一样的功能,要作的事情很简但,只须要用 Tiger 注解就能够了。

配置任意类中方法的插桩

上面的实现都是基于咱们已有的代码作文章,可是有时候咱们在作性能优化的时候,会须要统计一些咱们使用的开源库的方法耗时,对于 public 方法也许还好,可是对于 private 方法或者是其余一些场景,就会比较麻烦了,须要借助代理模式(动态代理或静态代理)来实现咱们须要的功能,或者是其余手段,可是这样的手段没有通用性,此次换个库要用,可能又要写一遍相似的功能,或者你也能够把三方库源码拉下来直接改也是能够的。

这里其实能够借助 ASM 稍微作一些辅助,简化这些工做。这里以 Glide 为例。

Glide.with(this).load(url).into(imageView);
复制代码

上面的代码相信你们都不陌生,假设(只是假设)如今须要对统计 load 方法和 into 方法的耗时,那么怎么作呢?

思考一下上面的两个实现,咱们是基于注解肯定了类和方法名,从而实如今特定的类或特定的方法中插入统计方法耗时的逻辑。那么如今这些方法的源码都没法访问了,注解也无法加了,怎么办呢?那就从问题的根本出发,直接由使用者告诉 transform 到底要在哪一个类的哪一个方法进行方法耗时的统计。

咱们能够像 build.gradle 的 android 闭包同样,本身定义一个这样的结点。

open class TransformExtension {
    // class 为键,方法名为值得一个 map
    var tigerClassList = HashMap<String, ArrayList<String?>>()

}
复制代码

在 build.gradle 文件中配置信息

transform {
        tigerClassList = ["com/bumptech/glide/RequestManager": ["load"],
                          "com/bumptech/glide/RequestBuilder": ["into"]]
    }
复制代码

而后分别在 ClassVisitor 和 MethodVisitor 中根据类名和方法名肯定要进行插桩的结点。

init {
        ....
        classList = transform?.tigerClassList
    }

    override fun visit( version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array<String>? ) {
        super.visit(version, access, name, signature, superName, interfaces)
        mClassName = name
        if (classList?.contains(name) == true) {
            methodList = classList?.get(name) ?: ArrayList()
            needHook = true
        }
    }
复制代码

能够简单看一下结果

点击展开
19407-19407 E/0Cat: ┌───────────────────────────────────------───────────────────────────────────------
19407-19407 E/1Cat: │ class's name:       com/bumptech/glide/RequestManager
19407-19407 E/2Cat: │ method's name:      load
19407-19407 E/3Cat: │ method's arguments: [http://t8.baidu.com/it/u=1484500186,1503043093&fm=79&app=86&f=JPEG?w=1280&h=853]
19407-19407 E/4Cat: │ method's result:    com.bumptech.glide.RequestBuilder@9a29abdf
19407-19407 E/5Cat: │ method cost time:   1.52 ms
19407-19407 E/6Cat: └───────────────────────────────────------───────────────────────────────────------
19407-19407 E/0Cat: ┌───────────────────────────────────------───────────────────────────────────------
19407-19407 E/1Cat: │ class's name:       com/bumptech/glide/RequestBuilder
19407-19407 E/2Cat: │ method's name:      into
19407-19407 E/3Cat: │ method's arguments: [Target for: androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}, null, com.bumptech.glide.RequestBuilder@31a00c76, com.bumptech.glide.util.Executors$1@1098060]
19407-19407 E/4Cat: │ method's result:    Target for: androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}
19407-19407 E/5Cat: │ method cost time:   5.78 ms
19407-19407 E/6Cat: └───────────────────────────────────------───────────────────────────────────------
19407-19407 E/0Cat: ┌───────────────────────────────────------───────────────────────────────────------
19407-19407 E/1Cat: │ class's name:       com/bumptech/glide/RequestBuilder
19407-19407 E/2Cat: │ method's name:      into
19407-19407 E/3Cat: │ method's arguments: [androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}]
19407-19407 E/4Cat: │ method's result:    Target for: androidx.appcompat.widget.AppCompatImageView{24ec8f4 V.ED..... ......I. 0,0-0,0 #7f08007c app:id/image}
19407-19407 E/5Cat: │ method cost time:   10.88 ms
19407-19407 E/6Cat: └───────────────────────────────────------───────────────────────────────────------

复制代码

能够看到,插桩已经成功了,RequestManager 的 load 方法打印了完整的方法信息,延伸一下,是否是能够在这里统计一下,到底用 Glide 加载过哪些 url 呢?固然,如上日志也看到,RequestBuilder 当中打印了两个 into 方法,经过方法名插桩是有点粗暴,目标类当中若是有多个同名的方法(方法重载),那么这些方法都会被插桩。这个问题其实也能够经过提供方法 desc (也就是方法参数,返回值等信息) 来作更精确的匹配。可是,这里若是只是作测试,这样粗粒度的也是能够的,毕竟这样对三方库的插桩仍是比较hack的,线上环境最好仍是不要使用。

Android 中点击事件的统计

这里的点击事件泛指实现了 View.OnClickListener 接口的点击事件

在一个成熟的 App 中确定会包含埋点,埋点其实就是在统计用户的行为。好比打开了哪一个页面,点击了哪一个按钮,使用了哪一个功能?经过对这些行为的统计,经过数据就能够获知用户最经常使用的功能,方便产品作决策。

关于点击行为这件事,首先想一想平时咱们都是怎么实现的?无非就是两种状况,要么就是实现 View.OnClickListener 这个接口,而后在 onClick 方法中展开;要么就是匿名内部类,一样是在 onClick 方法展开。所以,如何肯定 onClick 方法就成了咱们须要关注的问题。咱们不能按照以前方法名 equals 的简单规则进行定位。由于这样没法避免方法重名或者是参数重名的问题,假设某个小伙伴写了一个和 onClick(View view) 同名的普通方法,咱们实际定位的 hook 结点可能就不是点击事件发生时的结点。所以,咱们须要确保 ASM 访问的类实现了 android.view.View.OnClickListener 这个接口。

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)
        className = name

              interfaces?.forEach {
                if (it == "android/view/View\$OnClickListener") {
                    println("bingo , find click in class : $className")
                    hack = true
                }
            }
        
    }
复制代码

这个实现其实也很简单,ClassVisitor 的 visit 方法提供了当前类实现了的全部接口,所以这里简单判断一下会比较准确。固然,这里也能够同时判断其余接口,好比咱们要对应用内全部 TabBar 的选中事件进行插桩,就可判断当前类是否实现了 com.google.android.material.bottomnavigation.BottomNavigationView.OnNavigationItemSelectedListener 这个接口。

因为 Transform 会访问 javac 编译生成的全部 class,包括匿名内部类,所以这里对于普通类和匿名内部类能够统一处理。(关于匿名内部类和普通类的使用 ASM 的差别能够参考这篇)。

当前类是否实现了接口肯定以后,下一步就能够按照方法名及方法的参数和返回值进行更加精确的匹配了。

override fun visitMethod( access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>? ): MethodVisitor {
        var methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
        if (hack) {
            if (name.equals("onClick") && desc.equals("(Landroid/view/View;)V")) {
                methodVisitor =
                    TrackMethodVisitor(className, Opcodes.ASM6, methodVisitor, access, name, desc)
            }
        }

        return methodVisitor
    }
复制代码

插桩的具体代码,本质上和以前的几个实现都是相似的,这里就再也不贴代码了。这里简单看一下效果。

E/0Track: ┌───────────────────────────────────------───────────────────────────────────------
E/1Track: │ class's name:             com.engineer.android.myapplication.SecondActivity
E/2Track: │           view's id:      com.engineer.android.myapplication:id/button
E/3Track: │ view's package name:      com.engineer.android.myapplication
E/4Track: └───────────────────────────────────------───────────────────────────────────------
复制代码

当一个点击事件发生时,咱们能够获取实现这个点击事件的类,这个点击事件的 id 和包名。经过这些信息,咱们就能够大概得知这个点击事件是在哪一个页面(哪一个业务)。所以,这个实现也能够帮助咱们定位代码,有时候面对一份彻底陌生的代码,很难定位到你所使用的功能到底在代码的哪一个角落里,经过这个 Transform 实现,能够简单定位一下范围。

本身测试的时候发现,在 Java 中若是使用了 lambda 表达式来实现匿名内部类,那么是不会按照常规的匿名内部类那样处理,并不会生成额外的匿名类。所以,对于使用 lambda 表达式实现的点击事件,这样是没法处理的。(暂时也没想到其余比较好的替代方案)

看到这里,你是否是有一些想法了呢? 是否是能够考虑实现基于 Activity/Fragment 生命周期的代码插桩,来统计页面的展示时间和次数呢?是否是能够考虑将代码里的 Log.d 这样的代码统一删除掉?是否是能够将代码中没有引用和调用的代码删除掉呢?(这个可能有点难)

总结

首先明确一下,以上全部实现都是基于 Transform + ASM 技术栈的探索,只是简单的学习和了解一下 Transform + ASM 可以作什么以及怎么作。所以,部分实现也许有瑕疵甚至是 bug。源码 已同步到 Github 若是有想法,能够提 issue。

经过对 Gradle Transform + ASM 的简单探索,能够看到在工程构建的过程当中,从源码(包括java/kotlin/资源文件/其余) 到中间的 class 再到 dex 文件直至最终的 apk 文件生成,在整个过程当中有不少的 task 被执行。而在 class 到 dex 这之间,利用 ASM 仍是能够作不少文章的。

这里咱们只是简单的打印了 log,其实对一些关键信息,咱们彻底能够进行插桩式的收集,好比在 Glide 内部插入统计加载图片 URL 的代码,好比关键方法(例如 Application 的 onCreate 方法)的耗时统计,有时候咱们关心的并不必定是具体的数据,而是数据所呈现出来的趋势。能够经过代码插桩将这些信息批量保存在本地甚至是上传到服务器,在后续流程中进一步的分析和拆解一些关键数据。

参考文档

详解Android Gradle生成字节码流程

从 Java 字节码到 ASM 实践

App流畅度优化:利用字节码插桩实现一个快速排查高耗时方法的工具

ByteX

相关文章
相关标签/搜索