Gradle Plugin学习笔记之字节码插桩打印方法耗时

2,652 阅读7分钟

在上一篇Gradle Plugin学习笔记之插件的创建中学习记录了如何创建插件以及如何创建拓展函数,本篇以打印方法耗时来实践字节码插桩的学习

一、前置知识点Transform、ASM、.class字节码文件

1.1 Transform的作用

Transform API 由Android gradle提供,用以在.class编译为dex之前对其进行修改干预最终的.class字节码文件。HenCoder课中比喻很是恰当也便于理解。把整个android编译过程看作是一次快递运输,Transform则可以看作是运输过程中的一个个中转站,处理输入,再输出给下一站。在transform处理过程中,无需关心具体的gradle task,只需关注输入处理(JAR,CLASS,RES),与输出位置。 gradle编译过程中优先执行自定义的Transform,如下图所示 引用https://calvinlu.top/2019/12/03/gradle-build-flow/

图片出处:calvinlu.top/2019/12/03/…

1.2 ASM的使用

ASM是Java的一个字节码处理框架,配合插件极大的降低了字节码操作的难度,简化了字节码操作的过程。如果不是对字节码级别操作了如指掌,否则就此篇而言没有ASM我短时间是无法实践的,同时字节码操作也不是本篇学习的重点。下文实践中会对ASM的初步使用有一个简单的记录。

操作字节码文件还有其他框架与工具,本篇不做实践与记录。本篇不宜与展开AOP实践方面的知识,同时这部分知识我实践过少难免错误臆测结果与原理

1.3 .class字节码文件

就本篇实例而言,主要针对方法进行操作,.class字节码文件至少需要初步了解方法栈的一些知识,比如方法栈的本地变量,return指令

从另一篇笔记中拿一个简单的例子做简要说明, 创建一个简单的方法。 这个方法很简单,有三个变量,分别是i、a、b,不接受参数,没有return值

解析字节码指令结构:stack=1,操作栈容积为1,locals=4本地变量4个,args_size=1,接受参数个数1个,指令12执行return。此方法栈图示如下

了解以上几点小知识便于下文ASM使用分析

如果需要了解更多指令集的意义可参照java官方的字节码文档或者JVM 指令集整理


对上一篇中的插件代码稍作修改,同时增加transform的处理完成字节码插桩的实践

二、任务准备

2.1 插桩目标类配置拓展函数

需要插桩的包名,类名,方法名

open class GrockTrackTimeExtension {
    //将提供拓展修改,变量也必须是可变的,公开的
    var packageName = ""
    var className = ""
    var methodName = ""
}

创建拓展函数

 val extensionFun = project.extensions
 .create("trackTime",GrockTrackTimeExtension::class.java)

2.2 创建自定义Transform

class GrockTransformSimple(val trackConfig: GrockTrackTimeExtension) : Transform() {

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

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

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

    override fun isIncremental(): Boolean {
        return false
    }

    override fun transform(transformInvocation: TransformInvocation) {
    }
}    
  • 构造接受配置拓展函数
  • getName 任务名称
  • getInputTypes 接受的输入类型,指定为CLASS字节码文件
  • getScopes 指定影响范围为整个项目(其他类型还有子项目,外部依赖等等)
  • isIncremental 增量编译时是否干预
  • transform 处理.class,jar,res的输入与输出「重点」

2.3 在插件中注册transform

将拓展函数传递给transform,并将自定义transform注册进android编译任务

    override fun apply(project: Project) {
        //创建拓展函数,拓展函数名称,绑定拓展函数类
        val extensionFun = project.extensions.create("trackTime",GrockTrackTimeExtension::class.java)
        val transform = GrockTransform(extensionFun)
        //获取android{}拓展
        val baseExtension = project.extensions.getByType(BaseExtension::class.java)
        //在android拓展函数中注册变换
        baseExtension.registerTransform(transform = transform)
    }

稍微看下注册方法的实现,先添加进一个transfrom队列中,最后交由TaskManager取出遍历加入TransformManager,最终交由TransformTask注册进TaskFactory并关联到整个Android编译任务中被执行

BaseExtension{
···
    fun registerTransform(transform: Transform, vararg dependencies: Any) {
        _transforms.add(transform)
        _transformDependencies.add(listOf(dependencies))
    }
}

三、变更字节码文件

处理逻辑:获取输入确认输出>过滤出目标class>读取class>读取方法>方法插桩

