如何开发一款高性能的 gradle transform

Posted code小生

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何开发一款高性能的 gradle transform相关的知识,希望对你有一定的参考价值。

code小生,一个专注 android 领域的技术平台

前言

对于java开发者来说,大家好像都比较喜欢在编译期间搞事儿,比如为了做到AOP编程,大家都喜欢利用字节码生成技术,常用的有无痕埋点,方法耗时统计等等。那么Android中具体是如何做到这些的呢?所谓字节码插桩技术,其实就是修改已经编译的class文件,往里面添加自己的字节码,然后打包的时候打包的是修改后的class文件。为了便捷的修改编译后的class文件,Google爸爸开发了一套gradle相关的库,也就是gradle-transform-api,利用这个工具,我们可以自己实现class文件修改,下面我们看看具体做法。

1.实现一个gradle Plugin

想要使用gradle-transform-api,我们必须要先实现一个gradle插件,然后在插件中注册一个Transform,实现插件有三种方式,这里做下简单介绍,详细的请看 官方文档

1.1 直接在build.gradle文件中实现:

class GreetingPlugin implements Plugin<Project{
    void apply(Project project) {
        project.task('hello') {
            doLast {
                println 'Hello from the GreetingPlugin'
            }
        }
    }
}

// Apply the plugin
apply plugin: GreetingPlugin

关键是实现Plugin 接口

1.2 创建一个buildsrc模块

第一种方式不适合用来开发复杂的插件,如果只是自己的项目需要,插件又比较复杂,我们可以创建一个buildsrc模块,然后把上面的GreetingPlugin类移动到这个模块中,这个和下面另一种方式比较接近,这里就不做详细介绍了,有兴趣的可以看官方文档。

1.3 单独工程

创建一个自己的module或工程,这种方式是最常用的,可以看下目录结构

├── pluginmodule
│   ├── build.gradle
│   └── src
│       └── main
│           ├── groovy
│           │   └── com
│           │       └── jianglei
│           │           └── plugin
│           │               ├── MethodTracePluginPlugin.groovy
│           └── resources
│               └── META-INF
│                   └── gradle-plugins
│                       └── com.jianglei.method-tracer.properties

其中,JlLogPlugin就是插件实现者:

class MethodTracePlugin implements Plugin<Project{

    @Override
    void apply(Project project) {

         project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
        //确保只能在含有application的build.gradle文件中引入
        if (!project.plugins.hasPlugin('com.android.application')) {
            throw new GradleException('Android Application plugin required')
        }
        project.getExtensions().findByType(AppExtension.class)
                .registerTransform(new MethodTraceTransform(project))

    }
}

另外,com.jianglei.method-tracer.properties用来宣告谁是插件实现者,文件的名字也就是你要引用时的名字

apply plugin: 'com.jianglei.jllog'

文件里面长这样:

implementation-class=com.jianglei.plugin.MethodTracePlugin

2. 实现一个transform

我们在第一步中注册了一个transform,这个transform能够输入编译后的class文件,然后我们处理class文件,将修改后的文件输出。

代码很简单:

class MethodTraceTransform extends Transform {
    private Project project
    MethodTraceTransform(Project project) 
{
        this.project = project
    }
    @Override
    String getName() {
        return "MethodTrace"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {

        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        //此次是只允许在主module(build.gradle中含有com.android.application插件)
        //所以我们需要修改所有的module
        return TransformManager.SCOPE_FULL_PROJECT

    }

    @Override
    boolean isIncremental() {
        return true
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, 
                          InterruptedException, IOException 
{

    }
}

实现transform的核心就是覆写这几个方法,我们一个个说明。

2.1 getName()

这个方法只是用来定义transform任务的名称,随意定一个就好。

2.2 getInputTypes()

这个用来限定这个transform能处理的文件类型,一般来说我们要处理的都是class文件,就返回TransformManager.CONTENT_CLASS,当然如果你是想要处理资源文件,可以使用TransformManager.CONTENT_RESOURCES,这里按需要来就好,还有其它配置就要查看官网javadoc文档了,这里需要科学上网。

2.3 getScopes()

2.2中我们指定的是要处理那种文件,那么,这里我们要指定的的就是哪些文件了。比如说我们如果想处理class文件,但class文件可以是当前module的,也可以是子module的,还可以是第三方jar包中的,这里就是用来指定这个的,我们看下有哪些选项:

public static enum Scope implements QualifiedContent.ScopeType {
        PROJECT(1),
        SUB_PROJECTS(4),
        EXTERNAL_LIBRARIES(16),
        TESTED_CODE(32),
        PROVIDED_ONLY(64),
        /** @deprecated */
        @Deprecated
        PROJECT_LOCAL_DEPS(2),
        /** @deprecated */
        @Deprecated
        SUB_PROJECTS_LOCAL_DEPS(8);

        private final int value;

        private Scope(int value) {
            this.value = value;
        }

        public int getValue() {
            return this.value;
        }
    }

基本上从名字上也可以看出作用范围了,当然具体怎么选还是要注意些的,后面我们会介绍。

2.4 inIncremental()

是否支持增量编译,按道理讲,一个合格的transform应该支持增量编译。

2.5 transform()

这个方法就是我们要处理的重点了,我们在这个方法中获取输入的class文件,然后做些修改,最后输出修改后的class文件。主要也是分为三步走:

2.5.1 获取输入文件
transformInvocation.inputs.each {input ->
         transformInvocation.inputs
                .each { input ->

            transformSrc(transformInvocation, input)
            transformJar(transformInvocation, input)
        }
        }

这里的输入文件分为两种, 一种是本module自己的src下的源码编译后的class文件,一种是第三方的jar包文件,我们需要分开单独处理。

2.5.2 获取输出路径

输入文件有了,我们要先确定输出路径,这里要注意,输出路径必须用特殊方式获取,而不能自己随意指定,否则下一个任务就无法获取你这次的输出文件了,编译失败。
对于源码编译的class文件输出路径这样获取:

 def outputDirFile = transformInvocation.outputProvider.getContentLocation(
                    directoryInput.name, directoryInput.contentTypes, directoryInput.scopes,
                    Format.DIRECTORY
            )

对于jar包的输出路径这样获取:

 def outputFile = transformInvocation.outputProvider.getContentLocation(
                    jarInput.name, jarInput.contentTypes, jarInput.scopes,
                    Format.JAR
            )
2.5.3处理输入文件

经过上面的步骤,我们能获取到输入文件,也确定了输出路径,现在我们只要来处理这些文件,然后输出到输出路径就可以了:

首先处理src下源码编译生成的class文件:

private void transformSrc(TransformInput input){
    input.directoryInputs.each { directoryInput ->
          //这里是为了把所有目录下的文件存到一个list集合中
          def allFiles = DirectoryUtils.getAllFiles(directoryInput.file)
          for (File file : allFiles) {
                //比如上一个文件输入的全路径是 /A/B/com/jianglei/test/Test.class,获取的输出路径是
                // /transform/MethodTrace/debug,替换后就变成了/transform/MethodTrance/debug/com/jianglei/test/Test.class
                def outputFullPath = file.absolutePath.replace(inputFilePath, outputFilePath)
                def outputFile = new File(outputFullPath)
                 if (!outputFile.parentFile.exists()) {
                        outputFile.parentFile.mkdirs()
                 }
                  //这个方法中你可以尽情修改class文件,然后输出到outputFile中即可,
                  //就算不修改,至少也要将原有文件拷贝过去
                  MethodTraceUtils.traceFile(file, outputFile)

           }
    }
}

注释写的很清楚,这里注意,就算你不想修改这个class文件,你也应该将它原样拷贝过去,否则这个文件就丢失了。

接着,我们处理jar文件:

private void transformJar(TransformInvocation transformInvocation, TransformInput input,
                              boolean isIncrement, boolean isConfigChange,
                              Map<String, String> lastJarMap, Set<String> curJars, MethodTraceExtension extension)
 
{

        for (JarInput jarInput : input.jarInputs) {
            def outputFile = transformInvocation.outputProvider.getContentLocation(
                    jarInput.name, jarInput.contentTypes, jarInput.scopes,
                    Format.JAR
            )
           //这个方法就是处理jar文件,然后将处理后的jar文件输出到输出目录
           MethodTraceUtils.traceJar(jarInput, outputFile)
    }

上面其实就是遍历每个jar文件去处理,那么具体如何处理的?

public static void traceJar(JarInput jarInput, File outputFile) {

        def jar = jarInput.file
        LogUtils.i("正在处理jar:" + jarInput.name)
        //jar包解压的临时位置
        def tmpDir = outputFile.parentFile.absolutePath + File.separator + outputFile
                .name.replace(".jar", File.separator)
        def tmpFile = new File(tmpDir)
        tmpFile.mkdirs()
        //先解压缩到临时目录
        MyZipUtils.unzip(jar.absolutePath, tmpFile.absolutePath)
        //收集解压缩后的所有文件
        def allFiles = new ArrayList()
        collectFiles(tmpFile, allFiles)
        allFiles.each {
            if (isNeedTraceClass(it)) {
                //将处理后的文件命名成原名称-new形式
                def tracedFile = new File(tmpFile.absolutePath + "-new")
                //去修改单个class文件
                traceFile(it, tracedFile)
                //处理完后用新的文件替换原有文件
                it.delete()
                tracedFile.renameTo(it)
            }
        }
        MyZipUtils.zip(tmpFile.absolutePath, outputFile.absolutePath)
        tmpFile.deleteDir()
    }

jar文件和普通的class文件相比多了解压缩过程,解压缩后我们就可以按照普通的class文件一个个去处理,最后我们将处理后class文件夹重新压缩到输出目录即可,这里注意删掉中间产生的解压缩目录即可。

2.5.4 小结

经过上面的步骤,可以说我们就成功的实现了一个gradle插件,能够拦截所有的class文件,并且修改这些class文件,成功做到了AOP编程,当然具体如果修改class文件,这不在本文讨论范围内,大家可以自己去查找ASM等技术。

3. 让插件可以配置

现在,我们的插件开发完了,那这样够了吗?比如说不想对第三方的jar包做处理(不处理就直接复制过去)怎么办?又或者我只想某个时候去处理第三方jar包,某些时候又不想,这个时候我们就必须让我们的插件可以配置了。很简单,分两步走:

3.1 定义配置类

class MethodTraceExtension {
    /**
     * 是否追踪第三方依赖的方法执行数据
     */

    boolean traceThirdLibrary = false
    boolean getTraceThirdLibrary() 
{
        return traceThirdLibrary

    void setTraceThirdLibrary(boolean traceThirdLibrary) 
{
        this.traceThirdLibrary = traceThirdLibrary
    }
}

3.2 注册配置

这里我们要在自定义的Plugin中注册

class MethodTracePlugin implements Plugin<Project{

    @Override
    void apply(Project project) {
         project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
         ……
    }
}

注册后,我们可以在引入了这个插件的build文件中做出如下配置

apply plugin: 'com.jianglei.method-tracer'
……
methodTrace{
    traceThirdLibrary = false
}

3.3 获取配置

获取配置很简单,只要用如下代码就可以了:

        //获取配置信息
        MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)

问题是你什么时候获取这个配置信息呢?刚开始,我在注册这个配置后直接去获取:

 @Override
    void apply(Project project) {
          //注册配置
         project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
         //获取配置信息
        MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)

         ……
    }

我希望在这里获取配置然后传入到Transform中去,事实上这是不可取的,此处的apply方法被调用时机是
apply plugin代码被调用的时候,此时,我们在build.gradle中的配置代码快还没有被调用,所以是取不到我们想要的配置的,取到的都是默认值。

那么到底我们应该怎么获取呢?其实我们只要在transform()方法中获取就可以了,这个时候build.gradle中配置的代码已经执行过了:

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {

        //获取配置信息
        MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)
         ……
    }

4. 优化

现在,我们有了一个可配置的插件去修改所有的class文件了,功能上的需求我们已经完成了,但是,性能上够了吗?

4.1 gradle插件应该在application模块引入还是library模块引入?

目前,我们的插件都是直接在application模块中引入的,那么多模块情况下怎么办?每个模块都要引入吗?可以只在主模块引入吗?应该只在主模块引入吗?

4.1.1 只在主模块引入

我们知道,butterknife是需要在每个模块都引入的,其实,对于多模块来说,我们完全可以只在application主模块中引入插件,这里要注意Transform中的getScopes()方法:

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        //此次是只允许在主module(build.gradle中含有com.android.application插件)
        //所以我们需要修改所有的module
        return TransformManager.SCOPE_FULL_PROJECT

    }

