Transform+ASM插桩系列(3)——Transform+ASM的实战

  • Post author:
  • Post category:其他

回顾

在上一章讲到创建完buildSrc之后,实现了项目的plugin之后,就可以在plugin注册我们的Transform。这期文章将正式进入重头戏,今天的学习内容有【认识Transform】、【认识AMS】、【插桩实战】

前言

插桩的技巧中,我们要知道

  • Transform的作用:是用来替换(或转换)Class
  • AMS的作用:是用来修改Class字节码

两者配合起来,利用Transform将旧的class文件取出来,再用AMS修改class的字节码,最后替换成我们新的class文件

认识Transform

android gradle插件自从1.5.0-beta1版本开始就包含了一个Transform API,允许第三方插件在编译后的类文件转换为dex文件之前做处理操作。从这里可以知道Transform还在混淆之前,所以完全不用担心混淆的问题。在使用Transform API, 开发者完全可以不用去关注相关task的生成与执行流程,我们只聚焦在对输入的类文件进行处理,处理完后输出文件即可。

在这里插入图片描述

一、注册Transform

通过android.registerTransform(theTransform)就可以进行注册。

class PhoenixPlugin : Plugin<Project> {

    override fun apply(project: Project) {
      val appExtension = project.extensions.getByName("android")
        if (appExtension is AppExtension) {
            appExtension.registerTransform(CatTransform(project))
        }
    }
}

二、Transform的使用

自定义的Transform继承于com.android.build.api.transform.Transform

class CatTransform(val project: Project) : Transform() {

    private var SCOPES: MutableSet<QualifiedContent.Scope> = mutableSetOf()

    init {
        SCOPES.add(QualifiedContent.Scope.PROJECT)
        SCOPES.add(QualifiedContent.Scope.SUB_PROJECTS)
        SCOPES.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
    }

    /**
     * transform 名字
     */
    override fun getName(): String {
        return "cat"
    }

    /**
     * 输入文件的类型
     */
    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指定作用范围
     */
    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        return SCOPES
    }

     /**
     * 是否支持增量
     */
    override fun isIncremental(): Boolean {
        return false
    }

    /**
     * transform的执行
     */
    override fun transform(transformInvocation: TransformInvocation?) {
         transformInvocation?.inputs?.forEach {
          // 项目中编写的代码
          it.directoryInputs.forEach {directoryInput->
              with(directoryInput){
                 //字节码操作
                 ......
              }
          }

          // 项目中引入第三方Jar包的代码
          it.jarInputs.forEach { jarInput->
              with(jarInput){
                 //字节码操作
                 ......
              }
          }
        }
    }
}
1. 名字

通过Transform#getName指定的当前Transform名字,当工程注册过后的Transform会在gradle的other菜单中可以找到

在这里插入图片描述

同样,在运行完app后,在Transform出来的目录也会在build目录显示cat的名字

在这里插入图片描述

2. 作用域

通过Transform#getScopes指定的作用对象

QualifiedContent.Scope 作用域
EXTERNAL_LIBRARIES 只包含外部库
PROJECT 只作用于project本身内容
PROVIDED_ONLY 支持compileOnly的远程依赖
SUB_PROJECTS 子模块内容
TESTED_CODE 当前变体测试的代码以及包括测试的依赖项
3. 作用对象

通过Transform#getInputTypes指定的作用对象

QualifiedContent.ContentType 作用对象
CLASSES Java代码编译后的内容, 包括文件夹以及Jar包内的编译后的类文件
RESOURCES 基于资源获取到的内容
4. 转换transform

Transform插桩主要是在override fun transform(transformInvocation: TransformInvocation?) 执行完成,对于有代码的地方都需要扫描到,Transform分2个情况去插桩

  • 项目中编写的代码:在编译过程中,开发者编写的代码编译过后的class文件,直接取出来通过AMS修改class源码,然后放回去
  • 项目中引入第三方Jar包的代码:在编译过程中,将旧的jar包解压出来后,取出class文件,通过AMS修改class源码,然后放入新的jar包中,然后放回去
5. TransformInvocation

我们通过实现Transform#transform方法来处理我们的中间转换过程, 而中间相关信息都是通过TransformInvocation对象来传递

public interface TransformInvocation {

    // transform的上下文
    @NonNull
    Context getContext();

    // 返回transform的输入源
    @NonNull
    Collection<TransformInput> getInputs();

