Android Transform + ASM 初探

背景

随着项目中对 APM (Application Performance Management) 愈来愈关注,诸如像 Debug 日志,运行耗时监控等都会陆陆续续加入到源码中,随着功能的增多,这些监控日志代码在某种程度上会影响甚至是干扰业务代码的阅读,笔者因而查阅有没有一些能够自动化在代码中插入日志的方法,“插桩”就映入眼帘了,本质的思想都是 AOP,在编译或运行时动态注入代码。本文选了一种在编译期间修改字节码的方法,实如今方法执行先后插入日志代码的方式进行一些初步的试探,目的旨在学习这个流程。java

概述

交待完背景后,先对接下来要讲的内容作一个简要的说明。由于是编译期间搞事情,因此首先要在编译期间找一个时间点,这也就是标题前半部分 Transform 的内容;找到“做案”地点后,接下来就是“做案对象”了,这里选择的是对编译后的 .class 字节码下手,要到的工具就是后半部分要介绍的 ASM 了。至此,但愿读者能对本文要讲的内容有一个初步的印象了。android

Transform

先上图git

官方出品的编译打包签名流程,咱们要搞事情的位置就是 Java Compiler 编译成 .class Files 之到打包为 .dex Files 这之间。Google 官方在 Android Gradle 的 1.5.0 版本之后提供了 Transfrom API, 容许第三方自定义插件在打包 dex 文件以前的编译过程当中操做 .class 文件,因此这里先要作的就是实现一个自定义的 Transform 进行.class文件遍历拿到全部方法,修改完成对原文件进行替换。

下面说一下如何引入 Transform 依赖,在 Android gradle 插件 1.5 版本之前,是有一个单独的 transform api 的;从 2.0 版本开始,就直接并入到 gradle api 中了。github

Gradle 1.5:web

Compile ‘com.android.tools.build:transfrom-api:1.5.0复制代码

Gradle 2.0 开始:apache

implementation 'com.android.tools.build:gradle-api:3.0.1'
复制代码

每一个 Transform 其实都是一个 Gradle task,他们链式组合,前一个的输出做为下一个的输入,而咱们自定义的 Transform 是做为第一个 task 最早执行的。api

本文是基于 buildSrc 的方式定义 Gradle 插件的,由于只在 Demo 项目中应用,因此 buildSrc 的方式就够了。须要注意一点的是,buildSrc 方式要求 library module 的名称必须为 buildSrc,在实现中注意一下。app

废话少说,直接上图:框架

buildSrc module:ide

在 buildSrc 中自定义一个基于 Groovy 的插件

在主项目 App 的 build.gradle 中引入自定义的 AsmPlugin

apply plugin: AsmPlugin
复制代码

最后,在 settings.gradle 中加入 buildSrc module

include ':app', ':buildSrc'
复制代码

至此,咱们就完成了一个自定义的插件,功能十分简陋,只是在控制台输出 “hello gradle plugin",让咱们编译一下看看这个插件到底有没有生效。

好了,看到控制台的输出代表咱们自定义的插件生效了,“做案地方”就此埋伏完毕。

后面会定义一个 AsmTransform,注册到 AsmPlugin 中,具体代码会在介绍 ASM 的时候贴出来。

ASM

有了搞事情的时机,怎么去修改字节码呢?此时神器 ASM 就出场了。

ASM 是一个功能比较齐全的 Java 字节码操做与分析框架。它能被用来动态生成类或者加强既有类的功能。ASM 能够直接 产生二进制 class 文件,也能够在类被加载入 Java 虚拟机以前动态改变类的行为。

