如何理解 Transform API

概述

Starting with 1.5.0-beta1, the Gradle plugin includes a Transform API allowing 3rd party plugins to manipulate compiled class files before they are converted to dex files.
(The API existed in 1.4.0-beta2 but it's been completely revamped in 1.5.0-beta1)

摘自 Android Studio Project Site

Android Gradle 工具在 1.5.0 版本后提供了 Transfrom API, 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件。目前 jarMerge、proguard、multi-dex、Instant-Run 都已经换成 Transform 实现。

分析

从官方的描述中得知:

  1. Transform API 是新引进的操作 class 的方式
  2. Transform API 在编译之后,生成 dex 之前起作用

在翻查文档以及结合之前自己实现 Plugin 的经验,想到的几个问题:

  1. Transform 是如何拿到 class 文件的?
  2. Transform 与 Gradle Task 之间的关系?
  3. 为什么 Transform 的作用域在编译之后, 生成 Dex 之前,Gradle 是如何控制的?
  4. 既然 Instant-Run 使用 Transform 实现,那 Transform 是如何得到变更的内容的?
  5. Transform 之间的依赖关系是怎样的?

Transform

在解答问题之前,先看下 Transform 长什么样:

public class TestTransform extends Transform {
    @Override
    public String getName() {
        return null;
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return null;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return null;
    }

    @Override
    public boolean isIncremental() {
        return false;
    }

    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
    }
}

name: 给 transform 起个名字。 这个 name 并不是最终的名字, 在 TransformManager 中会对名字再处理:


    static String getTaskNamePrefix(Transform transform) {
        StringBuilder sb = new StringBuilder(100);
        sb.append("transform");
        sb.append((String)transform.getInputTypes().stream().map((inputType) -> {
            return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, inputType.name());
        }).sorted().collect(Collectors.joining("And"))).append("With").append(StringHelper.capitalize(transform.getName())).append("For");
        return sb.toString();
    }

inputTypes: transform 要处理的数据类型。

  • CLASSES 表示要处理编译后的字节码,可能是 jar 包也可能是目录

  • RESOURCES 表示处理标准的 java 资源

scopes:transform 的作用域

type Des
PROJECT 只处理当前项目
SUB_PROJECTS 只处理子项目
PROJECT_LOCAL_DEPS 只处理当前项目的本地依赖,例如jar, aar
EXTERNAL_LIBRARIES 只处理外部的依赖库
PROVIDED_ONLY 只处理本地或远程以provided形式引入的依赖库
TESTED_CODE 测试代码

ContentType 和 Scopes 都返回集合,TransformManager 中封装了默认的几种集中类型

** isIncremental** : 当前 Transform 是否支持增量编译

Transform 的工作流程

image.png

Transform 将输入进行处理,然后写入到指定的目录下作为下一个 Transform 的输入源。

获取输出路径:

destDir = transformInvocation.outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY)

案例解读

Metis 是一个 Android 的 SPI 实现,解决运行时获取指定的服务类型。
主要原理是用注解标记指定的类型,插件在编译过程中扫描所有的 class;对被注解标记过的类动态生成一个 java 源文件,再将 java 文件编译之后会被打包进 dex; 运行时只要调用工具类的方法执行查询操作即可。
动态生成的源文件:

final class MetisRegistry {
  private static final Map<Class<?>, HashSet<Class<?>>> sServices = new LinkedHashMap<Class<?>, HashSet<Class<?>>>();

  static {
    register(io.github.yangxiaolei.sub.TestAction.class, io.github.yangxiaolei.sub.TestAction1.class);
    register(io.github.yangxiaolei.sub.TestAction.class, io.github.yangxlei.TestAction3.class);
    register(io.github.yangxlei.TestAction3.class, io.github.yangxlei.MainActivity.class);
  }

  static final Set<Class<?>> get(Class<?> key) {
    Set<Class<?>> result = sServices.get(key);
    return null == result ? Collections.<Class<?>>emptySet() : Collections.unmodifiableSet(result);
  }

  private static final void register(Class key, Class<?> value) {
    HashSet<Class<?>> result = sServices.get(key);
    if (result == null) {
      result = new HashSet<Class<?>>();
      sServices.put(key, result);
    }
    result.add(value);
  }
}

