如何理解 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 都已經(jīng)換成 Transform 實現(xiàn)。

分析

從官方的描述中得知:

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

在翻查文檔以及結(jié)合之前自己實現(xiàn) Plugin 的經(jīng)驗,想到的幾個問題:

  1. Transform 是如何拿到 class 文件的?
  2. Transform 與 Gradle Task 之間的關(guān)系?
  3. 為什么 Transform 的作用域在編譯之后, 生成 Dex 之前,Gradle 是如何控制的?
  4. 既然 Instant-Run 使用 Transform 實現(xiàn),那 Transform 是如何得到變更的內(nèi)容的?
  5. Transform 之間的依賴關(guān)系是怎樣的?

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 要處理的數(shù)據(jù)類型。

  • CLASSES 表示要處理編譯后的字節(jié)碼,可能是 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 實現(xiàn),解決運行時獲取指定的服務(wù)類型。
主要原理是用注解標記指定的類型,插件在編譯過程中掃描所有的 class;對被注解標記過的類動態(tài)生成一個 java 源文件,再將 java 文件編譯之后會被打包進 dex; 運行時只要調(diào)用工具類的方法執(zhí)行查詢操作即可。
動態(tài)生成的源文件:

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 會傳入工程內(nèi)所有的 class 文件。

inputs 包含兩個部分:

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

    Collection<DirectoryInput> getDirectoryInputs();
}

看接口方法可知,包含了 jar 包和目錄。子 module 的 java 文件在編譯過程中也會生成一個 jar 包然后編譯到主工程中。
app/build 的目錄下可以看到 class 分別在 folders 和 jars 兩個目錄下:

2. Transform 與 Gradle Task 之間的關(guān)系?

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 方法在執(zhí)行過程中,會將 Transform 包裝成一個 AndroidTask 對象。
所以可以理解為一個 Transform 就是一個 Task

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

還是在 Gradle 的包中有一個 TaskManager 類,管理所有的 Task 執(zhí)行。 其中有一個方法:


    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 之后調(diào)用, 會遍歷所有的 transform,然后一一添加進 TransformManager。 加完自定義的 Transform 之后,再添加 Proguard, JarMergeTransform, MultiDex, Dex 等 Transform。

postCompilation 的調(diào)用:

   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);
        }

調(diào)用時判斷是使用 jack 編譯還是 javac 編譯。 javac 編譯完之后再組裝 Transform。
看了源碼之后,也可以回答 Transform 之間的依賴關(guān)系:

  • 因為是遍歷 List 順序添加的,所以可以在 Plugin 中通過先后順序一一添加
  • registerTransform 方法第二個參數(shù)是 dependsOn, 可以手動設(shè)置依賴關(guān)系

4. 如何得到文件的增量

再回到 TransformInput 這個接口,輸入源分為 JarInput 和 DirectoryInput


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

Status 是一個枚舉:

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

所以在輸入源中, 獲取了 JarInput 的對象時,可以同時得到每個 jar 的變更狀態(tài)。
需要注意的是:比如先 clean 再編譯時, jar 的狀態(tài)是 NOTCHANGED

再看看 DirectoryInput:

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

changedFiles 是一個 Map,其中會包含所有變更后的文件,以及每個文件對應(yīng)的狀態(tài)。
同樣需要注意的是:先 clean 再編譯時, changedFiles 是空的。

所以在處理增量時,只需要根據(jù)每個文件的狀態(tài)進行相應(yīng)的處理即可,不需要每次所有流程都重新來一遍。

踩了的坑

Transform 是用來處理 class 文件的, 但是在 Metis 的實現(xiàn)時,需要生成 java 源文件,再將 java 文件編譯一下。
之前的實現(xiàn)方式是:

  • 創(chuàng)建一個 generateSourceCode 的 task,依賴 JavaCompile, 這樣可以在整體編譯完成之后拿到所有的 class 文件
  • 再創(chuàng)建一個 compileSourceCode 的 task,在 generateSourceCode
    執(zhí)行完成后編譯動態(tài)生成的 java 源碼

但是現(xiàn)在 Transform 并不是原生的 task, 沒有找到合適的辦法讓 task 依賴 transfrom(誰要是有好辦法告訴我~~ )。

現(xiàn)在的解決辦法是在 MetisTransform 生成完 java 源文件之后,主動調(diào)用 javac 來編譯文件。

然后開始了踩坑之旅。。

1. 怎么得到 sourceCompatibility & targetCompatibility 版本

調(diào)用 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 在哪?

不同的系統(tǒng) javac 的配置是不一樣的。在 bash 環(huán)境下可以通過

which javac

獲取到 javac 的路徑。
在 Project 類中找到一個 exec 的方法,用來執(zhí)行命令

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

        return stdOut.toString().trim()
    }

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

3. commandLine 的坑

到這正常應(yīng)該已經(jīng)沒有問題了,只需要再調(diào)用 exec 執(zhí)行 javac 命令就可以了。但是...
javac 的命令在程序中是一個變量, 正常代碼會是這樣:

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

然后就報異常: command property is null!
但是 commandLine 后面直接配置 '/usr/bin/javac' 能編譯成功。我也不知道為什么。。 誰要是知道一定要告訴我!!

最后通過曲線救國, 將 javac 命令寫入到一個 shell 文件中,然后再 exec 中執(zhí)行一個 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 重新實現(xiàn) Metis 的插件工具,翻查了很多文檔,但是很少有對 transform 講的很詳細。一步一步摸索出來感覺收獲良多。

下一步準備將一個 AppRouter 的庫使用 tranform 重構(gòu)一下,比 Metis 要更復(fù)雜一點。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內(nèi)容