    // 返回引用型输入源
    @NonNull Collection<TransformInput> getReferencedInputs();
    
    //额外输入源
    @NonNull Collection<SecondaryInput> getSecondaryInputs();

    //输出源
    @Nullable
    TransformOutputProvider getOutputProvider();

    //是否增量
    boolean isIncremental();
}

关于输入源, 我们可以大致分为消费型和引用型和额外的输入源

  • 消费型(getInputs()获取):需要transform操作的类型, 这类对象在处理后我们必须指定输出传给下一级
  • 引用型(getReferencedInputs()获取):指我们不进行transform操作, 但可能存在查看时候使用, 所以这类我们也不需要输出给下一级
  • 额外的输入源(通过getSecondaryInputs()获取):正常开发中我们很少用到, 不过像是ProGuardTransform中, 就会指定创建mapping.txt传给下一级,这类额外增加的文件就归属到额外输入源
  • 输出源(通过getOutputProvider()获取):在消费完文件后,需要指定输出传给下一级的出口

认识AMS

ASM是一个通用的Java字节码操作和分析框架。它可以用于修改现有类或直接以二进制形式动态生成类。ASM的使用需要一定的学习成本,由于是基于汇编语言指令来改写class文件的,很多Api命名都是通过汇编指令命名,阅读起来只对汇编工程师友好。

一、Class文件操作

由于是对Class文件的操作,掌握AMS的文件操作显得尤为必要,其重要的角色为ClassReaderClassWriterClassVisitor

  • ClassReader:class的输入流,类似文件的输入流,负责《读Class》
  • ClassWriter:class的输出流,类似文件的输出流,负责《写Class》
  • ClassVisitor:class字节码的访问者,负责《转换Class》
1. ClassVisitor

在AMS中最为重要的角色当然是负责篡改字节码的ClassVisitor,在转换过程中ClassVisitor已经帮我封装好了接口,我们只需实现对应的方法就能访问到字节码对应的数据

  • ClassVisitor.visit():访问类自身信息,可以修改当前类、父类、接口的信息
  • ClassVisitor.visitField():访问字段本身,可以添加一个新的字段或者删除已有的字段
  • ClassVisitor.visitMethod():访问方法,可以添加一个新的方或者删除已有的方法
class CatClassVisitor(private val project: Project, classVisitor: ClassVisitor) :
    ClassVisitor(Opcodes.ASM6, classVisitor) {
    
    /**
     * 在这里访问类,进行篡改
     */
    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 visitField(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        value: Any?
    ): FieldVisitor {
        return super.visitField(access, name, descriptor, signature, value)
    }
    
    /**
     * 在这里访问方法,进行篡改
     */
    override fun visitMethod(
        access: Int,
        name: String?,
        desc: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        return super.visitMethod(access, name, desc, signature, exceptions)
    }
}
2. 方法解读

ClassVisitor.visit(int version, int access, String name, String signature, String superName, String[] interfaces)

  • version: 当前Class版本的信息
  • access: 当前类的访问修饰符
  • name: 当前类的名字
  • signature:改当前类的泛型信息
  • superName: 当前的父类
  • interfaces: 当前的接口信息

ClassVisitor.visitField(int access, String name, String descriptor, String signature, Object value)

  • access: 当前字段的访问标识(access flag)信息
  • name: 当前字段的名字
  • descriptor: 当前字段的描述符
  • signature: 当前字段的泛型信息
  • value: 当前字段的常量值

ClassVisitor.visitMethod(int access, String name, String descriptor, String signature, String[] exceptions)

  • access: 当前方法的访问修饰符
  • name: 当前方法的名字
  • descriptor: 当前方法的描述符,例如:()I、()V
  • signature: 当前方法的泛型信息
  • exceptions: 当前方法抛出的异常信息
3. class类完整的读写写法

通过ClassReader->ClassVisitor->ClassWriter(读取字节码->访问并修改字节码->输出字节码),你可以理解类似于文件流操作一样,这就是一种固定的写法

//1、通过ClassReader准备输入流
val reader = ClassReader(file.readBytes())
//2、通过ClassWriter准备输出流
val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
//3、将输出流交给ClassVisitor修改字节码
val visitor = CatClassVisitor(project, writer)
//4、将输入流的内容交给Visitor去处理
reader.accept(visitor, ClassReader.EXPAND_FRAMES)
//5、通过文件流的方式,将字节码输出,并写入原有的class文件
val code = writer.toByteArray()
val classPath = file.parentFile.absolutePath + File.separator + name
val fos = FileOutputStream(classPath)
fos.write(code)
fos.close()