1. 如何获取 class 文件

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

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

    @Override
    public boolean isIncremental() {
        return true;
    }

配置 Transform 的输入类型为 Class, 作用域为全工程。 这样在 transform(TransformInvocation transformInvocation) 方法中, transformInvocation.inputs 会传入工程内所有的 class 文件。

inputs 包含两个部分:

public interface TransformInput {
    Collection<JarInput> getJarInputs();

    Collection<DirectoryInput> getDirectoryInputs();
}

看接口方法可知,包含了 jar 包和目录。子 module 的 java 文件在编译过程中也会生成一个 jar 包然后编译到主工程中。
app/build 的目录下可以看到 class 分别在 folders 和 jars 两个目录下:

2. Transform 与 Gradle Task 之间的关系?

Gradle 包中有一个 TransformManager 的类,用来管理所有的 Transform。 在里面找到了这样的代码:


   public <T extends Transform> Optional<AndroidTask<TransformTask>> addTransform(TaskFactory taskFactory, TransformVariantScope scope, T transform, ConfigActionCallback<T> callback) {
               ...
               this.transforms.add(transform);
               AndroidTask task1 = this.taskRegistry.create(taskFactory, new ConfigAction(scope.getFullVariantName(), taskName, transform, inputStreams, referencedStreams, outputStream, this.recorder, callback));
               ...
               return Optional.ofNullable(task1);
           }
       }
   }

addTransform 方法在执行过程中,会将 Transform 包装成一个 AndroidTask 对象。
所以可以理解为一个 Transform 就是一个 Task

3. Gradle 是如何控制 Transform 的作用域的?

还是在 Gradle 的包中有一个 TaskManager 类,管理所有的 Task 执行。 其中有一个方法:


    public void createPostCompilationTasks(TaskFactory tasks, VariantScope variantScope) {
        ...
        List customTransforms = extension.getTransforms();
        List customTransformsDependencies = extension.getTransformsDependencies();
        int preColdSwapTask = 0;

        for(int multiDexClassListTask = customTransforms.size(); preColdSwapTask < multiDexClassListTask; ++preColdSwapTask) {
            Transform dexOptions = (Transform)customTransforms.get(preColdSwapTask);
            List dexTransform = (List)customTransformsDependencies.get(preColdSwapTask);
            transformManager.addTransform(tasks, variantScope, dexOptions).ifPresent((t) -> {
                if(!dexTransform.isEmpty()) {
                    t.dependsOn(tasks, dexTransform);
                }

                if(dexOptions.getScopes().isEmpty()) {
                    variantScope.getAssembleTask().dependsOn(tasks, t);
                }

            });
        }
        ...
    }

该方法在 javaCompile 之后调用, 会遍历所有的 transform,然后一一添加进 TransformManager。 加完自定义的 Transform 之后,再添加 Proguard, JarMergeTransform, MultiDex, Dex 等 Transform。

postCompilation 的调用:

   if(jackOptions1.isEnabled().booleanValue()) {
            javacTask = this.createJackTask(tasks, variantScope, true);
            setJavaCompilerTask(javacTask, tasks, variantScope);
        } else {
            javacTask = this.createJavacTask(tasks, variantScope);
            addJavacClassesStream(variantScope);
            setJavaCompilerTask(javacTask, tasks, variantScope);
            this.createPostCompilationTasks(tasks, variantScope);
        }

调用时判断是使用 jack 编译还是 javac 编译。 javac 编译完之后再组装 Transform。
看了源码之后,也可以回答 Transform 之间的依赖关系:

  • 因为是遍历 List 顺序添加的,所以可以在 Plugin 中通过先后顺序一一添加
  • registerTransform 方法第二个参数是 dependsOn, 可以手动设置依赖关系

4. 如何得到文件的增量

再回到 TransformInput 这个接口,输入源分为 JarInput 和 DirectoryInput


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

Status 是一个枚举:

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

所以在输入源中, 获取了 JarInput 的对象时,可以同时得到每个 jar 的变更状态。
需要注意的是:比如先 clean 再编译时, jar 的状态是 NOTCHANGED

