概述
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)。
分析
從官方的描述中得知:
- Transform API 是新引進的操作 class 的方式
- Transform API 在編譯之后,生成 dex 之前起作用
在翻查文檔以及結(jié)合之前自己實現(xiàn) Plugin 的經(jīng)驗,想到的幾個問題:
- Transform 是如何拿到 class 文件的?
- Transform 與 Gradle Task 之間的關(guān)系?
- 為什么 Transform 的作用域在編譯之后, 生成 Dex 之前,Gradle 是如何控制的?
- 既然 Instant-Run 使用 Transform 實現(xiàn),那 Transform 是如何得到變更的內(nèi)容的?
- 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 的工作流程
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ù)雜一點。