Gradle 進階(二):如何優化 Task 的性能?

語言: CN / TW / HK

theme: smartblue highlight: a11y-dark


我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第1篇文章,點擊查看活動詳情

前言

之前介紹了Gradle自定義Task相關的一些內容:Gradle 進階(一):深入瞭解 Tasks

其中也提到了,當Task的輸入與輸出被註解標註並且都沒有發生變化時,Task的狀態是up-to-date的,此時可以跳過Task的執行

除了以上所説的編譯避免的方式,Gradle還提供了其他方案可以優化Task的性能,本文主要包括以下內容

  1. 支持增量處理的Task
  2. Task惰性配置
  3. 避免不必要的Task配置
  4. 並行Task

支持增量處理的Task

上文提到,當Task的輸入與輸出被註解標註並且都沒有發生變化時,Task的狀態是up-to-date的,此時可以跳過Task的執行

但是也存在一種情況,Task的輸入只有幾個文件發生了更改,而你並不想重新處理其他沒有發生更改的文件,這對於那些將輸入文件按 1:1 轉換為輸出文件的Task特別有用,比如我們編譯代碼的過程,就是將.java文件一對一的編譯成.class文件

如果你想要你的Task只重新處理髮生了更改的文件,那麼可以使用incremental task

實現incremental task

對於一個支持增量處理輸入的task,必須包含一個處理增量輸入的action。我們知道,Task中的Action就是使用@TaskAction註解的方法,與普通的Action不同的是,支持增量的action擁有一個InputChanges的輸入。此外,該Task還需要使用@Incremental@SkipWhenEmpty註解至少一個增量文件輸入屬性。

增量action可以使用InputChanges.getFileChanges()方法來找出對於指定的輸入(類型為RegularFilePropertyDirectoryPropertyConfigurableFileCollection)中哪些文件已更改。該方法返回一個FileChangesIterable類型的結果,然後可以查詢以下內容

  • 受影響的文件
  • 更改的類型(ADDED,REMOVEDMODIFIED)
  • 變更了的文件的規範化路徑
  • 變更了的文件的文件類型

下面就是一個支持增量處理的Task示例,它有一個目錄文件作為輸入,這個task的作用就是將這個目錄中的文件都拷貝到另一個目錄並且將文件的內容反轉。代碼如下所示:

```kotlin abstract class IncrementalReverseTask : DefaultTask() { @get:Incremental @get:PathSensitive(PathSensitivity.NAME_ONLY) @get:InputDirectory abstract val inputDir: DirectoryProperty

@TaskAction
fun execute(inputChanges: InputChanges) {
    println(
        if (inputChanges.isIncremental) "Executing incrementally"
        else "Executing non-incrementally"
    )

    inputChanges.getFileChanges(inputDir).forEach { change ->
        if (change.fileType == FileType.DIRECTORY) return@forEach

        val targetFile = outputDir.file(change.normalizedPath).get().asFile
        if (change.changeType == ChangeType.REMOVED) {
            targetFile.delete()
        } else {
            targetFile.writeText(change.file.readText().reversed())
        }
    }
}

} ```

可以看出,主要做了以下幾點:
1. inputDir使用@Incremental註解標識,表示支持增量處理的輸入
2. 通過inputChanges方法獲取輸入中更快的文件,如果發生了更改則重新處理,如果被刪除了則同樣刪除目標目錄中的文件,沒有發生更改的文件則不處理

總得來説,對於增量編譯Task,只需要為任何過時的輸入生成輸出文件,併為已刪除的輸入刪除輸出文件。

哪些輸入被認為是過時的?

如果之前執行過Task,並且自執行以來唯一的更改就是增量輸入文件屬性,則 Gradle 能夠確定需要處理哪些輸入文件(即增量執行)。在這種情況下,InputChanges.getFileChanges()方法會返回指定屬性的所有已添加、修改或刪除的輸入文件的詳細信息。

但是,在很多情況下,Gradle 無法確定需要處理哪些輸入文件(即非增量執行)。比如:

  • 以前的執行沒有可用的歷史記錄。
  • 您正在使用不同版本的 Gradle 進行構建。目前,Gradle 不使用來自不同版本的Task歷史記錄。
  • 添加的自定義upToDateWhen規則返回false。
  • 自上次執行以來,輸入屬性已更改。(因為Gradle無法判斷這個屬性對輸出的影響,因此所有輸入都需要重新處理)
  • 自上次執行以來,非增量輸入文件屬性已更改。
  • 自上次執行以來,一個或多個輸出文件已更改。

