1. 程式人生 > >如何開發一款高效能的gradle transform

如何開發一款高效能的gradle transform

前言

對於java開發者來說,大家好像都比較喜歡在編譯期間搞事兒,比如為了做到AOP程式設計,大家都喜歡利用位元組碼生成技術,常用的有無痕埋點,方法耗時統計等等。那麼Android中具體是如何做到這些的呢?所謂位元組碼插樁技術,其實就是修改已經編譯的class檔案,往裡面新增自己的位元組碼,然後打包的時候打包的是修改後的class檔案。為了便捷的修改編譯後的class檔案,Google爸爸開發了一套gradle相關的庫,也就是gradle-transform-api,利用這個工具,我們可以自己實現class檔案修改,下面我們看看具體做法。

1.實現一個gradle Plugin

想要使用gradle-transform-api,我們必須要先實現一個gradle外掛,然後在外掛中註冊一個Transform,實現外掛有三種方式,這裡做下簡單介紹,詳細的請看 

官方文件

1.1 直接在build.gradle檔案中實現:

class GreetingPlugin implements Plugin<Project> {
    void apply(Project project) {
        project.task('hello') {
            doLast {
                println 'Hello from the GreetingPlugin'
            }
        }
    }
}

// Apply the plugin
apply plugin: GreetingPlugin

關鍵是實現Plugin<Project>介面

1.2 建立一個buildsrc模組

第一種方式不適合用來開發複雜的外掛,如果只是自己的專案需要,外掛又比較複雜,我們可以建立一個buildsrc模組,然後把上面的GreetingPlugin類移動到這個模組中,這個和下面另一種方式比較接近,這裡就不做詳細介紹了,有興趣的可以看官方文件。

1.3 單獨工程

建立一個自己的module或工程,這種方式是最常用的,可以看下目錄結構

├── pluginmodule
│   ├── build.gradle
│   └── src
│       └── main
│           ├── groovy
│           │   └── com
│           │       └── jianglei
│           │           └── plugin
│           │               ├── MethodTracePluginPlugin.groovy
│           └── resources
│               └── META-INF
│                   └── gradle-plugins
│                       └── com.jianglei.method-tracer.properties

其中,JlLogPlugin就是外掛實現者:

class MethodTracePlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {

         project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
        //確保只能在含有application的build.gradle檔案中引入
        if (!project.plugins.hasPlugin('com.android.application')) {
            throw new GradleException('Android Application plugin required')
        }
        project.getExtensions().findByType(AppExtension.class)
                .registerTransform(new MethodTraceTransform(project))

    }
}

另外,com.jianglei.method-tracer.properties用來宣告誰是外掛實現者,檔案的名字也就是你要引用時的名字

apply plugin: 'com.jianglei.jllog'

檔案裡面長這樣:

implementation-class=com.jianglei.plugin.MethodTracePlugin

2. 實現一個transform

我們在第一步中註冊了一個transform,這個transform能夠輸入編譯後的class檔案,然後我們處理class檔案,將修改後的檔案輸出。
程式碼很簡單:

class MethodTraceTransform extends Transform {
    private Project project
    MethodTraceTransform(Project project) {
        this.project = project
    }
    @Override
    String getName() {
        return "MethodTrace"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {

        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        //此次是隻允許在主module(build.gradle中含有com.android.application外掛)
        //所以我們需要修改所有的module
        return TransformManager.SCOPE_FULL_PROJECT

    }

    @Override
    boolean isIncremental() {
        return true
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, 
                          InterruptedException, IOException {
    
    }
}

實現transform的核心就是覆寫這幾個方法,我們一個個說明。

2.1 getName()

這個方法只是用來定義transform任務的名稱,隨意定一個就好。

2.2 getInputTypes()

這個用來限定這個transform能處理的檔案型別,一般來說我們要處理的都是class檔案,就返回TransformManager.CONTENT_CLASS,當然如果你是想要處理資原始檔,可以使用TransformManager.CONTENT_RESOURCES,這裡按需要來就好,還有其它配置就要檢視官網javadoc文件了,這裡需要科學上網。

2.3 getScopes()

2.2中我們指定的是要處理那種檔案,那麼,這裡我們要指定的的就是哪些檔案了。比如說我們如果想處理class檔案,但class檔案可以是當前module的,也可以是子module的,還可以是第三方jar包中的,這裡就是用來指定這個的,我們看下有哪些選項:

public static enum Scope implements QualifiedContent.ScopeType {
        PROJECT(1),
        SUB_PROJECTS(4),
        EXTERNAL_LIBRARIES(16),
        TESTED_CODE(32),
        PROVIDED_ONLY(64),
        /** @deprecated */
        @Deprecated
        PROJECT_LOCAL_DEPS(2),
        /** @deprecated */
        @Deprecated
        SUB_PROJECTS_LOCAL_DEPS(8);

        private final int value;

        private Scope(int value) {
            this.value = value;
        }

        public int getValue() {
            return this.value;
        }
    }

基本上從名字上也可以看出作用範圍了,當然具體怎麼選還是要注意些的,後面我們會介紹。

2.4 inIncremental()

是否支援增量編譯,按道理講,一個合格的transform應該支援增量編譯。

2.5 transform()

這個方法就是我們要處理的重點了,我們在這個方法中獲取輸入的class檔案,然後做些修改,最後輸出修改後的class檔案。主要也是分為三步走:

2.5.1 獲取輸入檔案

transformInvocation.inputs.each {input ->
         transformInvocation.inputs
                .each { input ->
           
            transformSrc(transformInvocation, input)
            transformJar(transformInvocation, input)
        }
        }

這裡的輸入檔案分為兩種, 一種是本module自己的src下的原始碼編譯後的class檔案,一種是第三方的jar包檔案,我們需要分開單獨處理。

2.5.2 獲取輸出路徑

輸入檔案有了,我們要先確定輸出路徑,這裡要注意,輸出路徑必須用特殊方式獲取,而不能自己隨意指定,否則下一個任務就無法獲取你這次的輸出檔案了,編譯失敗。
對於原始碼編譯的class檔案輸出路徑這樣獲取:

 def outputDirFile = transformInvocation.outputProvider.getContentLocation(
                    directoryInput.name, directoryInput.contentTypes, directoryInput.scopes,
                    Format.DIRECTORY
            )

對於jar包的輸出路徑這樣獲取:

 def outputFile = transformInvocation.outputProvider.getContentLocation(
                    jarInput.name, jarInput.contentTypes, jarInput.scopes,
                    Format.JAR
            )

2.5.3處理輸入檔案

經過上面的步驟,我們能獲取到輸入檔案,也確定了輸出路徑,現在我們只要來處理這些檔案,然後輸出到輸出路徑就可以了:
首先處理src下原始碼編譯生成的class檔案:

private void transformSrc(TransformInput input){
    input.directoryInputs.each { directoryInput ->
          //這裡是為了把所有目錄下的檔案存到一個list集合中
          def allFiles = DirectoryUtils.getAllFiles(directoryInput.file)
          for (File file : allFiles) {
                //比如上一個檔案輸入的全路徑是 /A/B/com/jianglei/test/Test.class,獲取的輸出路徑是
                // /transform/MethodTrace/debug,替換後就變成了/transform/MethodTrance/debug/com/jianglei/test/Test.class
                def outputFullPath = file.absolutePath.replace(inputFilePath, outputFilePath)
                def outputFile = new File(outputFullPath)
                 if (!outputFile.parentFile.exists()) {
                        outputFile.parentFile.mkdirs()
                 }
                  //這個方法中你可以盡情修改class檔案,然後輸出到outputFile中即可,
                  //就算不修改,至少也要將原有檔案拷貝過去
                  MethodTraceUtils.traceFile(file, outputFile)
   
           }
    }
}

註釋寫的很清楚,這裡注意,就算你不想修改這個class檔案,你也應該將它原樣拷貝過去,否則這個檔案就丟失了。

接著,我們處理jar檔案:

private void transformJar(TransformInvocation transformInvocation, TransformInput input,
                              boolean isIncrement, boolean isConfigChange,
                              Map<String, String> lastJarMap, Set<String> curJars, MethodTraceExtension extension) {

        for (JarInput jarInput : input.jarInputs) {
            def outputFile = transformInvocation.outputProvider.getContentLocation(
                    jarInput.name, jarInput.contentTypes, jarInput.scopes,
                    Format.JAR
            )
           //這個方法就是處理jar檔案,然後將處理後的jar檔案輸出到輸出目錄
           MethodTraceUtils.traceJar(jarInput, outputFile)
    }

上面其實就是遍歷每個jar檔案去處理,那麼具體如何處理的?

public static void traceJar(JarInput jarInput, File outputFile) {

        def jar = jarInput.file
        LogUtils.i("正在處理jar:" + jarInput.name)
        //jar包解壓的臨時位置
        def tmpDir = outputFile.parentFile.absolutePath + File.separator + outputFile
                .name.replace(".jar", File.separator)
        def tmpFile = new File(tmpDir)
        tmpFile.mkdirs()
        //先解壓縮到臨時目錄
        MyZipUtils.unzip(jar.absolutePath, tmpFile.absolutePath)
        //收集解壓縮後的所有檔案
        def allFiles = new ArrayList()
        collectFiles(tmpFile, allFiles)
        allFiles.each {
            if (isNeedTraceClass(it)) {
                //將處理後的檔案命名成原名稱-new形式
                def tracedFile = new File(tmpFile.absolutePath + "-new")
                //去修改單個class檔案
                traceFile(it, tracedFile)
                //處理完後用新的檔案替換原有檔案
                it.delete()
                tracedFile.renameTo(it)
            }
        }
        MyZipUtils.zip(tmpFile.absolutePath, outputFile.absolutePath)
        tmpFile.deleteDir()
    }

jar檔案和普通的class檔案相比多瞭解壓縮過程,解壓縮後我們就可以按照普通的class檔案一個個去處理,最後我們將處理後class資料夾重新壓縮到輸出目錄即可,這裡注意刪掉中間產生的解壓縮目錄即可。

2.5.4 小結

經過上面的步驟,可以說我們就成功的實現了一個gradle外掛,能夠攔截所有的class檔案,並且修改這些class檔案,成功做到了AOP程式設計,當然具體如果修改class檔案,這不在本文討論範圍內,大家可以自己去查詢ASM等技術。

3. 讓外掛可以配置

現在,我們的外掛開發完了,那這樣夠了嗎?比如說不想對第三方的jar包做處理(不處理就直接複製過去)怎麼辦?又或者我只想某個時候去處理第三方jar包,某些時候又不想,這個時候我們就必須讓我們的外掛可以配置了。很簡單,分兩步走:

3.1 定義配置類

class MethodTraceExtension {
    /**
     * 是否追蹤第三方依賴的方法執行資料
     */
    boolean traceThirdLibrary = false
    boolean getTraceThirdLibrary() {
        return traceThirdLibrary

    void setTraceThirdLibrary(boolean traceThirdLibrary) {
        this.traceThirdLibrary = traceThirdLibrary
    }
}

3.2 註冊配置

這裡我們要在自定義的Plugin中註冊

class MethodTracePlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
         project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
         ……
    }
}

註冊後,我們可以在引入了這個外掛的build檔案中做出如下配置

apply plugin: 'com.jianglei.method-tracer'
……
methodTrace{
    traceThirdLibrary = false
}

3.3 獲取配置

獲取配置很簡單,只要用如下程式碼就可以了:

        //獲取配置資訊
        MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)

問題是你什麼時候獲取這個配置資訊呢?剛開始,我在註冊這個配置後直接去獲取:

 @Override
    void apply(Project project) {
          //註冊配置
         project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
         //獲取配置資訊
        MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)

         ……
    }

我希望在這裡獲取配置然後傳入到Transform中去,事實上這是不可取的,此處的apply方法被呼叫時機是
apply plugin程式碼被呼叫的時候,此時,我們在build.gradle中的配置程式碼快還沒有被呼叫,所以是取不到我們想要的配置的,取到的都是預設值。
那麼到底我們應該怎麼獲取呢?其實我們只要在transform()方法中獲取就可以了,這個時候build.gradle中配置的程式碼已經執行過了:

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {

        //獲取配置資訊
        MethodTraceExtension extension = project.getExtensions().findByType(MethodTraceExtension.class)
         ……
    }

4. 優化

現在,我們有了一個可配置的外掛去修改所有的class檔案了,功能上的需求我們已經完成了,但是,效能上夠了嗎?

4.1 gradle外掛應該在application模組引入還是library模組引入?

目前,我們的外掛都是直接在application模組中引入的,那麼多模組情況下怎麼辦?每個模組都要引入嗎?可以只在主模組引入嗎?應該只在主模組引入嗎?

4.1.1 只在主模組引入

我們知道,butterknife是需要在每個模組都引入的,其實,對於多模組來說,我們完全可以只在application主模組中引入外掛,這裡要注意Transform中的getScopes()方法:

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        //此次是隻允許在主module(build.gradle中含有com.android.application外掛)
        //所以我們需要修改所有的module
        return TransformManager.SCOPE_FULL_PROJECT

    }