插桩实战——无痕的耗时统计

我们通过一个简单的插桩无痕统计方法耗时的例子来实战一下Transform+AMS,我们先看看最终的结果把

I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out:[类名] com/dreamer/uidemo/MainActivity
I/System.out:[函数] <init>
I/System.out:[参数] []
I/System.out:[返回] null
I/System.out:[耗时] 0.025938
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out:[类名] com/dreamer/uidemo/MainActivity
I/System.out:[函数] initList
I/System.out:[参数] []
I/System.out:[返回] null
I/System.out:[耗时] 0.067448
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out:[类名] com/dreamer/uidemo/MainActivity
I/System.out:[函数] initView
I/System.out:[参数] []
I/System.out:[返回] null
I/System.out:[耗时] 6.508906
I/System.out: └───────────────────────────────────------───────────────────────────────────------

项目结构展示

在这里插入图片描述

1、定义如何统计耗时的方案

首先我们要思考如何进行埋点,对方案的要怎么落笔去写代码,假如我们要对activity的创建进行埋点计时方法耗时

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView((int) R.layout.activity_main);
    initList();
    initView();
    initBanner();
    testLog();
}

最简单的办法就是在开始时标记为开始,并记下当前系统时间,当方法执行完成后,标记为结束,计时相差算出整个方法的运行时间

public void onCreate(Bundle savedInstanceState) {
    int start = MethodManager.start();
    long nanoTime = System.nanoTime();
    super.onCreate(savedInstanceState);
    setContentView((int) R.layout.activity_main);
    initList();
    initView();
    initBanner();
    testLog();
    MethodManager.end((Object) null, "com/dreamer/uidemo/MainActivity", "onCreate", nanoTime, start);
}

2、方案的实施

针对上面的方法,我们就开始编写我们的代码,首先定义需要统计的耗时实体

data class MethodInfo(
    var className: String = "", //类名
    var methodName: String = "", //函数
    var returnParam: Any? = "", //返回
    var time: Float = 0f, //耗时
    var params: ArrayList<Any?> = ArrayList() //参数
) {
    override fun equals(other: Any?): Boolean {
        val m: MethodInfo = other as MethodInfo
        return m.methodName == this.methodName
    }

    override fun toString(): String {
        return "MethodInfo(className='$className', methodName='$methodName', returnParam=$returnParam, time=$time, params=$params)"
    }
}

定义耗时统计的方法,在函数执行前调用start方法,在函数结束时调用end方法

object MethodManager {

    private val methodWareHouse = Vector<MethodInfo>(1024)

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

    @JvmStatic
    fun addParams(param: Any?, index: Int) {
        val method = methodWareHouse[index]
        method.params.add(param)
    }

    @JvmStatic
    fun end(result: Any?, className: String, methodName: String, startTime: Long, index: Int) {
        val method = methodWareHouse[index]
        method.className = className
        method.methodName = methodName
        method.returnParam = result
        method.time = (System.nanoTime() - startTime) / 1000000f
        println("┌───────────────────────────────────------───────────────────────────────────------")
        println("│ [类名] ${method.className}")
        println("│ [函数] ${method.methodName}")
        println("│ [参数] ${method.params}")
        println("│ [返回] ${method.returnParam}")
        println("│ [耗时] ${method.time}")
        println("└───────────────────────────────────------───────────────────────────────────------")
    }
}

在自定义Gradle插件中,通过android.registerTransform()将transform注册到app中

class PhoenixPlugin : Plugin<Project> {

    override fun apply(project: Project) {
        project.afterEvaluate {
            println()
            println("===================================PhoenixPlugin===============begin==================")
            println()
            println()
            println("===================================PhoenixPlugin===============end==================")
            println()
        }
        registerTransform(project)
    }

    private fun registerTransform(project: Project) {
        val appExtension = project.extensions.getByName("android")
        if (appExtension is AppExtension) {
            appExtension.registerTransform(CatTransform(project))
        }
    }
}

创建自定义Transform,开始进行字节码操作

class CatTransform(val project: Project) : Transform() {

    private var SCOPES: MutableSet<QualifiedContent.Scope> = mutableSetOf()

    init {
        SCOPES.add(QualifiedContent.Scope.PROJECT)
        SCOPES.add(QualifiedContent.Scope.SUB_PROJECTS)
        SCOPES.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
    }