在以上所有這些情況下,Gradle 會將所有的輸入文件標記為ADDED狀態,並且通過getFileChanges()方法返回

您可以如上面的示例,使用InputChanges.isIncremental()方法檢查Task執行是否是增量的。

Task惰性配置

隨着構建的複雜性增加,有時我們很難了解特定值的配置時間和位置。Gradle 提供了幾種使用惰性配置來管理這種複雜性的方法。

惰性屬性

Gradle 提供了惰性屬性,它會延遲屬性值的計算,直到實際需要使用該屬性的時候,類似於Kotlinby lazy,惰性屬性主要有以下收益

  1. 用户可以將 Task 屬性連接在一起,而不必擔心特定屬性何時會被賦值。例如Task有一個inputFiles的惰性屬性輸入,它與inputDirectory惰性屬性存在一個連接關係(即它依賴於inputDirectory惰性屬性),你不用關心inputDirectory何時被賦值,inputFiles會追蹤inputDirectory的值的變化,並且在真正用到時才去計算
  2. 用户可以將Task的輸出屬性連接到其他Task的輸入屬性,Gradle 會根據此連接自動確定Task的依賴關係,這樣可以有效避免用户忘記顯式的聲明依賴關係
  3. 用户可以避免在配置階段進行資源密集型工作,這會對構建性能產生很大影響。因為只有在真正使用屬性時才會真正去計算,這往往是在執行階段。

Gradle提供了兩個接口表示惰性屬性

  • Provider接口表示一個只讀的屬性,它只能讀不能被修改,通過Provider.get()方法返回值,可以通過Provider.map(Transformer)方法變換成另一個Provider
  • Property接口表示可讀可改的屬性,Property接口繼承了Provider,可通過Property.set(T)方法設置值,也可以通過Property.set(Provider)方法建立與另一個Provider的連接。

惰性屬性的目的就是在配置階段傳遞並且僅在需要時進行查詢,而查詢通常發生在執行階段,下面我們來看個例子

```kotlin abstract class Greeting : DefaultTask() { // Configurable by the user @get:Input abstract val greeting: Property

// Read-only property calculated from the greeting
@Internal
val message: Provider<String> = greeting.map { it + " from Gradle" }

@TaskAction
fun printMessage() {
    logger.quiet(message.get())
}

}

tasks.register("greeting") { // Configure the greeting greeting.set("Hi") } ```

如上所示,該Task有兩個屬性,一個由用户配置的greeting與一個派生的屬性message

文件處理

上面的示例屬性都是常規類型,如果要使用文件類型的惰性屬性的話,要怎麼處理呢?

Gradle 專門提供了兩個Property的子類型來處理文件類型:RegularFilePropertyDirectoryProperty,分別表示惰性的文件和目錄

一個DirectoryProperty也可以通過DirectoryProperty.dir(String)DirectoryProperty.file(String)方法創建新的provider,新的provider的路徑是相對於創建它的DirectoryProperty 計算的,如下面示例所示

```kotlin abstract class GenerateSource : DefaultTask() { @get:InputFile abstract val configFile: RegularFileProperty

@get:OutputDirectory
abstract val outputDir: DirectoryProperty

@TaskAction
fun compile() {
    val inFile = configFile.get().asFile
    logger.quiet("configuration file = $inFile")
    val dir = outputDir.get().asFile
    logger.quiet("output dir = $dir")
}

}

tasks.register("generate") { // 相對於projectDirectory與buildDirectory配置Task屬性 configFile.set(layout.projectDirectory.file("src/config.txt")) outputDir.set(layout.buildDirectory.dir("generated-source")) }

// 修改build directory // 不需要重新配置Task的屬性,當build directory變化時,task的outputDir屬性會自動變化 layout.buildDirectory.set(layout.projectDirectory.dir("output")) ```

上面的Task輸出如下

```kotlin

Task :generate configuration file = /home/user/gradle/samples/kotlin/src/config.txt output dir = /home/user/gradle/samples/kotlin/output/generated-source ```

