Gradle 學習之 Android 插件的 Transform API

前言

咱們已經學習瞭如何自定義 Gradle 插件以及 Android 插件的基本知識。那咱們自定義 Gradle 插件用來幹什麼呢?總不能只是定義一些簡單 Task 吧,那就有點大材小用了。這個時候,Android 插件就派上用場了。由於,從 1.5.0-beta1 版本開始,Android 插件中包含了 Transform API ,它容許第三方插件在將編譯後的類文件轉換爲 dex 文件以前對其進行操做。java

本文主要學習 Transform API 的基本知識,而後藉助 javassist 來完成一個簡單的字節碼操做。android

初識 Transform API

先來看 Transform 類:git

public abstract class Transform 複製代碼

它是一個抽象類,自定義 Transform 時必須繼承 Transform 類,並實現它的幾個方法:github

getName 方法

public abstract String getName();
複製代碼

用於指明 Transform 的名字,也對應了該 Transform 所表明的 Task 名稱,例如:api

// 設置自定義的Transform對應的Task名稱
// 相似:transformClassesWithPreDexForXXX
// 這裏應該是:transformClassesWithInjectTransformForxxx
@Override
String getName() {
    return 'InjectTransform'
}
複製代碼

示例中給 Transform 取名:InjectTransform ,編譯運行後,能夠在 Android Studio 中查到生成的 Task 。併發

Transform任務名

getInputTypes 方法

public abstract Set<ContentType> getInputTypes();
複製代碼

用於指明 Transform 的輸入類型,能夠做爲輸入過濾的手段。在 TransformManager 類中定義了不少類型:app

// 表明 javac 編譯成的 class 文件,經常使用
public static final Set<ContentType> CONTENT_CLASS;
public static final Set<ContentType> CONTENT_JARS;
// 這裏的 resources 單指 java 的資源
public static final Set<ContentType> CONTENT_RESOURCES;
public static final Set<ContentType> CONTENT_NATIVE_LIBS;
public static final Set<ContentType> CONTENT_DEX;
public static final Set<ContentType> CONTENT_DEX_WITH_RESOURCES;
public static final Set<ContentType> DATA_BINDING_BASE_CLASS_LOG_ARTIFACT;
複製代碼

其中,不少類型是不容許自定義 Transform 來處理的,咱們常使用 CONTENT_CLASS 來操做 Class 文件。ide

getScopes 方法

public abstract Set<? super Scope> getScopes();
複製代碼

用於指明 Transform 的做用域。一樣,在 TransformManager 類中定義了幾種範圍:函數

// 注意,不一樣版本值不同
public static final Set<Scope> EMPTY_SCOPES = ImmutableSet.of();
public static final Set<ScopeType> PROJECT_ONLY;
public static final Set<Scope> SCOPE_FULL_PROJECT; // 經常使用
public static final Set<ScopeType> SCOPE_FULL_WITH_IR_FOR_DEXING;
public static final Set<ScopeType> SCOPE_FULL_WITH_FEATURES;
public static final Set<ScopeType> SCOPE_FULL_WITH_IR_AND_FEATURES;
public static final Set<ScopeType> SCOPE_FEATURES;
public static final Set<ScopeType> SCOPE_FULL_LIBRARY_WITH_LOCAL_JARS;
public static final Set<ScopeType> SCOPE_IR_FOR_SLICING;
複製代碼

經常使用的是 SCOPE_FULL_PROJECT ,表明全部 Project 。工具

肯定了 ContentType 和 Scope 後就肯定了該自定義 Transform 須要處理的資源流。好比 CONTENT_CLASS 和 SCOPE_FULL_PROJECT 表示了全部項目中 java 編譯成的 class 組成的資源流。

isIncremental 方法

public abstract boolean isIncremental();
複製代碼

指明該 Transform 是否支持增量編譯。須要注意的是,即便返回了 true ,在某些狀況下運行時,它仍是會返回 false 的。

transform 方法

