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...

相关推荐
To_OC3 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic4 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy4 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志4 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble5 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯6 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜7 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司7 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
kaiking_g8 小时前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
江华森8 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http