主要需要注意以下兩點:
1. configFileoutputDir的路徑都是相對於創建它們的路徑計算出來的 2. 當修改build directory的值時,不需要重新配置Task的屬性,當build directory變化時,taskoutputDir屬性會自動變化,這也是惰性配置的特性帶來的

自動建立Task依賴關係

有時我們需要將多個Task連接起來,其中一個Task的輸出作為另一個的輸入來完成構建。這個時候我們不僅需要指定Task的輸入輸出,同時需要顯式地配置Task之間的依賴關係。如果其中某個屬性的依賴關係發生了變化,這可能會很麻煩且脆弱,因為在不使用惰性配置屬性的情況下,Task屬性需要以正確的順序配置,並且Task依賴關係也需要手動同步變更

Property API不僅能夠像上面示例那樣跟蹤屬性值,並且可以跟蹤產生屬性的Task,因此我們不必要手動配置Task依賴關係,我們來看下面的例子:

```kotlin abstract class Producer : DefaultTask() { @get:OutputFile abstract val outputFile: RegularFileProperty

@TaskAction
fun produce() {
    // ...
}

}

abstract class Consumer : DefaultTask() { @get:InputFile abstract val inputFile: RegularFileProperty

@TaskAction
fun consume() {
    // ...
}

}

val producer = tasks.register("producer") val consumer = tasks.register("consumer")

consumer { // 將 producer的輸出與consumer的輸入建立聯繫,你不必手動添加Task依賴關係 inputFile.set(producer.flatMap { it.outputFile }) }

producer { // 更新 producer的值,你不必手動更新 consumer.inputFile,它會隨着 producer.outputFile的變化自動變化 outputFile.set(layout.buildDirectory.file("file.txt")) }

// 修改 build directory,也不必手動更新 producer.outputFile 與 consuer.inputFile,它們會自動更新 layout.buildDirectory.set(layout.projectDirectory.dir("output")) ```

  1. 上面的代碼通過Provider的依賴關係自動地建立了Task的依賴關係,當運行consumer時會自動運行它的依賴Task(即producer)
  2. 使用惰性屬性不必關心屬性何時被賦值,哪個屬性先賦值,只要在Task真正執行前賦值就可以了,並且更改將自動影響所有相關的輸入和輸出屬性。

惰性屬性集合API

類似於文件,對於各種集合,Gradle也提供了相應的API供我們使用

  • 對於List值,該接口稱為ListProperty
  • 對於Set值,該接口稱為SetProperty
  • 對於Map值,該接口稱為MapProperty

這幾個接口的使用示例就不在這裏綴述了,感興趣的同學可查看:https://docs.gradle.org/current/userguide/lazy_configuration.html#working_with_collections

惰性屬性默認值

有時我們也可能需要給惰性屬性設置一個默認值,在沒有為屬性配置值時使用

```kotlin // 創建 property val property = objects.property(String::class)

// 設置默認值 property.convention("convention 1") println("value = " + property.get())

// 設置之後也可以重新設置默認值 property.convention("convention 2") println("value = " + property.get())

// 設置真正的值 property.set("value")

// 一旦設置了真正的值,默認值設置就會被忽略 property.convention("ignored convention") println("value = " + property.get()) ```

使Property不可更改

上文我們提到,Provider接口是不可設值的,Property接口是可以設值的。但是,有時候我們希望在Task開始執行之後,禁止對Property的修改(即只在配置階段修改)

惰性屬性提供了finalizeValue()來實現這個需求,它會計算屬性的最終值並防止對屬性進行進一步更改。當屬性的值來自於一個 Provider時,會向提供者查詢其當前值,結果將成為該屬性的最終值。並且該屬性不再跟蹤提供者的值。調用此方法還會使屬性實例不可修改,並且任何進一步更改屬性值的嘗試都將失敗。

finalizeValueOnRead()方法類似,只是在查詢到屬性的值之前不計算屬性的最終值。換句話説,該方法根據需要延遲計算最終值,而finalizeValue()急切地計算最終值。

小結

本節主要介紹了Provider API的使用,它有着如上文所説的一系列優點,現在官方的Task屬性都改成Provider了,我們在開發自定義Task時也應該儘量使用惰性屬性

避免不必要的Task配置