/** @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());
    }
複製代碼

重寫任意一個方法便可。其中,inputs 是該 Transform 要消費的輸入流,有兩種格式:jar 和目錄格式;referencedInputs 集合僅供參考,不該進行轉換,它是受 getReferencedScopes 方法控制的;outputProvider 是用來獲取輸出目錄的,咱們要將操做後的文件複製到輸出目錄中。

TransformInput 類

一個簡單的接口類:

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

    Collection<DirectoryInput> getDirectoryInputs();
}
複製代碼

所謂 Transform 就是對輸入的 class 文件轉變成目標字節碼文件,TransformInput 就是這些輸入文件的抽象。目前它包括兩部分:DirectoryInput 集合與 JarInput 集合。

DirectoryInput 表明以源碼方式參與項目編譯的全部目錄結構及其目錄下的源碼文件,能夠藉助於它來修改輸出文件的目錄結構以及目標字節碼文件。

JarInput 表明以 jar 包方式參與項目編譯的全部本地 jar 包或遠程 jar 包,能夠藉助它來動態添加 jar 包。

TransformOutputProvider 類

也是一個簡單的接口:

public interface TransformOutputProvider {
    void deleteAll() throws IOException;

    File getContentLocation(String var1, Set<ContentType> var2, Set<? super Scope> var3, Format var4);
}
複製代碼

調用 getContentLocation 獲取輸出目錄,例如:

// 獲取輸出目錄
def dest = outputProvider.getContentLocation(directoryInput.name,
               directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
複製代碼

Transform 的工做原理

直接來看圖:

Transform原理圖

很明顯的一個鏈式結構。其中,紅色的 Transform 表明自定義 Transform ,藍色的表明系統的 Transform 。

每一個 Transform 其實都是一個 Gradle 的 Task , Android 編譯器中的 TaskManager 會將每一個 Transform 串聯起來。第一個 Transform 接收來自 javac 編譯的結果,以及拉取到本地的第三方依賴和 resource 資源。這些編譯的中間產物在 Transform 鏈上流動,每一個 Transform 節點均可以對 class 進行處理再傳遞到下一個 Transform 。咱們自定義的 Transform 會插入到鏈的最前面,能夠在 TaskManager 類的 createPostCompilationTasks 方法中找到相關邏輯:

public void createPostCompilationTasks(VariantScope variantScope) {
    ...
    TransformManager transformManager = variantScope.getTransformManager();
    ...
    // 獲取自定義 Transform 列表
    List<Transform> customTransforms = extension.getTransforms();
    List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();
    int i = 0;
	// 循環添加
    for(int count = customTransforms.size(); i < count; ++i) {
        Transform transform = (Transform)customTransforms.get(i);
        List<Object> deps = (List)customTransformsDependencies.get(i);
        transformManager.addTransform(this.taskFactory, variantScope, transform, (PreConfigAction)null, (taskx) -> {
            if (!deps.isEmpty()) {
                taskx.dependsOn(new Object[]{deps});
            }

        }, (taskProvider) -> {
            if (transform.getScopes().isEmpty()) {
                    TaskFactoryUtils.dependsOn(variantScope.getTaskContainer().getAssembleTask(), taskProvider);
            }

        });
    }
}
複製代碼

以上是 Transform 的數據流動原理,下面再說下 Transform 的輸入數據的過濾機制。

Transform 的數據輸入 key 經過 Scope 和 ContentType 兩個維度進行過濾。ContentType 就是數據類型,在開發中通常只能使用 CLASSES 和 RESOURCES 兩種類型,這裏的 CLASSES 已經包含了 class 文件和 jar 包。其餘的一些類型如 DEX 是留給 Android 編譯器的,咱們沒法使用。至於 Scope ,開發可用的相對較多(詳細見 TransformManager 類),處理 class 字節碼時通常使用 SCOPE_FULL_PROJECT 。

Javassist 操做字節碼

說完了 Transform 的理論,咱們來實際操做一下,編寫自定義 Transform 來給類文件插入一行代碼。

示例:

利用 Javassist 在 MainActivity 的 onCreate 方法的最後插入一行 Toast 語句。

步驟一:建立自定義插件 Module

參照以前的文章Gradle 學習之插件中的方法建立自定義插件便可,這裏直接給圖:

transformPlugin目錄結構

步驟二:引入 Transform API 和 Javassist 依賴

dependencies {
    ...
    compile 'com.android.tools.build:gradle:3.3.1'
    compile group: 'org.javassist', name: 'javassist', version: '3.22.0-GA'
}
複製代碼

Transform API 和 Javassist 須要單獨依賴,這裏直接依賴 gradle 是由於其包含的 API 會更加豐富。注意:Transform API 的依賴包經歷過修改,從 transform-api 改爲了 gradle-api ,你們能夠在 Jcenter 中找到相應版本。

步驟三:實現自定義 Transform

這裏直接貼代碼了,其 API 和原理已經在上文中說過了。

/**
 * 定義一個Transform
 */