这里的SCOPE_FULL_PROJECT其实是这样的:

        SCOPE_FULL_PROJECT = Sets.immutableEnumSet(Scope.PROJECT, new Scope[]{Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES});

说明这里处理的模块包括本模块,子模块以及第三方jar包,这样我们就能在主模块中处理所有的class文件了,可见我们是可以只在主模块中引入的,这样做的话,所有子模块会以jar包的形式作为输入。

4.1.2 在每个模块都引入

那么如果想要在每个module中都引入该如何做呢?

首先是注册方式要修改:

    @Override
    void apply(Project project) {
        project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
        def extension = project.getExtensions().findByType(AppExtension.class)
        def isForApplication = true
        if (extension == null) 
{
            //说明当前使用在library中
            extension = project.getExtensions().findByType(LibraryExtension.class)
            isForApplication = false
        }
        extension.registerTransform(new MethodTraceTransform(project,isForApplication))

    }

关键是我们在Transform中要记录当前是应用于主模块还是子模块了。
这种模式下,每一个模块都会执行自己的transform()方法,所以这里的getScopes()方法要做些修改:

 @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        def scopes = new HashSet()
        scopes.add(QualifiedContent.Scope.PROJECT)
        if (isForApplication) {
            //application module中加入此项可以处理第三方jar包
            scopes.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
        }
        return scopes
    }