配置避免,簡而言之,就是避免在的配置階段創建和配置Task,因為這些Task可能永遠不會被執行。例如,在運行編譯Task時,不會執行其他不相關的Task,如代碼質量、測試和發佈,因此無需花費任何時間創建和配置這些Task。如果在構建過程中不需要配置Task,則配置避免 API 會避免配置Task,這可能會對總配置時間產生重大影響。

配置避免的一個常用手段就是使用register代替create來創建Task,除了這個還有哪些常用手段呢?

如何推遲Task配置?

避免使用DomainObjectCollection.all(org.gradle.api.Action)DomainObjectCollection.withType(java.lang.Class, org.gradle.api.Action)這樣的API
它們將立即創建和配置任何已註冊的Task。要推遲Task配置,您需要遷移到配置避免 API 等效項,即withType(java.lang.Class).configureEach(org.gradle.api.Action)

如何在不創建/配置Task的情況下引用它?

您可以通過TaskProvider對象來引用已註冊的Task,而不是直接引用Task對象。可以通過多種方式獲取 TaskProvider,包括調用TaskContainer.register(java.lang.String)或使用TaskCollection.named(java.lang.String)方法。

調用Provider.get()或使用TaskCollection.getByName(java.lang.String)方法將導致立即創建和配置TaskTask.dependsOn (java.lang.Object...​)之類的方法的參數可以是TaskProvider,因此您無需取出Task對象

總得來説,即儘量使用TaskProvider而不是Task

依賴關係對配置的影響

依賴關係可以分為軟關係與強關係兩類

Task.mustRunAfter(…​)Task.shouldRunAfter(…​)代表軟關係,只能改變現有Task的順序,不能觸發它們的創建。

Task.dependsOn(…​)Task.finalizedBy(…​)代表強關係,這將強制執行引用的Task

  • 如果未執行Task,定義的關係將不會在配置時觸發任何Task創建。
  • 如果執行一個Task,所有強關聯的Task都需要在配置時創建和配置,因為它們可能有其他dependsOnfinalizedBy關係。這將傳遞地發生,直到任務圖包含所有強關係。

問題定位

如果想具體瞭解我們項目目前Task的配置情況,可以使用gradle --scan命令

可以看到performanceconfiguration部分,如上圖所示:

  • Created immediately表示使用急切Task API創建的任務。
  • Created during configuration表示使用配置避免 API 創建的Task,但是可能使用了TaskProvider#get()或者來TaskCollection.getByName(java.lang.String)查詢
  • Created immediatelyCreated during configuration數字都被認為是“壞”數字,應儘可能減少。
  • Created during task graph calculation表示在構建執行Task圖時創建的任務。理想情況下,這個數字將等於執行的任務數。
  • Not created表示在此構建會話中避免的任務。

小結

總得來説,配置避免主要有以下實用手段

  1. 遷移配置tasks.all {}tasks.withType(…​) {}APIwithType(java.lang.Class).configureEach(...)
  2. 遷移查找方法TaskContainer#getByName(String)TaskContainer#named(String),儘量使用TaskProvider而不是直接使用Task
  3. 還有最常用的,使用register方法創建Task而不是create

並行Task

在我們自定義Task時,Task的輸入有時是個列表,而列表的每一項可以單獨處理,這個時候就可以使用Worker API來加速構建

Worker API 提供了將Task操作的執行分解為離散的工作單元然後併發和異步執行該工作的能力。這允許 Gradle 充分利用可用資源並更快地完成構建。

下面我們創建一個自定義Task,該Task為一組可配置的文件生成 MD5 哈希。然後,我們使用Worker API優化該Task

創建自定義Task

首先我們創建一個Task,該Task為一組可配置的文件生成 MD5 哈希

```java abstract public class CreateMD5 extends SourceTask {

@OutputDirectory
abstract public DirectoryProperty getDestinationDirectory();

@TaskAction
public void createHashes() {
    for (File sourceFile : getSource().getFiles()) { 
        try {
            InputStream stream = new FileInputStream(sourceFile);
            System.out.println("Generating MD5 for " + sourceFile.getName() + "...");
            // 模擬耗時操作
            Thread.sleep(3000); 
            Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");  
            FileUtils.writeStringToFile(md5File.get().getAsFile(), DigestUtils.md5Hex(stream), (String) null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

} ```