    override fun getName(): String {
        return "cat"
    }

    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS
    }

    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        return SCOPES
    }

    override fun isIncremental(): Boolean {
        return false
    }

    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)
        //字节码操作
        ......
    }
}

通过代码可以看到我们直接是全量的扫描整个项目的class文件,包括工程和jar,关键的点在transform上,在transform中我们要思考要做哪些事情:

  1. 要支持非增量和支持增量两种方式
  2. 由于MethodManagerMethodInfo是放在插件内部,属于编译时的的产物,如果没有打进到包体里面,则会报错类找不到的问题,所以要主动将这两个类打进到包体中
  3. 对所有方法,或者是指定方法,进行无痕埋点,即转换字节码的操作
override fun transform(transformInvocation: TransformInvocation?) {
    super.transform(transformInvocation)
    val outputProvider = transformInvocation?.outputProvider

    //非增量则全部删除输出文件,全量清空
    if (!isIncremental) {
        outputProvider?.deleteAll()
    }

    //知识点一:把类打进进包里面
    addClass(transformInvocation)

    //知识点二:把字节码打进包里面(分文件夹和jar包)
    transformInvocation?.inputs?.forEach { input ->
        //1、遍历输入的文件路径Dir
        input.directoryInputs.forEach { directoryInput ->
            println("--directoryInput = $directoryInput")
            if (directoryInput.file.isDirectory) {
                //2、遍历输入的所有文件File
                FileUtils.getAllFiles(directoryInput.file).forEach { it ->
                    println("----file = $it")
                    val file = it
                    val name = file.name
                    if (name.endsWith(".class")) {
                        //3、通过ClassReader->ClassVisitor->ClassWriter(读取字节码->访问并修改字节码->输出字节码)
                        val reader = ClassReader(file.readBytes())
                        val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
                        val visitor = CatClassVisitor(project, writer)
                        reader.accept(visitor, ClassReader.EXPAND_FRAMES)

                        //4、将输出的字节码写入原有的class文件
                        val code = writer.toByteArray()
                        val classPath = file.parentFile.absolutePath + File.separator + name
                        val fos = FileOutputStream(classPath)
                        fos.write(code)
                        fos.close()
                    }
                }

                //5、将class文件返回transform打包路径
                val dest = transformInvocation.outputProvider?.getContentLocation(
                    directoryInput.name,
                    directoryInput.contentTypes,
                    directoryInput.scopes,
                    Format.DIRECTORY
                )
                println("----src = ${directoryInput.file}")
                println("----dest = $dest")
                FileUtils.copyDirectoryToDirectory(directoryInput.file, dest)
            }
        }

        //1、遍历输入的jar路径
        input.jarInputs.forEach { jarInput ->
            println("--jarInput = $jarInput")

            val src = jarInput.file
            val dest = transformInvocation.outputProvider?.getContentLocation(
                jarInput.name,
                jarInput.contentTypes,
                jarInput.scopes,
                Format.JAR
            )

            //2、创建新的空jar包,用来装改完后的jar包
            val temp = src.absolutePath.substring(0, src.absolutePath.length - 4) + "_cat.jar"
            val tempFile = File(temp)
            if (tempFile.exists()) {
                tempFile.delete()
            }
            val outputStream = JarOutputStream(FileOutputStream(tempFile))

            //3、遍历输入的jar文件
            val jarFile = JarFile(src)
            val entries = jarFile.entries()
            while (entries.hasMoreElements()) {
                //4、对jar文件加入到临时新的jar包中
                val jarEntry = entries.nextElement()
                val className = jarEntry.name
                val inputStream = jarFile.getInputStream(jarEntry)
                val classEntry = ZipEntry(className)
                outputStream.putNextEntry(classEntry)

                if (className.contains("glide")
                    && className.endsWith(".class") && !className.contains("R$")
                    && !className.contains("R.class") && !className.contains("BuildConfig.class")
                ) {
                    //5、通过ClassReader->ClassVisitor->ClassWriter(读取字节码->访问并修改字节码->输出字节码)
                    val reader = ClassReader(inputStream)
                    val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
                    val visitor = CatClassVisitor(project, writer)
                    reader.accept(visitor, ClassReader.EXPAND_FRAMES)
                    val code = writer.toByteArray()
                    outputStream.write(code)
                } else {
                    //6、不符合条件的直接源文件读取后放入到临时新的jar包中
                    var len = inputStream.read()
                    while (len != -1) {
                        outputStream.write(len)
                        len = inputStream.read()
                    }
                }
                inputStream.close()
            }
            //7、刷新后,改造后的字节码jar包会返回到transform打包路径
            outputStream.flush()
            outputStream.close()
            FileUtils.copyFile(tempFile, dest)
            //8、删除临时新的jar包
            tempFile.delete()

            println("----tempFile = $tempFile")
            println("----dest = $dest")
        }
    }
}