3.1 优先处理好输入获取与输出位置

transform既然作为一个中转站,就需要优先需要完整接受输入内容,并按既定目标输出,确保整体链路的正常工作。目前暂不知道修改输出目标的意义,也暂时不做尝试了。 本篇只对当前项目源码的class文件进行修改,但是jar也是类似的,只是多了解压与重新打包的步骤

获取原始输入内容进行操作,并按照原始输出目标输出给下一站

    override fun transform(transformInvocation: TransformInvocation) {
        val inputs = transformInvocation.inputs
        val output = transformInvocation.outputProvider
        inputs.forEach { input ->
            //依赖的jar包内容保持不动
            input.jarInputs.forEach { jar ->
                //传递给下一个任务
                val dest = output.getContentLocation(
                        jar.name, jar.contentTypes, jar.scopes, Format.JAR
                )
                FileUtils.copyFile(jar.file, dest)
            }
            //当前项目源码
            input.directoryInputs.forEach { dirInput ->
            	//处理字节码
                handlerDirInput(dirInput)
                //传递给下一个任务
                val dest = output.getContentLocation(
                        dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY
                )
                FileUtils.copyDirectory(dirInput.file, dest)
            }
        }
    }

3.2 使用ASM操作字节码文件

按照配置拓展函数,确认class文件目标为com\xx\xx\X.class 那就需要从输入的class中找出全部的class文件进行选中

3.2.1 找出目标class文件

    private fun handlerDirInput(dirInput: DirectoryInput) {
        val files = fileRecurse(dirInput.file.absolutePath, mutableListOf())
        val suffix = formatPackageName(trackConfig.packageName) + "\\" + trackConfig.className + ".class"
        files?.forEach { classFile ->
            if (classFile.absolutePath.endsWith(suffix)) {
         		//确认class文件
            }
        }
    }

fileRecurse在用groovy时是自带的,换到kotlin没发现有现成的,只好用同名又重新写了一个,心中很是不爽。

3.2.2 ASM出场:ClassVisitor与MethodVisitor

最终目标是要修改方法,按照ASM的API需要先访问.class,再访问method进行修改。并把修改后的结果覆盖写入原始的.class文件完成替换。

自定义一个.class访问实现类TrackClassVisitor,来完成与transform的对接逻辑。 这个类需要接受一个方法名称的参数,用以过滤目标方法,并交由MethodVisitor来处理

class TrackClassVisitor(val classWriter: ClassWriter, val methodName: String)
    : ClassVisitor(Opcodes.ASM5, classWriter) {
    override fun visit(version: Int, access: Int, name: String?//className,注意不是simpleName,是name!
                       , signature: String?, superName: String?, interfaces: Array<out String>?) {
        super.visit(version, access, name, signature, superName, interfaces)
    }
    override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>?): MethodVisitor {
        if (methodName == name) {
            val methodVisitor = classWriter.visitMethod(access, name, desc, signature, exceptions)
            return TrackMethodVisitor(methodVisitor)
        }
        return super.visitMethod(access, name, desc, signature, exceptions)

    }
}

ASM提供了ClassReaderClassWriter来对对应.class文件的读写操作,这里只需要简单的创建与参数传入即可完成访问修改写入覆盖

    private fun handlerDirInput(dirInput: DirectoryInput) {
        val files = fileRecurse(dirInput.file.absolutePath, mutableListOf())
        val suffix = formatPackageName(trackConfig.packageName) + "\\" + trackConfig.className + ".class"
        files?.forEach { classFile ->
            if (classFile.absolutePath.endsWith(suffix)) {
                //修改字节码文件
                val classReader = ClassReader(classFile.readBytes())
                val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                //自定义类访问器
                val classVisitor = TrackClassVisitor(classWriter, trackConfig.methodName)
                //修改类
                classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
                //覆盖写入源文件
                val byte = classWriter.toByteArray()
                val fos = FileOutputStream(classFile.parentFile.absolutePath
                        + File.separator + classFile.name)
                fos.write(byte)
                fos.close()
            }
        }
    }

3.2.3 初识MethodVisitor