更多细节能够去 [ASM 官网](https://asm.ow2.io/) 看看。

笔者写 Demo 的时候最新的版本是 7.0。

ASM 提供一种基于 Visitor 的 API,经过接口的方式,分离读 class 和写 class 的逻辑,提供一个 ClassReader 负责读取class字节码,而后传递给 Class Visitor 接口,Class Visitor 接口提供了不少 visitor 方法,好比 visit class,visit method 等,这个过程就像 ClassReader 带着 ClassVisitor 游览了 class 字节码的每个指令。

光有读还不够,若是咱们要修改字节码,ClassWriter 就出场了。ClassWriter 其实也是继承自 ClassVisitor 的,所作的就是保存字节码信息并最终能够导出,那么若是咱们能够代理 ClassWriter 的接口,就能够干预最终生成的字节码了。

好,仍是废话少说,直接上代码。

先看一下插件目录的结构

这里新建了 AsmTransform 插件,以及 class visitor 的 adapter(TestMethodClassAdapter),使得在 visit method 的时候能够调用自定义的 TestMethodVisitor。

同时,buildSrc 的 build.gradle 中也要引入 ASM 依赖

// ASM 相关
implementation 'org.ow2.asm:asm:7.1'
implementation 'org.ow2.asm:asm-util:7.1'
implementation 'org.ow2.asm:asm-commons:7.1'
复制代码

下面先来看一下 AsmTransform

import com.android.build.api.transform.DirectoryInput
import com.android.build.api.transform.Format
import com.android.build.api.transform.JarInput
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInput
import com.android.build.api.transform.TransformInvocation
import com.android.build.api.transform.TransformOutputProvider
import com.android.build.gradle.internal.pipeline.TransformManager
import me.sure.asm.TestMethodClassAdapter
import org.apache.commons.io.FileUtils
import org.gradle.api.Project
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter

class AsmTransform extends Transform {

    Project project AsmTransform(Project project) {
        this.project = project
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        println("===== ASM Transform =====")
        println("${transformInvocation.inputs}")
        println("${transformInvocation.referencedInputs}")
        println("${transformInvocation.outputProvider}")
        println("${transformInvocation.incremental}")

        //当前是不是增量编译
        boolean isIncremental = transformInvocation.isIncremental()
        //消费型输入,能够从中获取jar包和class文件夹路径。须要输出给下一个任务
        Collection<TransformInput> inputs = transformInvocation.getInputs()
        //引用型输入,无需输出。
        Collection<TransformInput> referencedInputs = transformInvocation.getReferencedInputs()
        //OutputProvider管理输出路径,若是消费型输入为空,你会发现OutputProvider == null
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider()
        for (TransformInput input : inputs) {
            for (JarInput jarInput : input.getJarInputs()) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getFile().getAbsolutePath(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR)
                //将修改过的字节码copy到dest,就能够实现编译期间干预字节码的目的了 
                transformJar(jarInput.getFile(), dest)
            }
            for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
                println("== DI = " + directoryInput.file.listFiles().toArrayString())
                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY)
                //将修改过的字节码copy到dest,就能够实现编译期间干预字节码的目的了
                //FileUtils.copyDirectory(directoryInput.getFile(), dest)
                transformDir(directoryInput.getFile(), dest)
            }
        }
    }

    @Override
    String getName() {
        return AsmTransform.simpleName
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return true
    }

    private static void transformJar(File input, File dest) {
        println("=== transformJar ===")
        FileUtils.copyFile(input, dest)
    }

    private static void transformDir(File input, File dest) {
        if (dest.exists()) {
            FileUtils.forceDelete(dest)
        }
        FileUtils.forceMkdir(dest)
        String srcDirPath = input.getAbsolutePath()
        String destDirPath = dest.getAbsolutePath()
        println("=== transform dir = " + srcDirPath + ", " + destDirPath)
        for (File file : input.listFiles()) {
            String destFilePath = file.absolutePath.replace(srcDirPath, destDirPath)
            File destFile = new File(destFilePath)
            if (file.isDirectory()) {
                transformDir(file, destFile)
            } else if (file.isFile()) {
                FileUtils.touch(destFile)
                transformSingleFile(file, destFile)
            }
        }
    }

    private static void transformSingleFile(File input, File dest) {
        println("=== transformSingleFile ===")
        weave(input.getAbsolutePath(), dest.getAbsolutePath())
    }

    private static void weave(String inputPath, String outputPath) {
        try {
            FileInputStream is = new FileInputStream(inputPath)
            ClassReader cr = new ClassReader(is)
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES)
            TestMethodClassAdapter adapter = new TestMethodClassAdapter(cw)
            cr.accept(adapter, 0)
            FileOutputStream fos = new FileOutputStream(outputPath)
            fos.write(cw.toByteArray())
            fos.close()
        } catch (IOException e) {
            e.printStackTrace()
        }
    }
}
复制代码

