gradle插件化ASM框架实现字节码插桩

kotlin vs java
知识点来源:这个知识点第一次知道是在一次无意之间看到一个高级工程师的售卖课程,花一分钱试听了一堂JVM虚拟机相关的课程才知道还能这样玩儿出花来,接下来进入正题:

DEMO

1. 想要彻底了解这个知识点首先要掌握JVM虚拟机对于内存分配和线程中线程私有区域栈内方法执行的流程。
java虚拟机内存分配

上图中主要理解Threads中JVM Stacks里面虚拟机栈的作用和意义,了解了第一点后可以做到通过字节码在编译时期通过工具asm进行编译时期代码里面插入新代码的需求

2. 了解熟悉gradle插件开发,因为我们最终的目的是在android中使用该功能进行编译时期对特定方法或者类文件进行处理操作,所以插件是必然的,主要目的就是通过gradle的自动编译将我们java文件编译出来的.class文件进行替换成我们处理之后的文件,最终由编译器转换成.dex文件生成apk,自定义插件有多种方式,只要理解了gradle插件的含义和作用以及开发方式,用哪一种我觉得根据需求而定(我这使用buildSrc本地项目使用的方式)
apk编译流程图

上图中我们通过gradle插件化操作的点就是在 .class Files---->dex----->.dex files的过程中替换 .class文件。谷歌为我们提供了完整的API对这一步骤做处理----[Transform]

3. Transform可谷歌百度深入了解,此处给出超链接也不一定是最好的,只是个人阅读的。

根据下图可以看出我们自定义Transform始终是在系统的Transform之前先做编译处理,如果大家有关注过android编译过程,可以发现整个过程中有很多带有Transform文字样式在里面的名字,其实就是相应的系统Transform而已,至于名字都是通过规则拼接的。

Transform
4. 最后一点ASM,整个工具是用于插入字节码操作的工具,这里就贴出目前最新版本, commons依赖下面有很多利于简化开发的工具,具体可自行百度。
    implementation 'org.ow2.asm:asm:7.0'
    implementation 'org.ow2.asm:asm-commons:7.0'

5. 实战:

  • 接下来就是项目实战环节,目的在demo方法代码中插入一个Toast方法,打印一句话。
    1. 创建一个全新的项目:GradleToASMStakeDemo(此过程完全忽略)
    1. 在项目中创建一个lib模块(此过程也完全忽略),创建模块以buildSrc命名,切记名字一定是这个。
    1. 对buildSrc模块进行改造,使它成为我们的gradle插件模块:
  • 删除除了主目录结构的所有文件和build.gradle文件(所谓主目录结构就是src/main/java/包名文件夹)
  • 创建resources目录,该目录与main目录在同一级,然后在resources中穿件META-INF--->gradle-plugins--->项目包名.properties文件,真个文件创建完整结构如下图:
gradle插件demo目录结构
4. 接下来配置配置buildSrc
  • 首先build.gradle文件配置:
apply plugin: 'java-library' //整个插件用Java编写,如果喜欢用groovy可以导入groovy的相关依赖
// 当前开发编译版本
compileJava {
    sourceCompatibility = 1.8
    targetCompatibility = 1.8
    options.encoding = "UTF-8"
}
//开发文件结构
sourceSets {
    main {
        java {
            srcDir 'src/main/java'
        }
        resources {
            srcDir 'src/main/resources'
        }
    }
}
//获取远程库的仓库地址
repositories {
    jcenter()
    mavenCentral()
    maven { url "https://dl.google.com/dl/android/maven2/" }
}
//开发插件需要的库
dependencies {
    implementation gradleApi()
    implementation localGroovy()
    implementation 'com.android.tools.build:gradle:3.6.2'
    implementation 'org.ow2.asm:asm:7.0'
    implementation 'org.ow2.asm:asm-commons:7.0'
}
  • 然后是 .properties文件,此文件可以简单理解成AndroidManifest.xml文件,作用就是注册该插件。
implementation-class=com.kylin.gradle.buildsrc.TestPlugin,
  • 接下来就是实现插件代码逻辑了,在java文件夹下面创建一个TestPlugin的java文件,该类继承于Plugin ,作为gradle插件开发的入口,实现唯一必须实现方法apply,简单打印一句话就可以在我们编译同步的时候看到我们所打印的东西,当然还需要依赖该插件。
  • 使用gradle插件,使用很简单,只需要在我们项目下的build.gradle文件中依赖即可,就想我们依赖application一样,注意此处依赖使用的是包名,然后编译就可以看到我们上面在apply方法中打印的东西了。