class TrackMethodVisitor(val methodVisitor: MethodVisitor)
    : MethodVisitor(Opcodes.ASM4, methodVisitor), Opcodes {

    override fun visitCode() {
        println("fetchBooks visitCode")
        super.visitCode()
    }
    override fun visitInsn(opcode: Int) {
        println("fetchBooks visitInsn opcode=$opcode")
        super.visitInsn(opcode)
    }
    override fun visitEnd() {
        super.visitEnd()
        println("fetchBooks visitEnd")
    }
}
  • visitCode 处理前置逻辑,如:变量初始化
  • visitInsn 处理后置逻辑,如:结束日志打印
  • visitEnd 访问结束 值得一提的是,经过测试发现,前置逻辑与后置逻辑均需要放在super.xx之前执行。对应逻辑也就是前置逻辑在最早执行(早于靠前的原始代码),后置逻辑在最后执行(晚于最靠后的原始代码)

按照逻辑,后续只需要把ASM插件生成的字节码操作代码放在对应位置即可

3.2.4 使用ASM插件工具生成字节码操作代码

安装一个ASM插件用以生成字节码操作代码。一番折腾发现java的ASM插件不能再Android studio中使用,但是好在 ASM Bytecode Viewer Support Kotlin 同时也适用于java,所以本篇还是以java为测试实例 这里准备的测试类为BookShop,先写好插桩完的代码,使用ASM插件预览字节码操作代码。

测试类代码:

public class BookShop {

    public void fetchBooks() {
        //插桩代码
        long nailTime = System.currentTimeMillis();
        io();
        //插桩代码
        long trackTime = System.currentTimeMillis() - nailTime;
        System.out.println("trackTime=" + trackTime);
    }
    //模拟耗时方法
    private void io() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

测试类中提供一个fetchBooks方法模拟耗时,调用io方法睡2秒,并在其前后插装耗时统计,最终打印结果。

这里有个坑哦就是这个io方法为什么要做单独提出? 其实我知道问题是什么,暂时还没有探究到如何解决。期望你读到这里时能为我解惑一二,是本质上的解决而不是其他AOP的解决方案哦

ASM字节码操作代码预览:

打开ASM Bytecode Viewer 选择ASMified

代码比较长,这里就用图片展示部分

借助对字节码文件的了解,上图的这些代码不难理解。值得一提的时,这些行号的指定是需要忽略的,因为这样的硬逻辑不适用于其他类文件。就打印方法执行耗时这一目的只需要关注最早,与最后即可,所以可以忽略行号的确认。

3.2.5 在MethodVisitor中进行插桩

    //放入前置逻辑,如变量声明
    override fun visitCode() {
        println("fetchBooks visitCode")
        methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
        methodVisitor.visitVarInsn(Opcodes.LSTORE, 1)
        super.visitCode()
    }

    //判断执行位置 增加后置逻辑
    override fun visitInsn(opcode: Int) {
        println("fetchBooks visitInsn opcode=$opcode")
        if (opcode == Opcodes.RETURN || opcode == Opcodes.ATHROW) {
            methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
            methodVisitor.visitVarInsn(Opcodes.LLOAD, 1)
            methodVisitor.visitInsn(Opcodes.LSUB)
            methodVisitor.visitVarInsn(Opcodes.LSTORE, 3)
            methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;")
            methodVisitor.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder")
            methodVisitor.visitInsn(Opcodes.DUP)
            methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false)
            methodVisitor.visitLdcInsn("trackTime=")
            methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false)
            methodVisitor.visitVarInsn(Opcodes.LLOAD, 3)
            methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false)
            methodVisitor.visitLdcInsn("ms")
            methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false)
            methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false)
            methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false)
            methodVisitor.visitInsn(Opcodes.RETURN)
        }
        methodVisitor.visitEnd()
        //要在super之前执行插入
        super.visitInsn(opcode)

    }

删除预览插桩代码的源码逻辑,本篇中仅保留io方法的调用即可

public class BookShop {

    public void fetchBooks() {
        io();
    }

    //模拟耗时方法
    private void io() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

3.2.6 配置插件的拓展函数,验证结果

apply plugin:'com.grock'

trackTime{
     packageName = "com.grock.myplugin"
     className = "BookShop"
     methodName = "fetchBooks"
}

日志打印:

 I/System.out: trackTime=2002ms

最后再看一下插桩后的.class文件的目标方法fetchBooks方法

    public void fetchBooks() {
        long var1 = System.currentTimeMillis();
        this.io();
        long var3 = System.currentTimeMillis() - var1;
        System.out.println("trackTime=" + var3 + "ms");
    }

下一篇计划:争取搞明白ASM操作字节码时如何正确处理方法栈的变量写入

如有笔误之处,欢迎斧正

END