这里对于主模块的情况下应该额外处理第三方jar包,子模块只要处理自己的项目代码即可。

其实进过实验,所有子模块的依赖的第三方jar包只会在处理主模块中输入,换句话说子模块是永远不可能处理第三方jar包的。

4.1.3 小结

两种方式都是可以的,那么到底该选那种呢?有什么选择依据吗?从上面的介绍来看没有,而且应用所有子module的方式编写起来似乎还要复杂一点,那是不是应该选择只在主模块引入插件呢?其实不然,最大的区别下面会讲到,到时候自然有结果。

4.2 如何增量编译

通过上面的介绍,完成一个插件已经不是问题,但是这里有一个问题,每次编译时,transform()方法都会执行,我们会遍历所有的class文件,会解压缩所有jar文件,然后重新压缩成所有jar文件,但事实上,一次编译有可能只改动了一个class文件,我们能不能做到只重新修改这一个class文件呢?gradle其实是提供了方法的。

4.2.1 gradle transform的增量机制

transform-api将输入文件分成了两类:

DirectoryInput,包装的是源码对应的class文件,长这样:

public interface DirectoryInput extends QualifiedContent {
    Map<File, Status> getChangedFiles();
}

换句话说,我们可以通过以下方式获取改动的class文件:

 input.directoryInputs.each { directoryInput ->
    directoryInput.changedFiles.each{changeFileEntry->
        def status = changeFileEntry.value;
    }
 }

这样我们可以遍历所有改动的文件,而且可以获取每个改动文件的状态,有4种:

public enum Status {
    NOTCHANGED,
    ADDED,
    CHANGED,
    REMOVED;

    private Status() {
    }
}

第一次编译或clean后重新编译directory.changedFiles为空,需要做好区分
经测试,删除一个java文件,对应的class文件输入不会出现REMOVED状态,也就是不能从changeFiles里面获取被删除的文件

JarInput  和DirectoryInput不同,JarInput只能获取状态,也有4种状态:

public interface JarInput extends QualifiedContent {
    Status getStatus();
}

也就是说,我们如果想要增量编译,应该处理所有非 Status.NOTCHANGED状态的jar包,同样如果移除了一个依赖,这个jar包就再也不会输入,自然也就不会出现Status.REMOVED状态的jar包了。

4.2.2 增量编译要解决的问题

有了以上对gradle transform增量机制的了解,相信大家都对如何支持增量编译有了一个基本的了解,但是想要开发一个健壮的、支持增量的插件还有很多问题要解决,我们一一探讨。

4.2.2.1 如何区分未编译和未修改

之前提到,对于DirectoryInput来说,未编译或clean后重新编译时Directory.changedFiles为空,未修改时也是为空,前一种状态下我们需要处理所有的文件,后一种状态下又不应该处理任何文件,同样,JarInput也面对这个问题,要解决也很简单,这里给出一种简单方案,首次编译时生成一个标记文件,下一次编译时,如果修改文件为空,我们判断该标记文件是否存在,存在就是未修改,否则就是首次或clean后重新编译。当然上次编译也会有文件输出,我们可以直接拿任一输出文件做这个标记文件。