class InjectTransform extends Transform {

    private Project mProject

    // 構造函數,咱們將Project保存下來備用
    InjectTransform(Project project) {
        this.mProject = project
    }

    // 設置咱們自定義的Transform對應的Task名稱
    // 相似:transformClassesWithPreDexForXXX
    // 這裏應該是:transformClassesWithInjectTransformForxxx
    @Override
    String getName() {
        return 'InjectTransform'
    }

    // 指定輸入的類型,經過這裏的設定,能夠指定咱們要處理的文件類型
    //  這樣確保其餘類型的文件不會傳入
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    // 指定Transform的做用範圍
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    // 當前Transform是否支持增量編譯
    @Override
    boolean isIncremental() {
        return false
    }

    // 核心方法
    // inputs是傳過來的輸入流,有兩種格式:jar和目錄格式
    // outputProvider 獲取輸出目錄,將修改的文件複製到輸出目錄,必須執行
    @Override
    void transform(Context context, Collection<TransformInput> inputs,
                   Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider,
                   boolean isIncremental) throws IOException, TransformException, InterruptedException {
        println '--------------------transform 開始-------------------'

        // Transform的inputs有兩種類型,一種是目錄,一種是jar包,要分開遍歷
        inputs.each {
            TransformInput input ->
                // 遍歷文件夾
                //文件夾裏面包含的是咱們手寫的類以及R.class、BuildConfig.class以及R$XXX.class等
                input.directoryInputs.each {
                    DirectoryInput directoryInput ->
                        // 注入代碼
                        MyInjectByJavassit.injectToast(directoryInput.file.absolutePath, mProject)

                        // 獲取輸出目錄
                        def dest = outputProvider.getContentLocation(directoryInput.name,
                                directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

                        println("directory output dest: $dest.absolutePath")
                        // 將input的目錄複製到output指定目錄
                        FileUtils.copyDirectory(directoryInput.file, dest)
                }

                //對類型爲jar文件的input進行遍歷
                input.jarInputs.each {
                        //jar文件通常是第三方依賴庫jar文件
                    JarInput jarInput ->
                        // 重命名輸出文件(同目錄copyFile會衝突)
                        def jarName = jarInput.name
                        println("jar: $jarInput.file.absolutePath")
                        def md5Name = DigestUtils.md5Hex(jarInput.file.absolutePath)
                        if (jarName.endsWith('.jar')) {
                            jarName = jarName.substring(0, jarName.length() - 4)
                        }
                        def dest = outputProvider.getContentLocation(jarName + md5Name, jarInput.contentTypes, jarInput.scopes, Format.JAR)

                        println("jar output dest: $dest.absolutePath")
                        FileUtils.copyFile(jarInput.file, dest)
                }
        }

        println '---------------------transform 結束-------------------'
    }
}
複製代碼

步驟四:使用 Javassist 實現代碼注入邏輯

/** * 藉助 Javassit 操做 Class 文件 */
class MyInjectByJavassit {

    private static final ClassPool sClassPool = ClassPool.getDefault()