apply plugin: 'com.kylin.gradle.buildsrc' //使用gradle自定义插件
5. 注册我们自己的Transform类文件,注册此文件需要获取到我们AppExtension对象
AppExtension byType = project.getExtensions().getByType(AppExtension.class);
byType.registerTransform(new TestTransform());
  • 接下来创建我们的TestTransform文件,具体方法定义代码中有注释:

/**
 * @Description:Transform在编译过程中.class文件转换成.dex的时候触发(.class -->transform-->.dex)
 * @Auther: wangqi
 * CreateTime: 2020/4/16.
 */
public class TestTransform extends Transform {

    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
        //当前是否是增量编译(由isIncremental() 方法的返回和当前编译是否有增量基础)
        boolean isIncremental = transformInvocation.isIncremental();
        //消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
        Collection inputs = transformInvocation.getInputs();
        //OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

        for (TransformInput input : inputs) {
            //Failed resolution of: Landroidx/appcompat/R$drawable;(不遍历处理的话会出现这个bug)
            for (JarInput jarInput : input.getJarInputs()) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getFile().getAbsolutePath(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR);
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
                FileUtils.copyFile(jarInput.getFile(), dest);
            }

            for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
                FileUtils.copyDirectory(
                        directoryInput.getFile(),
                        outputProvider.getContentLocation(directoryInput.getName(), getInputTypes(), getScopes(), Format.DIRECTORY));

                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY);
                // 插桩
               // replaceFileClass(directoryInput.getFile());
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了, 此目的就是把我们修改之后的文件按照android编译要求放置到本来该放置的位置,以助于apk打包。
                FileUtils.copyDirectory(directoryInput.getFile(), dest);
                System.out.println("dest: " + dest);
            }
        }
    }

    @Override
    public String getName() {
        return "kylin0628";
    }

    /**
     * 筛选需要处理的文件
     * 代表了所有jar包,文件夹中,aar包中的.class文件和标准的java源文件,我们都进行筛选。
     *
     * @return
     */
    @Override
    public Set getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    /**
     * 插件作用域设置
     * TransformManager.SCOPE_FULL_PROJECT 插件作用域真个项目
     *
     * @return
     */
    @Override
    public Set getScopes() {
        return TransformManager.PROJECT_ONLY;
    }

    /**
     * 是否支持增量编译
     *
     * @return
     */
    @Override
    public boolean isIncremental() {
        return false;
    }
}
  • 前面步骤主要是实现了gradle插件自动化的帮助我们把改变之后的文件塞到android编译过程中对应的位置,接下来就是要进行我们的字节码处理文件核心------->利用ASM工具实现字节码插桩操作
  • 插桩主要分以下几步:
         1. 读取.class文件(FileInputStream ->ClassReader)
         2. 写入读取的流文件(ClassWriter ->FileOutputStream)
         3. 写入文件后对文件进行加工处理(ASM-->XxxClassVisitor)
         4. 通过自定义Visitor实现相应的方法处理,注解处理,内部类等处理操作
        5. 具体操作就是先通过javap命令把字节码.class文件反编译成字节码,然后按照visitor提供的方法把你要添加的代码写入代码中。具体可以下载demo

字节码反编译技巧:Idea或Android Studio查看字节码当然还有ASM的插件,但是感觉不好用,还不如这个扩展来的简单方便。

gradle插件调试,在开发过程中肯定免不了打断点看数据:

  • IntelliJ(Android Studio) Edit Configurations点击+找到Remote点击创建远程配置
  • 填写信息. Name自定义, 默认远程调试localhost:5005
  • Search sources using module's classpath选择需要调试的插件模块
  • 命令行执行任务调试: ./gradlew tasks -Dorg.gradle.debug=true --no-daemon,等待连接调试
  • 源代码断点, 选择刚创建的调试配置, 点击 Debug Xxx(Shift+F9)
  • 点击同步代码的,调试断点,将在源码断点处停下来。

你可能感兴趣的:(gradle插件化ASM框架实现字节码插桩)