這裡的SCOPE_FULL_PROJECT其實是這樣的:

        SCOPE_FULL_PROJECT = Sets.immutableEnumSet(Scope.PROJECT, new Scope[]{Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES});

說明這裡處理的模組包括本模組,子模組以及第三方jar包,這樣我們就能在主模組中處理所有的class檔案了,可見我們是可以只在主模組中引入的,這樣做的話,所有子模組會以jar包的形式作為輸入。

4.1.2 在每個模組都引入

那麼如果想要在每個module中都引入該如何做呢?
首先是註冊方式要修改:

    @Override
    void apply(Project project) {
        project.getExtensions()
                .create("methodTrace", MethodTraceExtension.class)
        def extension = project.getExtensions().findByType(AppExtension.class)
        def isForApplication = true
        if (extension == null) {
            //說明當前使用在library中
            extension = project.getExtensions().findByType(LibraryExtension.class)
            isForApplication = false
        }
        extension.registerTransform(new MethodTraceTransform(project,isForApplication))

    }

關鍵是我們在Transform中要記錄當前是應用於主模組還是子模組了。
這種模式下,每一個模組都會執行自己的transform()方法,所以這裡的getScopes()方法要做些修改:

 @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        def scopes = new HashSet()
        scopes.add(QualifiedContent.Scope.PROJECT)
        if (isForApplication) {
            //application module中加入此項可以處理第三方jar包
            scopes.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
        }
        return scopes
    }

這裡對於主模組的情況下應該額外處理第三方jar包,子模組只要處理自己的專案程式碼即可。
其實進過實驗,所有子模組的依賴的第三方jar包只會在處理主模組中輸入,換句話說子模組是永遠不可能處理第三方jar包的。

4.1.3 小結

兩種方式都是可以的,那麼到底該選那種呢?有什麼選擇依據嗎?從上面的介紹來看沒有,而且應用所有子module的方式編寫起來似乎還要複雜一點,那是不是應該選擇只在主模組引入外掛呢?其實不然,最大的區別下面會講到,到時候自然有結果。

4.2 如何增量編譯

通過上面的介紹,完成一個外掛已經不是問題,但是這裡有一個問題,每次編譯時,transform()方法都會執行,我們會遍歷所有的class檔案,會解壓縮所有jar檔案,然後重新壓縮成所有jar檔案,但事實上,一次編譯有可能只改動了一個class檔案,我們能不能做到只重新修改這一個class檔案呢?gradle其實是提供了方法的。

4.2.1 gradle transform的增量機制

transform-api將輸入檔案分成了兩類:

  • DirectoryInput,包裝的是原始碼對應的class檔案,長這樣:
public interface DirectoryInput extends QualifiedContent {
    Map<File, Status> getChangedFiles();
}

換句話說,我們可以通過以下方式獲取改動的class檔案:

 input.directoryInputs.each { directoryInput ->
    directoryInput.changedFiles.each{changeFileEntry->
        def status = changeFileEntry.value;
    }
 }

這樣我們可以遍歷所有改動的檔案,而且可以獲取每個改動檔案的狀態,有4種:

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

    private Status() {
    }
}

第一次編譯或clean後重新編譯directory.changedFiles為空,需要做好區分
經測試,刪除一個java檔案,對應的class檔案輸入不會出現REMOVED狀態,也就是不能從changeFiles裡面獲取被刪除的檔案

  • JarInput 和DirectoryInput不同,JarInput只能獲取狀態,也有4種狀態:
public interface JarInput extends QualifiedContent {
    Status getStatus();
}

也就是說,我們如果想要增量編譯,應該處理所有非 Status.NOTCHANGED狀態的jar包,同樣如果移除了一個依賴,這個jar包就再也不會輸入,自然也就不會出現Status.REMOVED狀態的jar包了。

4.2.2 增量編譯要解決的問題

有了以上對gradle transform增量機制的瞭解,相信大家都對如何支援增量編譯有了一個基本的瞭解,但是想要開發一個健壯的、支援增量的外掛還有很多問題要解決,我們一一探討。

4.2.2.1 如何區分未編譯和未修改

之前提到,對於DirectoryInput來說,未編譯或clean後重新編譯時Directory.changedFiles為空,未修改時也是為空,前一種狀態下我們需要處理所有的檔案,後一種狀態下又不應該處理任何檔案,同樣,JarInput也面對這個問題,要解決也很簡單,這裡給出一種簡單方案,首次編譯時生成一個標記檔案,下一次編譯時,如果修改檔案為空,我們判斷該標記檔案是否存在,存在就是未修改,否則就是首次或clean後重新編譯。當然上次編譯也會有檔案輸出,我們可以直接拿任一輸出檔案做這個標記檔案。