再看看 DirectoryInput:

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

changedFiles 是一个 Map,其中会包含所有变更后的文件,以及每个文件对应的状态。
同样需要注意的是:先 clean 再编译时, changedFiles 是空的。

所以在处理增量时,只需要根据每个文件的状态进行相应的处理即可,不需要每次所有流程都重新来一遍。

踩了的坑

Transform 是用来处理 class 文件的, 但是在 Metis 的实现时,需要生成 java 源文件,再将 java 文件编译一下。
之前的实现方式是:

  • 创建一个 generateSourceCode 的 task,依赖 JavaCompile, 这样可以在整体编译完成之后拿到所有的 class 文件
  • 再创建一个 compileSourceCode 的 task,在 generateSourceCode
    执行完成后编译动态生成的 java 源码

但是现在 Transform 并不是原生的 task, 没有找到合适的办法让 task 依赖 transfrom(谁要是有好办法告诉我~~ )。

现在的解决办法是在 MetisTransform 生成完 java 源文件之后,主动调用 javac 来编译文件。

然后开始了踩坑之旅。。

1. 怎么得到 sourceCompatibility & targetCompatibility 版本

调用 javac 需要兼容指定的版本,sourceCompatibility 和 targetCompatibility 有时候会配置,有时候不会配置会有默认值。但是在 Transform 如何得到这两个值呢?
翻查源码时找到了 JavaCompile 包含这两个属性,所以只要能找到 JavaCompile 这个 task,就能得到这两个值:

        def sourceCompatibility
        def targetCompatibility
        def bootClasspath
        mProject.tasks.each { task ->
            if (AbstractCompile.isAssignableFrom(task.class)) {
                sourceCompatibility = task.sourceCompatibility
                targetCompatibility = task.targetCompatibility
            }

            if (JavaCompile.isAssignableFrom(task.class)) {
                bootClasspath = task.options.bootClasspath
            }
        }

bootClassPath 的值获取采用同样的方法。

2. javac 在哪?

不同的系统 javac 的配置是不一样的。在 bash 环境下可以通过

which javac

获取到 javac 的路径。
在 Project 类中找到一个 exec 的方法,用来执行命令

    def getJavac() {
        def stdOut = new ByteArrayOutputStream()
        mProject.exec {
            commandLine 'which'
            args 'javac'
            standardOutput = stdOut
        }

        return stdOut.toString().trim()
    }

**一定要 trim() !!! **

3. commandLine 的坑

到这正常应该已经没有问题了,只需要再调用 exec 执行 javac 命令就可以了。但是...
javac 的命令在程序中是一个变量, 正常代码会是这样:

def javac = getJavac()
 mProject.exec {
            commandLine javac
            args "xxx", "xxx", "xxx"
        }

然后就报异常: command property is null!
但是 commandLine 后面直接配置 '/usr/bin/javac' 能编译成功。我也不知道为什么。。 谁要是知道一定要告诉我!!

最后通过曲线救国, 将 javac 命令写入到一个 shell 文件中,然后再 exec 中执行一个 shell 脚本。

  def generateCompileShell(tempDir, javac, sourceCompatibility, targetCompatibility, sourceFile, destDir, bootClasspath, classpaths) {
       def shellFile = new File(tempDir, "compileMetisShell.sh")
       if (shellFile.exists()) shellFile.delete()

       shellFile.append("#!/bin/sh")

       shellFile.append("\n")

       shellFile.append("${javac} -source ${sourceCompatibility} -target ${targetCompatibility} ${sourceFile} -d ${destDir}")

       shellFile.append(" -bootclasspath ${bootClasspath}")

       shellFile.append(" -classpath ")

       classpaths.each { classpath ->
           shellFile.append("${classpath}:")
       }

       return shellFile
   }
 ExecResult result = mProject.exec {
            executable 'sh'
            args shell.absolutePath
        }

后记

这次使用 Transform api 重新实现 Metis 的插件工具,翻查了很多文档,但是很少有对 transform 讲的很详细。一步一步摸索出来感觉收获良多。

下一步准备将一个 AppRouter 的库使用 tranform 重构一下,比 Metis 要更复杂一点。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容