    /** * 插入一段Toast代碼 * @param path * @param project */
    static void injectToast(String path, Project project) {
        // 加入當前路徑
        sClassPool.appendClassPath(path)
        // project.android.bootClasspath 加入android.jar,否則找不到android相關的全部類
        sClassPool.appendClassPath(project.android.bootClasspath[0].toString())
        // 引入android.os.Bundle包,由於onCreate方法參數有Bundle
        sClassPool.importPackage('android.os.Bundle')

        File dir = new File(path)
        if (dir.isDirectory()) {
            // 遍歷文件夾
            dir.eachFileRecurse { File file ->
                String filePath = file.absolutePath
                println("filePath: $filePath")

                if (file.name == 'MainActivity.class') {
                    // 獲取Class
                    // 這裏的MainActivity就在app模塊裏
                    CtClass ctClass = sClassPool.getCtClass('com.apm.windseeker.MainActivity')
                    println("ctClass: $ctClass")

                    // 解凍
                    if (ctClass.isFrozen()) {
                        ctClass.defrost()
                    }

                    // 獲取Method
                    CtMethod ctMethod = ctClass.getDeclaredMethod('onCreate')
                    println("ctMethod: $ctMethod")

                    String toastStr = """ android.widget.Toast.makeText(this,"我是被插入的Toast代碼~!!",android.widget.Toast.LENGTH_SHORT).show(); """

                    // 方法尾插入
                    ctMethod.insertAfter(toastStr)
                    ctClass.writeFile(path)
                    ctClass.detach() //釋放
                }
            }
        }
    }

}
複製代碼

步驟五:將 Transform 註冊到 Android 插件中

/** * 定義插件,加入Transform */
class TransformPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {

        // 獲取Android擴展
        def android = project.extensions.getByType(AppExtension)
        // 註冊Transform,其實就是添加了Task
        android.registerTransform(new InjectTransform(project))

        // 這裏只是隨便定義一個Task而已,和Transform無關
        project.task('JustTask') {
            doLast {
                println('InjectTransform task')
            }
        }

    }
}
複製代碼

這裏先經過 AppExtension 獲取 Android 擴展,而後調用 registerTransform 方法添加自定義的 Transform 。

步驟六:發佈插件並使用

/* 自定義插件:利用Transform向MainActivity中插入代碼 */
apply plugin: 'com.happy.customplugin.transform'
複製代碼

運行後,能夠在 build/intermediates/transforms 目錄下找到自定義的 Transform :

InjectTransform

這裏的 jar 包名字是數字遞增的,這是正常的,其命名邏輯能夠在 IntermediateFolderUtils 類的 getContentLocation 方法中找到。咱們直接看 MainActivity.class 文件:

修改後的MainActivity

能夠看到成功注入了一行 Toast 語句。運行 APP 也能正常彈出 Toast 。

Transform 的注意點

  1. 自定義 Transform 沒法處理 Dex ;
  2. 自定義 Transform 沒法使用自定義 Transform ;
  3. 可使用 isIncremental 來支持增量編譯以及併發處理來加快 Transform 編譯速度;
  4. Transform 只能在全局註冊,並將其應用於全部變體(variant)。

總結

Transform 簡單來看就是一個 Task ,只不過 Android 在這個 Task 中給咱們提供了一個修改 Class 字節碼的契機。咱們能夠根據本身的業務需求進行字節碼操做。文中利用 Javassist 寫的示例很簡單,像 APM 這種功能強大的 SDK ,它的字節碼處理邏輯會很複雜,可能會使用到更強大的 ASM 字節碼處理工具。

本文示例代碼已放在 zjxstar 的 GitHub

參考資料

  1. Android Gradle API
  2. Transform API 首頁
  3. Javassist官網
  4. 一塊兒玩轉Android項目中的字節碼(Transform篇)
  5. Android Gradle高級用法,動態編譯技術:Plugin Transform Javassist操做Class文件
相關文章
相關標籤/搜索