4.2.2.2 如何解決增量編譯時包重複問題

一般來說,如果我們依賴了一個第三方jar包,比如:

    implementation "commons-io:commons-io:2.4"

首次編譯會在編譯輸出目錄下生成一個檔案,比如:

/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/32.jar

現在我們註釋掉這個包的引入,重新編譯,之前我們提過,刪除了一個jar包引用後我們是收不到任何資訊的,無法對這個包做任何處理,因為它根本就不會被輸入,那麼自然這個32.jar還在那裡,這個時候我們在重新引入剛才被移除的依賴,這個時候生成的檔案變成了:

/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/33.jar

這個時候問題就來了,32.jar和33.jar其實是一個jar包,編譯時自然會出現類衝突,而且這個衝突還比較尷尬,不好排查,因為gradle檔案是沒有任何問題的,最簡單的方法就是clean後重新編譯,這個問題自然不存在了,但一般開發者是沒有這個意識的,這樣做也太麻煩了,刪掉一個依賴再重新引入是很正常操作,為什麼非要先clean呢?
現在,我們來看下解決方案:
解決思路很簡單,要是我們能夠找到此次編譯時哪些jar包被刪除了,我們自己手動刪除該jar包上次編譯的輸出檔案不就解決了衝突問題嗎? 所以我們完全可以自己記錄下每次編譯時有哪些jar包參與了編譯,並且輸出到了哪裡,如下:

{
        "commons-io:commons-io:2.4": "/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/5.jar",
        ……    
}

那麼此次編譯時,我們讀取上次的檔案,對比兩次參與編譯的jar包,如果有刪除的,我們自己刪除該jar包對應的輸出檔案即可。

4.2.2.4 如何判斷配置檔案改變

和上面的class檔案改變或jar改變都不一樣,配置檔案改變transform是得不到任何額外的資訊的,但你不能不處理,比如說上次配置檔案定義如下:

methodTrace{
    traceThirdLibrary = false
}

編譯後自然不會處理第三方的jar包,但現在將其改成了false , 這個時候,上次編譯的所有結果都要重來,因為這次需要處理第三方jar包了。解決方案也很簡單,既然gradle沒有通知我們配置檔案改變了,我們自己記錄上次配置檔案,和本次編譯對比,如果配置檔案改變就全部重來,這個時候記錄的檔案就變成了這樣:

{
  "extension": {
    "traceThirdLibrary": true
  },
  "jarMap": {
     "commons-io:commons-io:2.4": "/home/jianglei/AndroidStudioProjects/ASMStudy/app/build/intermediates/transforms/MethodTrace/debug/5.jar",
      ……
   }
}

這裡有一個問題沒有解決,即使是自己對比配置檔案是否改變 ,這些程式碼都是寫在transform()方法中的,如果此次編譯只是修改配置檔案,沒有修改任何東西,gradle認為你什麼都沒有改動,直接不呼叫transform()方法了,這個就意味著你給了配置檔案增量編譯不生效,暫時沒有好的解決方案,只能重新CLEAN,或者修改其他的java檔案,都能觸發重新編譯。如果大家有更好的解決方案,希望能指出來。

5. 查缺補漏

之前我們還有一個問題沒有解決,那就是gradle外掛到底是應該只在主module中引入還是再所有的module中都引入。在我看來,衡量的關鍵點就是編譯速度,如果只在主module中引入的話,子module其實是以jar包的形式作為輸入檔案來 處理的,這樣我們就算只修改了子module中一個檔案,我們都需要將整個jar解壓,然後處理該jar中的所有class檔案,最後還得壓縮一次,多做了無用功; 如果我們放在所有的module中引入的話,針對這種情況我們只需要處理改動的class檔案即可,能節省很多時間,所以我推薦放到所有module引入。

6. 總結

有了上面的知識,我相信大家應該都能開發出一個健壯的、支援增量編譯的外掛了,然後你就能利用位元組碼插樁技術為所欲為了,上面這些原始碼大家可以點這裡:https://github.com/FamliarMan/ASMStudy, 當然這些都是我自己瞎琢磨出來的,網上似乎沒有查到相關資料,如果有錯誤,懇請指正,不甚感激!



作者:低情商的大仙
連結:https://www.jianshu.com/p/d84032b46b56
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授