咱们的 InputTypes 是 CONTENT_CLASS, 代表是 class 文件,Scope 先无脑选择 SCOPE_FULL_PROJECT 在 transform 方法中主要作的事情就是把 Inputs 保存到 outProvider 提供的位置去。生成的位置见下图:

对照代码,主要有两个 transform 方法,一个 transformJar 就是简单的拷贝,另外一个 transformSingleFile,咱们就是在这里用 ASM 对字节码进行修改的。 关注一下 weave 方法,能够看到咱们借助 ClassReader 从 inputPath 中读取输入流,在 ClassWriter 以前用一个 adapter 进行了封装,接下来就让咱们看看 adapter 作了什么。

public class TestMethodClassAdapter extends ClassVisitor implements Opcodes {

    public TestMethodClassAdapter(ClassVisitor classVisitor) {
        super(ASM7, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        return (mv == null) ? null : new TestMethodVisitor(mv);
    }
}
复制代码

这个 adapter 接收一个 classVisitor 做为输入(即 ClassWriter),在 visitMethod 方法时使用自定义的 TestMethodVisitor 进行访问,再看看 TestMethodVisitor:

public class TestMethodVisitor extends MethodVisitor {

    public TestMethodVisitor(MethodVisitor methodVisitor) {
        super(ASM7, methodVisitor);
    }

    @Override
    public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
        System.out.println("== TestMethodVisitor, owner = " + owner + ", name = " + name);
        //方法执行以前打印
        mv.visitLdcInsn(" before method exec");
        mv.visitLdcInsn(" [ASM 测试] method in " + owner + " ,name=" + name);
        mv.visitMethodInsn(INVOKESTATIC,
                "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);

        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);

        //方法执行以后打印
        mv.visitLdcInsn(" after method exec");
        mv.visitLdcInsn(" method in " + owner + " ,name=" + name);
        mv.visitMethodInsn(INVOKESTATIC,
                "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
    }
}
复制代码

TestMethodVisitor 重写了 visitMethodInsn 方法,在默认方法先后插入了一些 “字节码”,这些字节码近似 bytecode,能够认为是 ASM 格式的 bytecode。具体作的事情其实就是分别输出了两条日志:

Log.i("before method exec", "[ASM 测试] method in" + owner + ", name=" + name);
Log.i("after method exec", "method in" + owner + ", name=" + name);
复制代码

话说这么啰哩啰嗦的写一堆就是干这么点儿事儿啊,写起来也太麻烦了吧。 别担忧,ASM 提供了一款的插件,能够转化源码为 ASM bytecode。地址在[这里](https://plugins.jetbrains.com/plugin/5918-asm-bytecode-outline)

找一个简单的方法试一下,见下图:

左边是源码,test 方法也是只打了一条日志,右图是插件翻译出来的“ASMified” 代码,若是想看 bytecode,也是有的哈。

最后让咱们看看编译后的 AsmTest.class 变成了什么样

能够看到,不单在 test() 方法中本来的日志先后新加入日志,连构造函数方法先后都加了,这是由于对 visitorMethod 方法没有进行任何区分和限制,因此任何方法调用先后都被“插桩”了。

结语

至此,经过 Transform + ASM 的方式在编译期间修改字节码的流程就算介绍完毕了,由于只是一个初探,距离实际应用还有许多细节须要优化,但愿本文能够对此种方式感兴趣的朋友提供一点初试的方便,全当抛砖引玉了。

参考资料

google.github.io/android-gra…

asm.ow2.io/

asm.ow2.io/asm4-guide.…

quinnchen.me/2018/09/13/…

plugins.jetbrains.com/plugin/5918…

www.sensorsdata.cn/blog/201812…

相关文章
相关标签/搜索