/**
 * 把MethodManager和MethodInfo的class文件直接打进进包里面
 * /
private fun addClass(transformInvocation: TransformInvocation?) {
    var dir = File(
        System.getProperties()
            .getProperty("user.dir")
                + File.separator + "buildSrc"
                + File.separator + "build"
                + File.separator + "classes" + File.separator + "kotlin" + File.separator + "main"
                + File.separator + MethodManager.javaClass.`package`.name.replace(
            ".",
            File.separator
        )
    )
    val dest = transformInvocation?.outputProvider?.getContentLocation(
        dir.name,
        setOf(QualifiedContent.DefaultContentType.CLASSES),
        mutableSetOf(QualifiedContent.Scope.PROJECT),
        Format.DIRECTORY
    )
    println("----src = $dir")
    println("----dest = $dest")
    FileUtils.copyDirectoryToDirectory(dir, dest)
}
  • 支持增量和非增量:增量情况的实现有一点差距(在下文中介绍),非增量情况就是将输出流的文件清空,防止冗余文件干扰
  • MethodManagerMethodInfo打进包体:其实就是将编译好的类文件的目录找出来,然后拷贝到Transform的输出流中
  • 无痕埋点:通过一些列的文件操作,将class交给CatClassVisitor替换掉,最终将文件复制到transformInvocation?.outputProvider?.getContentLocation提供的输出位置,交给下一个transform去处理

Transform它就犹如是一间工厂,加工完成后,将加工品传送到下一个工厂继续加工。

3、CatClassVisitor

CatClassVisitor里面就是开始对字节码操作的内容,我们通过visit获取类名信息,通过visitMethod获取方法,接着交给CatMethodVisitor开始篡改class字节码,也就是说我们的汇编程序

class CatClassVisitor(private val project: Project, classVisitor: ClassVisitor) :
    ClassVisitor(Opcodes.ASM6, classVisitor) {

    private lateinit var mClassName: String

    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)
        mClassName = name
        println("------visit = 【className】$mClassName")
    }

    override fun visitMethod(
        access: Int,
        name: String?,
        desc: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        println("------visitMethod = 【name】$name 【desc】$desc")
        var methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
        methodVisitor =
            CatMethodVisitor(Opcodes.ASM6, methodVisitor, access, mClassName, name, desc)
        return methodVisitor
    }
}

4、CatMethodVisitor

class字节码的篡改依靠MethodVisitor对程序写入指令,按照汇编的执行顺序,依次写入对应的汇编指令

  • onMethodEnter:在方法开头调用start方法和当前时间戳的方法
  • onMethodExit:在方法结束调用end方法
class CatMethodVisitor(
    api: Int, mv: MethodVisitor?, access: Int,
    private val className: String?,
    private val methodName: String?,
    private val desc: String?
) : AdviceAdapter(api, mv, access, methodName, desc) {

    private var startTimeId = 0
    private var methodId = 0
    private val isStaticMethod: Boolean = access and Opcodes.ACC_STATIC != 0
    private val argumentArrays: Array<Type> = Type.getArgumentTypes(desc)

    override fun onMethodEnter() {
        println("--------onMethodEnter")

        //1、插入Start()
        methodId = newLocal(Type.INT_TYPE)
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            Constants.method_manager,
            "start",
            "()I",
            false
        )
        mv.visitIntInsn(Opcodes.ISTORE, methodId)

        //3、插入时间戳
        startTimeId = newLocal(Type.LONG_TYPE)
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            "java/lang/System",
            "nanoTime",
            "()J",
            false
        )
        mv.visitIntInsn(Opcodes.LSTORE, startTimeId)
    }

    override fun onMethodExit(opcode: Int) {
        println("--------onMethodExit")

        //4、插入end()
        if (opcode == Opcodes.RETURN) {
            visitInsn(Opcodes.ACONST_NULL)
        } else if (opcode == Opcodes.ARETURN || opcode == Opcodes.ATHROW) {
            dup()
        } else {
            if (opcode == Opcodes.LRETURN || opcode == Opcodes.DRETURN) {
                dup2()
            } else {
                dup()
            }
            box(Type.getReturnType(methodDesc))
        }
        mv.visitLdcInsn(className)
        mv.visitLdcInsn(methodName)
        mv.visitVarInsn(Opcodes.LLOAD, startTimeId)
        mv.visitVarInsn(Opcodes.ILOAD, methodId)
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            Constants.method_manager,
            "end",
            "(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;JI)V",
            false
        )
    }
}

通过图片对比(左边:java字节码操作代码,右边:反编译后代码),大概知道语法所表示的意思

在这里插入图片描述

这里的语法解释(TODO:还未了解透彻,后续更新)

5、结果

我们通过构建查看下我们的插桩埋下的日志情况,可以发现,其最终原理是将src篡改过后的源文件目录位置复制到dest下transform指定的目标目录

--directoryInput = ImmutableDirectoryInput{name=0f05f04ac88e24be2f45d8d4dbf2f736e46af0f7, file=G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug, contentTypes=CLASSES, scopes=PROJECT, changedFiles={}}
----file = G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug\com\dreamer\uidemo\MainActivity.class
------visit = 【className】com/dreamer/uidemo/MainActivity
------visitMethod = 【name】onCreate 【desc】(Landroid/os/Bundle;)V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initBanner 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initList 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initView 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】testLog 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】<init> 【desc】()V
--------onMethodEnter
--------onMethodExit
----src = G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug
----dest = G:\yy\UIDemo\app\build\intermediates\transforms\cat\debug\48

通过启动app,查看logcat日志可以看到我们的MainActivity遍历了所有方法,输出所有方法的耗时

I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out:[类名] com/dreamer/uidemo/MainActivity
I/System.out:[函数] <init>
I/System.out:[参数] []
I/System.out:[返回] null
I/System.out:[耗时] 0.025938
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out:[类名] com/dreamer/uidemo/MainActivity
I/System.out:[函数] initList
I/System.out:[参数] []
I/System.out:[返回] null
I/System.out:[耗时] 0.067448
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out:[类名] com/dreamer/uidemo/MainActivity
I/System.out:[函数] initView
I/System.out:[参数] []
I/System.out:[返回] null
I/System.out:[耗时] 6.508906
I/System.out: └───────────────────────────────────------───────────────────────────────────------

支持增量编译

1、增量的介绍

增量的编译Transform也帮我们有考虑进去,其伪代码如下

if (transformInvocation.isIncremental) {
    for ((changedFile, status) in src.changedFiles) {
        when (status ?: Status.NOTCHANGED) {
            Status.NOTCHANGED -> {
            }
            Status.ADDED, Status.CHANGED -> transformFile(changedFile, destFile)
    
            Status.REMOVED -> {
                FileUtils.deleteIfExists(destFile)
            }
        }
    }
} else {
    transformJar(function, inputJar, outputJar)
}

2、增量的实施

增量方案主要是在transform(transformInvocation: TransformInvocation?)范围内去修改,通过transformInvocation.isIncremental去判断是否增量编译,在文件的增量和jar的增量情况不一样,jar的话增量实现代码一样,而文件的增量有所不同。支持增量的情况下,需要将isIncremental设置为true

override fun isIncremental(): Boolean {
        return true
}

3、增量文件的处理

  • 非增量的情况:主要是针对文件目录去适配的,修改整个目录的class文件后拷贝整个目录到目标目录下
  • 增量的情况:主要是对单个文件的处理,通过修改单个class文件后,将单个文件拷贝到需要修改的目录下
//1、遍历输入的文件路径Dir
input.directoryInputs.forEach { directoryInput ->
    println("--directoryInput = $directoryInput")
    if (directoryInput.file.isDirectory) {

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

        println("--isIncremental = ${transformInvocation.isIncremental}")

        if (transformInvocation.isIncremental) {
            //2、遍历输入的所有增量文件File
            println("--changedFile = ${src.changedFiles}")
            for ((changedFile, status) in src.changedFiles) {

                //找到最终文件路径
                val destFile = toOutputFile(
                    destDir,
                    src.file,
                    changedFile
                )

                when (status ?: Status.NOTCHANGED) {
                    Status.NOTCHANGED -> {
                    }
                    Status.ADDED, Status.CHANGED -> transformFile(changedFile, destFile)

                    Status.REMOVED -> {
                        FileUtils.deleteIfExists(destFile)
                    }
                }
            }
        } else {
            //2、遍历输入的所有非增量文件File
            FileUtils.getAllFiles(src.file).forEach { it ->

                //找到最终文件路径
                val destFile = toOutputFile(
                    destDir,
                    src.file,
                    it
                )

                //转换
                transformFile(it, destFile)
            }
        }
    }
}

在修改增量文件中要注意的是,由于文件给的输出流是一个目录,而我们输入流是一个文件,所以最终的文件位置是需要拼接,最终文件该放置的位置=输出流目录+输入流文件名。但是在它的代码实现中,并没有那么简单,它的代码实现原理是为最终文件该放置的位置=输出流目录+ (输入流文件绝对路径输入流文件目录),这里不能单纯的取文件名那么简单,比如:输入流文件路径android/com/path/demo/MainActity.class输入流文件目录android/,其相减的结果是com/path/demo/MainActity.class,简单的说是两个路径的相减得到是一个包含目录的位置,而不是一个简单的文件名

private fun toOutputFile(
    outputDir: File?, inputDir: File, inputFile: File
): File {
    return File(
        outputDir,
        FileUtils.toSystemDependentPath(inputDir.toURI().relativize(inputFile.toURI()).path)
    )
}

这里博主就在这里踩了坑,由于一开始用了非增量编译的方案,直接拷贝目录,结果发现编译报错,导致有两份重复的文件,原来增量的方案是不能拷贝目录的

在这里插入图片描述

3、增量jar的处理

增量jar处理起来很简单,因为是跟非增量一样的原理

  • 非增量的情况:主要是通过jar解压后,对所有文件进行插桩处理
  • 增量的情况:主要是通过有变化的jar包,将整个jar包重新做class的插桩
//1、遍历输入的jar路径
input.jarInputs.forEach { jarInput ->
    println("--jarInput = $jarInput")

    val src = jarInput.file
    val dest = transformInvocation.outputProvider?.getContentLocation(
        jarInput.name,
        jarInput.contentTypes,
        jarInput.scopes,
        Format.JAR
    )

    if (transformInvocation.isIncremental) {
        when (jarInput.status ?: Status.NOTCHANGED) {
            Status.NOTCHANGED -> {
            }
            Status.ADDED, Status.CHANGED -> transformJar(src, dest)
            Status.REMOVED -> FileUtils.delete(dest)
        }
    } else {
        transformJar(src, dest)
    }
}

4、结果

我们通过修改MainActivity一句代码,然后需要通过Android StudioApply Change Run才会跑增量编译(这里要注意不是普通的构建Run),日志也能发现其isIncrementaltrue,还有文件的状态显示

--directoryInput = ImmutableDirectoryInput{name=86c6f016c40a6913bcc53bc7a9e902ae17669288, file=G:\yy\UIDemo\app\build\intermediates\javac\debug\classes, contentTypes=CLASSES, scopes=PROJECT, changedFiles={}}
--isIncremental = true
--changedFile = {}
--directoryInput = ImmutableDirectoryInput{name=0f05f04ac88e24be2f45d8d4dbf2f736e46af0f7, file=G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug, contentTypes=CLASSES, scopes=PROJECT, changedFiles={G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug\com\dreamer\uidemo\MainActivity.class=CHANGED}}
--isIncremental = true
--changedFile = {G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug\com\dreamer\uidemo\MainActivity.class=CHANGED}
----src = G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug\com\dreamer\uidemo\MainActivity.class
----dest = G:\yy\UIDemo\app\build\intermediates\transforms\catIns\debug\49\com\dreamer\uidemo\MainActivity.class
------visit = 【className】com/dreamer/uidemo/MainActivity
------visitMethod = 【name】onCreate 【desc】(Landroid/os/Bundle;)V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initBanner 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initList 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initView 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】testLog 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】<init> 【desc】()V
--------onMethodEnter
--------onMethodExit

优缺点

无痕插入统计耗时虽然做到了对开发者无感知并且无痕插入统计耗时,但其存在也有缺点,由于过多的插入代码,会带来包体积的增大的隐患,对编译速度也有性能上的损耗,建议可以在此基础上做针对性的埋点,或者重要业务上的埋点,而不是做到全量埋点。

完整源码

源码


版权声明:本文为qq_30379689原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。