4.2.2.2 如何解决增量编译时包重复问题
一般来说,如果我们依赖了一个第三方jar包,比如:

    implementation "commons-io:commons-io:2.4"

首次编译会在编译输出目录下生成一个文件,比如:

/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/32.jar

现在我们注释掉这个包的引入,重新编译,之前我们提过,删除了一个jar包引用后我们是收不到任何信息的,无法对这个包做任何处理,因为它根本就不会被输入,那么自然这个32.jar还在那里,这个时候我们在重新引入刚才被移除的依赖,这个时候生成的文件变成了:

/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/33.jar

这个时候问题就来了,32.jar和33.jar其实是一个jar包,编译时自然会出现类冲突,而且这个冲突还比较尴尬,不好排查,因为gradle文件是没有任何问题的,最简单的方法就是clean后重新编译,这个问题自然不存在了,但一般开发者是没有这个意识的,这样做也太麻烦了,删掉一个依赖再重新引入是很正常操作,为什么非要先clean呢?

现在,我们来看下解决方案:
解决思路很简单,要是我们能够找到此次编译时哪些jar包被删除了,我们自己手动删除该jar包上次编译的输出文件不就解决了冲突问题吗?所以我们完全可以自己记录下每次编译时有哪些jar包参与了编译,并且输出到了哪里,如下:

{
        "commons-io:commons-io:2.4""/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/5.jar",
        ……    
}

那么此次编译时,我们读取上次的文件,对比两次参与编译的jar包,如果有删除的,我们自己删除该jar包对应的输出文件即可。

4.2.2.4 如何判断配置文件改变
和上面的class文件改变或jar改变都不一样,配置文件改变transform是得不到任何额外的信息的,但你不能不处理,比如说上次配置文件定义如下:

methodTrace{
    traceThirdLibrary = false
}

编译后自然不会处理第三方的jar包,但现在将其改成了false , 这个时候,上次编译的所有结果都要重来,因为这次需要处理第三方jar包了。解决方案也很简单,既然gradle没有通知我们配置文件改变了,我们自己记录上次配置文件,和本次编译对比,如果配置文件改变就全部重来,这个时候记录的文件就变成了这样:

{
  "extension": {
    "traceThirdLibrary"true
  },
  "jarMap": {
     "commons-io:commons-io:2.4""/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/5.jar",
      ……
   }
}

这里有一个问题没有解决,即使是自己对比配置文件是否改变 ,这些代码都是写在transform()方法中的,如果此次编译只是修改配置文件,没有修改任何东西,gradle认为你什么都没有改动,直接不调用transform()方法了,这个就意味着你给了配置文件增量编译不生效,暂时没有好的解决方案,只能重新CLEAN,或者修改其他的java文件,都能触发重新编译。如果大家有更好的解决方案,希望能指出来。

5. 查缺补漏

之前我们还有一个问题没有解决,那就是gradle插件到底是应该只在主module中引入还是再所有的module中都引入。在我看来,衡量的关键点就是编译速度,如果只在主module中引入的话,子module其实是以jar包的形式作为输入文件来 处理的,这样我们就算只修改了子module中一个文件,我们都需要将整个jar解压,然后处理该jar中的所有class文件,最后还得压缩一次,多做了无用功;如果我们放在所有的module中引入的话,针对这种情况我们只需要处理改动的class文件即可,能节省很多时间,所以我推荐放到所有module引入。

6. 总结

有了上面的知识,我相信大家应该都能开发出一个健壮的、支持增量编译的插件了,然后你就能利用字节码插桩技术为所欲为了,上面这些源码大家可以点这里:https://github.com/FamliarMan/ASMStudy , 当然这些都是我自己瞎琢磨出来的,网上似乎没有查到相关资料,如果有错误,恳请指正,不甚感激!

推荐阅读


如果你想要跟大家分享你的文章,

以上是关于如何开发一款高性能的 gradle transform的主要内容,如果未能解决你的问题,请参考以下文章

Gradle 简介

手把手完成商业级社交App开发 进阶Android高级工程师

# Gradle 在开发中的使用

如何针对构建持续时间和 RAM 使用优化 gradle 构建性能?

Android Studio怎么用

开源了!腾讯推出一款高性能 RPC 开发框架