代碼其實很簡單,主要是遍歷輸入文件列表,為每個文件生成md5哈希並輸出。如果輸入文件數量是3個的話,該Task至少需要 9 秒才能運行,因為它一次對每個文件進行一個哈希處理(每個文件大約 3 秒)。

轉換為 Worker API

儘管此Task按順序處理每個文件,但每個文件的處理獨立於任何其他文件。如果這項工作能夠並行完成那就太好了。這就是 Worker API 的用武之地

第一步:首先我們需要定義一個接口來表示每個工作單元需要的參數

java public interface MD5WorkParameters extends WorkParameters { RegularFileProperty getSourceFile(); RegularFileProperty getMD5File(); }

  1. 這個接口需要繼承WorkParameters,但是不需要實現
  2. 每個工作單元主要需要兩個參數,輸入的文件與輸出文件,也就是接口中聲明的對象

第二步:您需要將自定義Task中為每個單獨文件執行工作的部分重構為單獨的類。這個類是你的“工作單元”實現,它應該是一個繼承了WorkAction接口的抽象類

java public abstract class GenerateMD5 implements WorkAction<MD5WorkParameters> { @Override public void execute() { try { File sourceFile = getParameters().getSourceFile().getAsFile().get(); File md5File = getParameters().getMD5File().getAsFile().get(); InputStream stream = new FileInputStream(sourceFile); System.out.println("Generating MD5 for " + sourceFile.getName() + "..."); // 模擬耗時操作 Thread.sleep(3000); FileUtils.writeStringToFile(md5File, DigestUtils.md5Hex(stream), (String) null); } catch (Exception e) { throw new RuntimeException(e); } } }

需要注意的是,不要實現getParameters()方法,Gradle 將在運行時注入它。

第三步:您應該重構自定義Task類以將工作提交給 WorkerExecutor,而不是自己完成工作。

```java abstract public class CreateMD5 extends SourceTask {

@OutputDirectory
abstract public DirectoryProperty getDestinationDirectory();

@Inject
abstract public WorkerExecutor getWorkerExecutor();

@TaskAction
public void createHashes() {
    WorkQueue workQueue = getWorkerExecutor().noIsolation();

    for (File sourceFile : getSource().getFiles()) {
        Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");
        workQueue.submit(GenerateMD5.class, parameters -> { 
            parameters.getSourceFile().set(sourceFile);
            parameters.getMD5File().set(md5File);
        });
    }
}

} ```

  1. 您需要擁有WorkerExecutor服務才能提交您的工作。這裏我們添加了一個抽象的getWorkerExecutor方法並添加註解,Gradle 將在運行時注入服務。
  2. 在提交工作之前,我們需要通過不同的隔離模式獲取WorkQueue。關於隔離模式我們稍後再討論。
  3. 提交工作單元時,指定工作單元實現,在這種情況下調用GenerateMD5並配置其參數。

當我們再次運行這個Task時,可以發現Task運行的速度變快了,這是因為 Worker API 是並行的而不是按順序對每個文件執行 MD5 計算。

隔離模式

上面的示例中我們使用了noIsolation隔離模式,Gralde提供了三種隔離模式

  • WorkerExecutor.noIsolation():這表明工作應該在具有最小隔離的線程中運行。例如,它將共享加載Task的同一個類加載器。這是最快的隔離級別。
  • WorkerExecutor.classLoaderIsolation():這表明工作應該在具有隔離類加載器的線程中運行。類加載器將具有來自加載工作單元實現類的類加載器的classpath,以及通過ClassLoaderWorkerSpec.getClasspath()添加的任何其他classpath
  • WorkerExecutor.processIsolation(): 這表明工作應該在單獨的進程中執行,工作以最大程度的隔離運行。進程的類加載器將使用加載工作單元的類加載器中的classpath以及通過ClassLoaderWorkerSpec.getClasspath()添加的任何其他classpath。此外,該進程將是一個Worker守護進程,它將保持活動狀態並且可以重用於未來可能具有相同要求的工作項。可以使用ProcessWorkerSpec.forkOptions(org.gradle.api.Action)方法來配置此進程。

總結

本文主要介紹了通過支持增量處理,惰性配置,避免不必要的Task配置,以及並行Task等方式來優化自定義Task的性能,希望對你有所幫助~

參考資料

https://docs.gradle.org/current/userguide/custom_tasks.html