Gradle 進階(二):如何優化 Task 的性能?
theme: smartblue highlight: a11y-dark
我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第1篇文章,點擊查看活動詳情
前言
之前介紹了Gradle
自定義Task
相關的一些內容:Gradle 進階(一):深入瞭解 Tasks
其中也提到了,當Task
的輸入與輸出被註解標註並且都沒有發生變化時,Task
的狀態是up-to-date
的,此時可以跳過Task
的執行
除了以上所説的編譯避免的方式,Gradle
還提供了其他方案可以優化Task
的性能,本文主要包括以下內容
- 支持增量處理的
Task
Task
惰性配置- 避免不必要的
Task
配置 - 並行
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()
方法來找出對於指定的輸入(類型為RegularFileProperty
,DirectoryProperty
或ConfigurableFileCollection
)中哪些文件已更改。該方法返回一個FileChangesIterable
類型的結果,然後可以查詢以下內容
- 受影響的文件
- 更改的類型(
ADDED
,REMOVED
或MODIFIED
) - 變更了的文件的規範化路徑
- 變更了的文件的文件類型
下面就是一個支持增量處理的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
提供了惰性屬性,它會延遲屬性值的計算,直到實際需要使用該屬性的時候,類似於Kotlin
的by lazy
,惰性屬性主要有以下收益
- 用户可以將
Task
屬性連接在一起,而不必擔心特定屬性何時會被賦值。例如Task
有一個inputFiles
的惰性屬性輸入,它與inputDirectory
惰性屬性存在一個連接關係(即它依賴於inputDirectory
惰性屬性),你不用關心inputDirectory
何時被賦值,inputFiles
會追蹤inputDirectory
的值的變化,並且在真正用到時才去計算 - 用户可以將
Task
的輸出屬性連接到其他Task
的輸入屬性,Gradle
會根據此連接自動確定Task
的依賴關係,這樣可以有效避免用户忘記顯式的聲明依賴關係 - 用户可以避免在配置階段進行資源密集型工作,這會對構建性能產生很大影響。因為只有在真正使用屬性時才會真正去計算,這往往是在執行階段。
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
如上所示,該Task
有兩個屬性,一個由用户配置的greeting
與一個派生的屬性message
文件處理
上面的示例屬性都是常規類型,如果要使用文件類型的惰性屬性的話,要怎麼處理呢?
Gradle
專門提供了兩個Property
的子類型來處理文件類型:RegularFileProperty
和DirectoryProperty
,分別表示惰性的文件和目錄
一個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
// 修改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. configFile
與outputDir
的路徑都是相對於創建它們的路徑計算出來的
2. 當修改build directory
的值時,不需要重新配置Task
的屬性,當build directory
變化時,task
的outputDir
屬性會自動變化,這也是惰性配置的特性帶來的
自動建立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
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")) ```
- 上面的代碼通過
Provider
的依賴關係自動地建立了Task
的依賴關係,當運行consumer
時會自動運行它的依賴Task
(即producer
) - 使用惰性屬性不必關心屬性何時被賦值,哪個屬性先賦值,只要在
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)
方法將導致立即創建和配置Task
。Task.dependsOn (java.lang.Object...)
之類的方法的參數可以是TaskProvider
,因此您無需取出Task
對象
總得來説,即儘量使用TaskProvider
而不是Task
依賴關係對配置的影響
依賴關係可以分為軟關係與強關係兩類
Task.mustRunAfter(…)
和Task.shouldRunAfter(…)
代表軟關係,只能改變現有Task
的順序,不能觸發它們的創建。
Task.dependsOn(…)
和Task.finalizedBy(…)
代表強關係,這將強制執行引用的Task
- 如果未執行
Task
,定義的關係將不會在配置時觸發任何Task
創建。 - 如果執行一個
Task
,所有強關聯的Task
都需要在配置時創建和配置,因為它們可能有其他dependsOn
或finalizedBy
關係。這將傳遞地發生,直到任務圖包含所有強關係。
問題定位
如果想具體瞭解我們項目目前Task
的配置情況,可以使用gradle --scan
命令
可以看到performance
的configuration
部分,如上圖所示:
Created immediately
表示使用急切Task API
創建的任務。Created during configuration
表示使用配置避免API
創建的Task
,但是可能使用了TaskProvider#get()
或者來TaskCollection.getByName(java.lang.String)
查詢Created immediately
和Created during configuration
數字都被認為是“壞”數字,應儘可能減少。Created during task graph calculation
表示在構建執行Task
圖時創建的任務。理想情況下,這個數字將等於執行的任務數。Not created
表示在此構建會話中避免的任務。
小結
總得來説,配置避免主要有以下實用手段
- 遷移配置
tasks.all {}
和tasks.withType(…) {}
等API
到withType(java.lang.Class).configureEach(...)
- 遷移查找方法
TaskContainer#getByName(String)
到TaskContainer#named(String)
,儘量使用TaskProvider
而不是直接使用Task
- 還有最常用的,使用
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();
}
- 這個接口需要繼承
WorkParameters
,但是不需要實現 - 每個工作單元主要需要兩個參數,輸入的文件與輸出文件,也就是接口中聲明的對象
第二步:您需要將自定義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);
});
}
}
} ```
- 您需要擁有
WorkerExecutor
服務才能提交您的工作。這裏我們添加了一個抽象的getWorkerExecutor
方法並添加註解,Gradle
將在運行時注入服務。 - 在提交工作之前,我們需要通過不同的隔離模式獲取
WorkQueue
。關於隔離模式我們稍後再討論。 - 提交工作單元時,指定工作單元實現,在這種情況下調用
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
的性能,希望對你有所幫助~
參考資料
- kotlin-android-extensions 插件到底是怎麼實現的?
- 江同學的 2022 年終總結,請查收~
- kotlin-android-extensions 插件將被正式移除,如何無縫遷移?
- 學習一下 nowinandroid 的構建腳本
- Kotlin 默認可見性為 public,是不是一個好的設計?
- 2022年編譯加速的8個實用技巧
- 落地 Kotlin 代碼規範,DeteKt 瞭解一下~
- Gradle 進階(二):如何優化 Task 的性能?
- 開發一個支持跨平台的 Kotlin 編譯器插件
- 開發你的第一個 Kotlin 編譯器插件
- Kotlin 增量編譯是怎麼實現的?
- Gradle 都做了哪些緩存?
- K2 編譯器是什麼?世界第二高峯又是哪座?
- Android 性能優化之 R 文件優化詳解
- Kotlin 快速編譯背後的黑科技,瞭解一下~
- 別了 KAPT , 使用 KSP 快速實現 ButterKnife
- Android Apk 編譯打包流程,瞭解一下~
- 如何優雅地擴展 AGP 插件
- ASM 插樁採集方法入參,出參及耗時信息
- Transform 被廢棄,ASM 如何適配?