我們先來看下Android應用程序打包流程:
通過上圖可知,我們只要在圖中紅色箭頭處攔截(生成class文件之后,dex文件之前),就可以拿到當前應用程序中所有的.class文件,再去借助ASM之類的庫,就可以遍歷這些.class文件中所有方法,再根據一定的條件找到需要的目標方法,最后進行修改并保存,就可以插入我們的埋點代碼。
Google從 Android Gradle 1.5.0 開始,提供了Transform API。通過Transform API,允許第三方以插件的形式,在Android應用程序打包成dex文件之前的編譯過程中操作.class文件。我們只要實現一套Transform,去遍歷所有.class文件的所有方法,然后進行修改(在特定的listener回調中插入埋點代碼),再對源文件進行替換,即可以達到插入代碼的目的。
Gradle Transform概述
Gradle Transform是Android官方提供給開發者在項目構建階段(.class -> .dex轉換期間)用來修改.class文件的一套標準API,即把輸入的.class文件轉變成目標字節碼文件。目前比較經典的應用是字節碼插樁、代碼注入等。
我們build一個項目,會打印出如下日志,紅框框住的部分就是一個Transform的名稱
通過上張圖可以看到原生就帶了一系列Transform供使用,那么這些Transform是怎么組織在一起的呢?
每個Transform其實都是一個gradle task,Android編譯器中的TaskManager將每個Transform串連起來,第一個Transform接收來自javac編譯的結果,以及已經拉取到在本地的第三方依賴(jar、aar),還有resource資源,注意,這里的resource并非android項目中的res資源,而是asset目錄下的資源。 這些編譯的中間產物,在Transform組成的鏈條上流動,每個Transform節點可以對class進行處理再傳遞給下一個Transform。我們常見的混淆,Desugar等邏輯,它們的實現如今都是封裝在一個個Transform中,而我們自定義的Transform,會插入到這個Transform鏈條的最前面。
最終,我們定義的Transform會被轉化成一個個TransformTask,在Gradle編譯時調用。
Transform兩個基礎概念
- TransformInput
- TransformOutputProvider
TransformInput
TransformInput是指輸入文件的一個抽象,包括:
DirectoryInput集合
是指以源碼的方式參與項目編譯的所有目錄結構及其目錄下的源碼文件JarInput集合
是指以jar包方式參與項目編譯的所有本地jar包和遠程jar包(此處的jar包包括aar)
TransformOutputProvider
之Transform的輸出,通過它可以獲取到輸出路徑等信息
Transform.java
先來了解下Transform類,定義如下
public abstract class Transform {
public Transform() {
}
// Transform名稱
public abstract String getName();
public abstract Set<ContentType> getInputTypes();
public Set<ContentType> getOutputTypes() {
return this.getInputTypes();
}
public abstract Set<? super Scope> getScopes();
public abstract boolean isIncremental();
/** @deprecated */
@Deprecated
public void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
}
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
this.transform(transformInvocation.getContext(), transformInvocation.getInputs(), transformInvocation.getReferencedInputs(), transformInvocation.getOutputProvider(), transformInvocation.isIncremental());
}
public boolean isCacheable() {
return false;
}
...
}
Transform#getName()
Transform名稱,上面build日志紅框框住的部分就是Transform名稱
transformClassesWithDexBuilderForDebug
那么最終的名字是如何構成的呢?
在gradle plugin的源碼中有一個叫TransformManager的類,這個類管理著所有的Transform的子類,里面有一個方法叫getTaskNamePrefix,在這個方法中就是獲得Task的前綴,以transform開頭,之后拼接ContentType,這個ContentType代表著這個Transform的輸入文件的類型,類型主要有兩種,一種是Classes,另一種是Resources,ContentType之間使用And連接,拼接完成后加上With,之后緊跟的就是這個Transform的Name,name在getName()方法中重寫返回即可。TransformManager#getTaskNamePrefix()代碼如下:
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")));
sb.append("With");
StringHelper.appendCapitalized(sb, transform.getName());
sb.append("For");
return sb.toString();
}
Transform#getInputTypes()
需要處理的數據類型,有兩種枚舉類型
CLASSES
代表處理的 java 的 class 文件,返回TransformManager.CONTENT_CLASSRESOURCES
代表要處理 java 的資源,返回TransformManager.CONTENT_RESOURCES
Transform#getScopes()
指 Transform 要操作內容的范圍,官方文檔 Scope 有 7 種類型:
- EXTERNAL_LIBRARIES : 只有外部庫
- PROJECT : 只有項目內容
- PROJECT_LOCAL_DEPS : 只有項目的本地依賴(本地jar)
- PROVIDED_ONLY : 只提供本地或遠程依賴項
- SUB_PROJECTS : 只有子項目
- SUB_PROJECTS_LOCAL_DEPS: 只有子項目的本地依賴項(本地jar)
- TESTED_CODE :由當前變量(包括依賴項)測試的代碼
如果要處理所有的class字節碼,返回TransformManager.SCOPE_FULL_PROJECT
Transform#isIncremental()
增量編譯開關
當我們開啟增量編譯的時候,相當input包含了changed/removed/added三種狀態,實際上還有notchanged。需要做的操作如下:
- NOTCHANGED: 當前文件不需處理,甚至復制操作都不用;
- ADDED、CHANGED: 正常處理,輸出給下一個任務;
- REMOVED: 移除outputProvider獲取路徑對應的文件。
Transform#transform()
public void transform(@NonNull TransformInvocation transformInvocation)
throws TransformException, InterruptedException, IOException {
// Just delegate to old method, for code that uses the old API.
//noinspection deprecation
this.transform(transformInvocation.getContext(), transformInvocation.getInputs(),
transformInvocation.getReferencedInputs(),
transformInvocation.getOutputProvider(),
transformInvocation.isIncremental());
}
注意點
- 如果拿取了getInputs()的輸入進行消費,則transform后必須再輸出給下一級
- 如果拿取了getReferencedInputs()的輸入,則不應該被transform
- 是否增量編譯要以transformInvocation.isIncremental()為準
Transform#isCacheable()
如果我們的transform需要被緩存,則為true,它被TransformTask所用到
Transform編寫模板
AspectJTransform.groovy代碼如下:
class AspectJTransform extends Transform {
final String NAME = "JokerwanTransform"
@Override
String getName() {
return NAME
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
return false
}
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation)
// OutputProvider管理輸出路徑,如果消費型輸入為空,你會發現OutputProvider == null
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
transformInvocation.inputs.each { TransformInput input ->
input.jarInputs.each { JarInput jarInput ->
// 處理Jar
processJarInput(jarInput, outputProvider)
}
input.directoryInputs.each { DirectoryInput directoryInput ->
// 處理源碼文件
processDirectoryInputs(directoryInput, outputProvider)
}
}
}
void processJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {
File dest = outputProvider.getContentLocation(
jarInput.getFile().getAbsolutePath(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR)
// to do some transform
// 將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的了
FileUtils.copyFiley(jarInput.getFile(), dest)
}
void processDirectoryInputs(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY)
// 建立文件夾
FileUtils.forceMkdir(dest)
// to do some transform
// 將修改過的字節碼copy到dest,就可以實現編譯期間干預字節碼的目的了
FileUtils.copyDirectory(directoryInput.getFile(), dest)
}
}