TransformAPI + ASM实现自动插桩

Transform是Android官方插件提供给开发者在项目构建阶段件的一套api。目前典型的由class到dex转换之前修改Class应用就是字节码插桩技术

Transform的整体运行流程

这里的resource不是指的是安卓中的resource,而是指Java的资源:

不过一般的,我们都是用来处理class文件。

我们所写的配置文件一般都会被打包成一个jar包,jar包里边会包含resource资源。

另外,对于每一个transform,在build阶段,都会出现如下的log:

js 复制代码
:app:transformClassesWithTestForDebug

其中,transform是固定的;Test表示的是当前transform的名称;ForDebug表示的是当前是在debug环境下构建的。 当前除了这种自定义的,还有很多系统的流程,这都是由安卓系统实现的:

js 复制代码
:app:transformClassesWithDexBuilderForDebug

这个就是class转化成dex文件。

我们自定的transform一般是在最开始执行的。这也是需要的,因为我们改完之后的代码需要被加载进入dex文件才能正常运行,实现编译时修改代码的能力。

java 复制代码
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
    TransformOutputProvider outputProvider = transformInvocation.outputProvider
    //清理文件
    outputProvider.deleteAll()
    def inputs = transformInvocation.inputs
    inputs.each {
        // 所有的jar文件的输入,包含了所有的非自己的模块,有aar和jar。三方的jar也在里边。
        def jarInputs = it.jarInputs
        jarInputs.each {
            // 如果开启了增量编译(isIncremental() = true),这里会看到jar包的状态, 删除,新增等。
            println (it.status)
        }
        // 所有的目录的输入。就是自己写的代码的,如下图所示
        def dIs = it.directoryInputs
        dIs.each {
            // 文件有
            def changeFiles = it.changedFiles
            changeFiles.entrySet().each {
                // 如果开启了增量编译,这里会看到类的状态, 删除,新增等。
                println(it.key.name + it.value.name())
            }
        }
    }
}

isIncremental()如果这个值为false,就相当于每一次都是初次构建。

上图就是directoryInputs的位置。

自定义的Transform的全部代码以及对应的解释:

typescript 复制代码
class ASMTransform extends Transform {
    @Override
    public String getName() {
        return "asm";
    }

    /**
     * 处理所有class
     *
     * @return
     */
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    /**
     * 用来处理范围,可以决定是否包含三方库等。
     * 范围是整个项目所有的类,包含依赖的库
     *
     * @return
     */
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    /**
     * 不使用增量
     * @return true表示
     */
    @Override
    public boolean isIncremental() {
        return false;
    }

    /**
     * android插件将所有的class通过这个方法告诉给我们
     *  我们这个transform的输出就是下一个transform的输入。
     * @param transformInvocation
     * @throws TransformException
     * @throws InterruptedException
     * @throws IOException
     */
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        TransformOutputProvider outputProvider = transformInvocation.outputProvider
        //清理文件
        outputProvider.deleteAll()
        def inputs = transformInvocation.inputs
        inputs.each {
            // 所有的jar文件的输入,包含了所有的非自己的模块,有aar和jar。三方的jar也在里边。
            def jarInputs = it.jarInputs
            jarInputs.each {
                // 如果开启了增量编译(isIncremental() = true),这里会看到jar包的状态, 删除,新增等。
                println (it.status)
            }
            // 所有的目录的输入。就是自己写的代码的,如下图所示
            def dIs = it.directoryInputs
            dIs.each {
                // 文件有
                def changeFiles = it.changedFiles
                changeFiles.entrySet().each {
                    // 如果开启了增量编译,这里会看到类的状态, 删除,新增等。
                    println(it.key.name + it.value.name())
                }
            }
        }
    }
}

ASM插桩

首先我们需要准备一份class文件,用来做插桩用,接着需要利用ClassReader这样一个类。它里边有一个accept这样一个api,通过里边的ClassVisitor,可以用来访问类,访问方法,访问注解,或者操作他们。然后通过MethodVisitor来实现插入。

例如我们想在所有的增加了@ASMTest注解的代码中增加统计执行时长的代码:

java 复制代码
public class InjectTest {
    public InjectTest() {
    }

    @ASMTest
    public static void main(String[] var0) throws InterruptedException {
        Thread.sleep(1000L);
    }
}
// 我们希望修改成这样。
public class InjectTest {
    public InjectTest() {
    }

    @ASMTest
    public static void main(String[] var0) throws InterruptedException {
        long var1 = System.currentTimeMillis();
        Thread.sleep(1000L);
        long var3 = System.currentTimeMillis();
        System.out.println("execute:" + (var3 - var1));
    }
}

为了实现修改,我们需要分别将上边的两段代码编译成字节码文件,查看其中的差异,然后再通过上边图中提到的方式来对字节码进行修改。 这样就实现了字节码插桩。

方法签名

这些就是字节码插桩的时候需要注意的:

例如这个指令:

bash 复制代码
INVOKESTATIC java/lang/System.currentTimeMillis ()J

我们在进行插桩的时候,需要对照表,System是一个对象,因此需要在最前边加上L, ()J表示一个方法的签名标识。 修改完成的代码就是这样的:

less 复制代码
invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J"));

Transform与ASM联动

联动的方式就是通过实现一个Plugin来做,这相当于增加了一个gradle任务:

java 复制代码
public class ASMPlugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        BaseExtension android = project.getExtensions().getByType(BaseExtension.class);

        // android 插件 能够获得所有的class
        // 同时他提供一个接口,能够让我们也获得所有class
        android.registerTransform(new ASMTransform());

    }
}

ASMTransform是我们自定义的Transform,就是通过他来实现字节码插桩:

java 复制代码
public class ASMTransform extends Transform {
    @Override
    public String getName() {
        return "asm";
    }

    /**
     * 处理所有class
     *
     * @return
     */
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    /**
     * 用来处理范围,可以决定是否包含三方库等。
     * PROJECT_ONLY:范围仅仅包含我们自己写的代码中的java或者kotlin文件
     *
     * @return
     */
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.PROJECT_ONLY;
    }

    /**
     * 不使用增量
     * @return true表示
     */
    @Override
    public boolean isIncremental() {
        return false;
    }

    /**
     * android插件将所有的class通过这个方法告诉给我们
     *  我们这个transform的输出就是下一个transform的输入。
     * @param transformInvocation
     * @throws TransformException
     * @throws InterruptedException
     * @throws IOException
     */
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
        // 因为不是增量构建,所以可以对之前的文件进行清理
        outputProvider.deleteAll();
        // 得到所有的输入
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        for (TransformInput input : inputs) {
            // 处理class目录
            for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
                // 直接复制输出到对应的目录
                String dirName = directoryInput.getName();
                File src = directoryInput.getFile();
                System.out.println("目录:" + src);
                String md5Name = DigestUtils.md5Hex(src.getAbsolutePath());
                File dest = outputProvider.getContentLocation(dirName + md5Name/*用来作为输出的唯一标记,为什么要做么做?这是因为Transform是一个一个执行的,
                上一个作为下一个输入,所以我们将通过Transform得到的结果写入 outputprovider 获得的一个file中去,然后outputprovider获取的文件的第一个参数就需要给一个唯一的标记
                 */,
                        directoryInput.getContentTypes()/*类型*/, directoryInput.getScopes()/*作用域*/,
                        Format.DIRECTORY);
                // todo 插桩
                processInject(src, dest);
            }
            // 处理jar(依赖)的class todo 先不处理了
            for (JarInput jarInput : input.getJarInputs()) {
                String jarName = jarInput.getName();
                File src = jarInput.getFile();
                System.out.println("jar包:" + src);
                String md5Name = DigestUtils.md5Hex(src.getAbsolutePath());
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4);
                }
                File dest = outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR);
                FileUtils.copyFile(src, dest);
            }
        }
    }

    private void processInject(File src, File dest) throws IOException {
        String dir = src.getAbsolutePath();
        FluentIterable<File> allFiles = FileUtils.getAllFiles(src);
        for (File file : allFiles) {
            FileInputStream fis = new FileInputStream(file);
            // 插桩
            ClassReader cr = new ClassReader(fis);
            // 写出器
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
            // 分析,处理结果写入cw
            cr.accept(new ClassInjectTimeVisitor(cw,file.getName()), ClassReader.EXPAND_FRAMES);

            byte[] newClassBytes = cw.toByteArray();
            // class 文件
            String absolutePath = file.getAbsolutePath();
            // class文件的绝对地址去掉目录,得到的全类名.
            String fullClassPath = absolutePath.replace(dir, "");
            // 完成文件覆盖
            File outFile = new File(dest, fullClassPath);
            FileUtils.mkdirs(outFile.getParentFile());
            FileOutputStream fos = new FileOutputStream(outFile);
            fos.write(newClassBytes);
            fos.close();
        }
    }
}

整体结构就是,通过注解标记哪些方法需要插桩,完成插桩代码编写之后,通过Transform遍历class插桩代码插入到class文件中。最后在编译的时候自动运行这个任务完成自动插桩。

测试源码:github.com/xingchaozha...

相关推荐
ekskef_sef40 分钟前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6411 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻1 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云1 小时前
npm淘宝镜像
前端·npm·node.js
dz88i82 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr2 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook
顾平安3 小时前
Promise/A+ 规范 - 中文版本
前端
聚名网3 小时前
域名和服务器是什么?域名和服务器是什么关系?
服务器·前端
桃园码工3 小时前
4-Gin HTML 模板渲染 --[Gin 框架入门精讲与实战案例]
前端·html·gin·模板渲染
不是鱼3 小时前
构建React基础及理解与Vue的区别
前端·vue.js·react.js