Android開發之Gradle 進階Tasks深入瞭解

前言

Gradle自定義Task看起來非常簡單,通過tasks.register等API就可以輕松實現。但實際上為瞭寫出高效的,可緩存的,不拖慢編譯速度的task,還需要瞭解更多知識。

本文主要包括以下內容:

  • 定義Task
  • 查找Task
  • 配置Task
  • 將參數傳遞給Task構造函數
  • Task添加依賴
  • Task排序
  • Task添加說明
  • 跳過Task
  • Task支持增量編譯
  • Finalizer Task

定義Task

如上所說,自定義Task一般可以通過register API實現

tasks.register("hello") {
    doLast {
        println("hello")
    }
}
tasks.register<Copy>("copy") {
    from(file("srcDir"))
    into(buildDir)
}

如果是kotlin或者kts中,也可以通過代理來實現

val hello by tasks.registering {
    doLast {
        println("hello")
    }
}
val copy by tasks.registering(Copy::class) {
    from(file("srcDir"))
    into(buildDir)
}

register與create的區別

除瞭上面介紹的register,其實create也可以用於創建Task,那麼它們有什麼區別呢?

  • 通過register創建時,隻有在這個task被需要時才會真正創建與配置該Task(被需要是指在本次構建中需要執行該Task)
  • 通過create創建時,則會立即創建與配置該Task

總得來說,通過register創建Task性能更好,更推薦使用

查找Task

我們有時需要查找Task,比如需要配置或者依賴某個Task,我們可以通過named方法來查找對應名字的task

tasks.register("hello")
tasks.register<Copy>("copy")
println(tasks.named("hello").get().name) // or just 'tasks.hello' if the task was added by a plugin
println(tasks.named<Copy>("copy").get().destinationDir)

也可以使用tasks.withType()方法來查找特定類型的Task

tasks.withType<Tar>().configureEach {
    enabled = false
}
tasks.register("test") {
    dependsOn(tasks.withType<Copy>())
}

除瞭上述方法,也可以通過tasks.getByPath()方法來查找task,不過這種方式破壞瞭configuration avoidance和project isolation,因此不被推薦使用

配置Task

在創建瞭Task之後,我們常常需要配置Task

我們可以在查找到Task之後進行配置

tasks.named<Copy>("myCopy") {
    from("resources")
    into("target")
    include("**/*.txt", "**/*.xml", "**/*.properties")
}

我們還可以將Task引用存儲在變量中,並用於稍後在腳本中進一步配置任務。

val myCopy by tasks.existing(Copy::class) {
    from("resources")
    into("target")
}
myCopy {
    include("**/*.txt", "**/*.xml", "**/*.properties")
}

我們也可以在定義Task時進行配置,這也是最常用的一種

tasks.register<Copy>("copy") {
   from("resources")
   into("target")
   include("**/*.txt", "**/*.xml", "**/*.properties")
}

將參數傳遞給Task構造函數

除瞭在Task創建後配置參數,我們也可以將參數傳遞給Task的構建函數,為瞭實現這點,我們必須使用@Inject註解

abstract class CustomTask @Inject constructor(
    private val message: String,
    private val number: Int
) : DefaultTask()

然後,我們可以創建一個Task,在參數列表的末尾傳遞構造函數參數。

tasks.register<CustomTask>("myTask", "hello", 42)

需要註意的是,在任何情況下,作為構造函數參數傳遞的值都必須是非空的。如果您嘗試傳遞一個null值,Gradle 將拋出一個NullPointerException指示哪個運行時值是null.

Task添加依賴

有幾種方法可以定義Task的依賴關系,首先我們可以通過名稱定義依賴項

project("project-a") {
    tasks.register("taskX") {
        dependsOn(":project-b:taskY")
        doLast {
            println("taskX")
        }
    }
}
project("project-b") {
    tasks.register("taskY") {
        doLast {
            println("taskY")
        }
    }
}

其次我們也可以通過Task對象定義依賴項

val taskX by tasks.registering {
    doLast {
        println("taskX")
    }
}
val taskY by tasks.registering {
    doLast {
        println("taskY")
    }
}
taskX {
    dependsOn(taskY)
}

還有一些更高端的用法,我們可以用provider懶加載塊來定義依賴項,在evaluated階段,provider被傳遞給正在計算依賴的task

provider塊應返回單個對象Task或Task對象集合,然後將其視為任務的依賴項,如下所示:taskx添加瞭所有以lib開頭的對象

val taskX by tasks.registering {
    doLast {
        println("taskX")
    }
}
// Using a Gradle Provider
taskX {
    dependsOn(provider {
        tasks.filter { task -> task.name.startsWith("lib") }
    })
}
tasks.register("lib1") {
    doLast {
        println("lib1")
    }
}tasks.register("lib2") {
    doLast {
        println("lib2")
    }
}
tasks.register("notALib") {
    doLast {
        println("notALib")
    }
}

Task排序

有時候,兩個task之間沒有依賴關系,但是對兩個task的執行順序卻有所要求

任務排序和任務依賴之間的主要區別在於,排序規則不會影響將執行哪些任務,隻會影響它們的執行順序。

任務排序在許多場景中都很有用:

  • 強制執行任務的順序:例如,build 永遠不會在clean 之前運行。
  • 在構建的早期運行構建驗證:例如,在開始發佈構建工作之前驗證我是否擁有正確的憑據。
  • 通過在長時間驗證任務之前運行快速驗證任務來更快地獲得反饋:例如,單元測試應該在集成測試之前運行。
  • 聚合特定類型的所有任務的結果的任務:例如測試報告任務組合所有已執行測試任務的輸出。

gradle提供瞭兩個可用的排序規則:mustRunAfter 和 shouldRunAfter

當您使用mustRunAfter排序規則時,您指定taskB必須始終在taskA之後運行,這表示為taskB.mustRunAfter(taskA)

而shouldRunAfter規則理加弱化,因為在兩種情況下這條規則會被忽略。一是使用這條規則會導致先後順序成環的情況,二是當並行執行task,並且任務的所有依賴關系都已經滿足時,那麼無論它的shouldRunAfter依賴關系是否已經運行,這個任務都會運行。

因此您應該在排序有幫助但不是嚴格要求的情況下使用shouldRunAfter

示例如下:

val taskX by tasks.registering {
    doLast {
        println("taskX")
    }
}
val taskY by tasks.registering {
    doLast {
        println("taskY")
    }
}
// mustRunAfter 
taskY {
    mustRunAfter(taskX)
}
// shouldRunAfter
taskY {
    shouldRunAfter(taskX)
}

需要註意的是,B.mustRunAfter(A)或B.shouldRunAfter(A)並不意味著任務之間存在任何執行依賴關系:

我們可以獨立執行A或者任務B。排序規則僅在兩個任務都計劃執行時才有效。

Task添加說明

您可以為Task添加說明。執行時gradle tasks時會顯示此說明。

tasks.register<Copy>("copy") {
   description = "Copies the resource directory to the target directory."
   from("resources")
   into("target")
   include("**/*.txt", "**/*.xml", "**/*.properties")
}

跳過Task

gradle提供瞭多種方式來跳過task的執行

使用onlyIf

你可以通過onlyIf為任務的執行添加條件,如果任務應該執行,則應該返回 true,如果應該跳過任務,則返回 false

val hello by tasks.registering {
    doLast {
        println("hello world")
    }
}
hello {
    onlyIf { !project.hasProperty("skipHello") }
}

Output of gradle hello -PskipHello
> gradle hello -PskipHello
> Task :hello SKIPPED 

如上所示,hello任務被跳過瞭

使用 StopExecutionException

如果跳過任務邏輯不能使用onlyIf實現,您可以使用StopExecutionException。如果某個Action拋出此異常,則跳過該Action的進一步執行以及該任務的任何後續Action的執行。構建繼續執行下一個任務。

val compile by tasks.registering {
    doLast {
        println("We are doing the compile.")
    }
}
compile {
    doFirst {
        // Here you would put arbitrary conditions in real life.
        if (true) {
            throw StopExecutionException()
        }
    }
}
tasks.register("myTask") {
    dependsOn(compile)
    doLast {
        println("I am not affected")
    }
}

禁用與啟用Task

每個任務都有一個enabled的標志位,默認為true。將其設置為false可以阻止執行任何Task的執行。禁用的任務將被標記為 SKIPPED。

val disableMe by tasks.registering {
    doLast {
        println("This should not be printed if the task is disabled.")
    }
}
disableMe {
    enabled = false
}

Task超時

每個Task都有一個timeout屬性,可用於限制其執行時間。當一個任務達到它的超時時間時,它的任務執行線程被中斷。該任務將被標記為失敗。但是Finalizer Task任務仍將運行。

如果構建時使用瞭–continue參數,其他任務可以在它之後繼續運行。不響應中斷的task不能超時。Gradle 的所有內置task都會及時響應超時

Task支持增量編譯

任何構建工具的一個重要部分是避免重復工作。在編譯過程中,就是在編譯源文件後,除非發生瞭影響輸出的更改(例如源文件的修改或輸出文件的刪除),無需重新編譯它們。因為編譯可能會花費大量時間,因此在不需要時跳過該步驟可以節省大量時間。

Gradle 支持增量構建,當您運行構建時,有些Task被標記為UP-TO-DATE,這就是增量編譯生效瞭

那麼Gradle增量編譯如何工作?自定義Task如何支持增量編譯?我們一起來看看

Task的輸入輸出

Task最基本的功能就是接受一些輸入,進行一系列運算後生成輸出。比如在編譯過程中,Java源文件是輸入,生成的classes文件是輸出。其他輸入可能包括諸如是否應包含調試信息之類的內容。

task輸入的一個重要特征是它會影響一個或多個輸出,從上圖中可以看出。根據源文件的內容和target jdk版本,會生成不同的字節碼。這使他們成為task輸入。

但是編譯期的一些其他屬性,比如編譯最大可用內存,由memoryMaximumSize屬性決定,memoryMaximumSize對生成的字節碼沒有影響。因此,memoryMaximumSize不是task輸入,它隻是一個內部task屬性。

作為增量構建的一部分,Gradle 會檢查自上次構建以來是task的輸入或輸出有沒有發生變化。如果沒有,Gradle 可以認為task是最新的,因此跳過執行其action。需要註意的是,除非task至少有一個task輸出,否則增量構建將不起作用

總得來說:

您需要告訴 Gradle 哪些task屬性是輸入,哪些是輸出。

如果task屬性影響輸出,請務必將其註冊為輸入,否則該任務將被認為是最新的而不是最新的。

相反,如果屬性不影響輸出,則不要將其註冊為輸入,否則任務可能會在不需要時執行。

還要註意可能為完全相同的輸入生成不同輸出的非確定性task:不應將這些任務配置為增量構建,因為最新檢查將不起作用。

接下來讓我們看看如何將task屬性註冊為輸入和輸出。

自定義task類型

為瞭讓自定義task支持增量編譯,隻需要以下兩個步驟

  • 為每個task輸入和輸出創建類型化屬性(通過 getter 方法)
  • 為每個屬性添加適當的註解

Gradle 支持四種主要的輸入和輸出類型:

  • 簡單值
    例如字符串和數字類型。更一般地說,任何一個實現瞭Serializable的類型。
  • 文件系統類型
    包括RegularFile,Directory和標準File類,也包括 Gradle 的FileCollection類型的派生類,以及任何可以被Project.file(java.lang.Object)和Project.files(java.lang.Object…)方法接收的參數
  • 依賴解析結果
    這包括包含Artifact元數據的ResolvedArtifactResult類型和包含依賴圖的ResolvedComponentResult類型。請註意,它們僅支持包裝在Provider中.
  • 包裝類型
    不符合其他幾個類型但具有自己的輸入或輸出屬性的自定義類型。task的輸入或輸出包裝在這些自定義類型中。

接下來我們看個例子

假設您有一個task處理不同類型的模板,例如 FreeMarker、Velocity、Moustache 等。它獲取模板源文件並將它們與一些模型數據結合以生成不同結果。

此任務將具有三個輸入和一個輸出:

  • 模板源文件
  • 模型數據
  • 模板引擎
  • 輸出文件的寫入位置

在編寫自定義task類時,我們很容易通過註解將屬性註冊為輸入或輸出

public abstract class ProcessTemplates extends DefaultTask {
    @Input
    public abstract Property<TemplateEngineType> getTemplateEngine();
    @InputFiles
    public abstract ConfigurableFileCollection getSourceFiles();
    @Nested
    public abstract TemplateData getTemplateData();
    @OutputDirectory
    public abstract DirectoryProperty getOutputDir();
    @TaskAction
    public void processTemplates() {
        // ...
    }
}
public abstract class TemplateData {
    @Input
    public abstract Property<String> getName();
    @Input
    public abstract MapProperty<String, String> getVariables();
}

可以看出,我們定義瞭3個輸入,一個輸出

  • templateEngine,表示使用什麼模板引擎,我們傳入一個枚舉類型,枚舉類型都實現瞭Serializable,因此可作為輸入
  • sourceFiles,表示源文件,我們傳入FileCollection作為輸入
  • templateData,表示模型數據,自定義類型,在它的內部包裝瞭真正的輸入,通過@Nested註解表示
  • outputDir,表示輸出目錄,表示單個目錄的屬性需要@OutputDirectory註解

當我們重復運行以上task之後,就可以看到以下輸出

> gradle processTemplates
> Task :processTemplates UP-TO-DATE
BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 up-to-date

如上所示,task在執行過程中會判斷輸入輸出有沒有發生變化,由於task的輸入輸出都沒有發生變化,該task可以直接跳過,展示為up-to-date

除瞭上述幾種註解,還有其他常用註解如@Internal,@Optional,@Classpath等,具體可查看文檔:Incremental build property type annotations

聲明輸入輸出的好處

一旦你聲明瞭一個task的正式輸入和輸出,Gradle 就可以推斷出關於這些屬性的一些事情。例如,如果一個task的輸入設置為另一個task的輸出,這意味著第一個task依賴於第二個,gradle可以推斷出這一點並添加隱式依賴

推斷task依賴關系

想象一個歸檔task,會將processTemplates task的輸出歸檔。可以看到歸檔task顯然需要processTemplates首先運行,因此可能會添加顯式的dependsOn. 但是,如果您像這樣定義歸檔task:

tasks.register<Zip>("packageFiles") {
    from(processTemplates.map {it.outputs })
}

Gradle 會自動使packageFiles依賴processTemplates。它可以這樣做是因為它知道 packageFiles 的輸入之一需要 processTemplates 任務的輸出。我們稱之為推斷的task依賴。

上面的例子也可以寫成

tasks.register<Zip>("packageFiles2") {
    from(processTemplates)
}

這是因為from()方法可以接受task對象作為參數。然後在幕後,from()使用project.files()方法包裝參數,進而將task的正式輸出轉化為文件集合

輸入和輸出驗證

增量構建註解為 Gradle 提供瞭足夠的信息來對帶註解的屬性執行一些基本驗證。它會在task執行之前對每個屬性執行以下操作:

  • @InputFile- 驗證屬性是否有值,並且路徑是否對應於存在的文件(不是目錄)。
  • @InputDirectory- 與@InputFile相同,但路徑必須對應於目錄。
  • @OutputDirectory- 驗證路徑是否是個目錄,如果該目錄尚不存在,則創建該目錄。

如果一個task在某個位置產生輸出,而另一個任務task將其作為輸入使用,則 Gradle 會檢查消費者任務是否依賴於生產者任務。當生產者和消費者任務同時執行時,構建就會失敗。

此類驗證提高瞭構建的穩健性,使您能夠快速識別與輸入和輸出相關的問題。

您偶爾會想要禁用某些驗證,特別是當輸入文件可能實際上不存在時。這就是 Gradle 提供@Optional註釋的原因:您使用它來告訴 Gradle 特定輸入是可選的,因此如果相應的文件或目錄不存在,則構建不應失敗。

並行task

定義task輸入和輸出的另一個好處是:當使用–parallel選項時,Gradle 可以使用此信息來決定如何運行task。

例如,Gradle 將在選擇下一個要運行的任務時檢查task的輸出,並避免並發執行寫入同一輸出目錄的任務。

同樣,當另一個task正在運行消耗或創建一些文件時,Gradle 將使用有關task銷毀哪些文件的信息(例如,由Destroys註釋)來避免運行刪除這些文件的task,反之亦然。

它還可以確定創建一組文件的task已經運行,並且使用這些文件的task尚未運行,並且將避免在這中間運行刪除這些文件的task。

總得來說,通過以這種方式提供task的輸入和輸出信息,Gradle 可以推斷task之間的創建/消費/銷毀關系,並可以確保task執行不會違反這些關系。

增量編譯原理解析

上面我們介紹瞭如何自定義一個支持增量編譯的task,那麼它的原理是什麼呢?

在第一次執行task之前,Gradle 會獲取輸入的指紋。該指紋包含輸入文件的路徑和每個文件內容的哈希值。Gradle 然後執行task。如果任務成功完成,Gradle 會獲取輸出的指紋。該指紋包含一組輸出文件和每個文件內容的哈希值。Gradle 會在下次執行task時保留兩個指紋。

之後每次執行task之前,Gradle 都會獲取輸入和輸出的新指紋。如果新指紋與之前的指紋相同,Gradle 會假定輸出是最新的並跳過該task。如果它們不相同,Gradle 將執行task。Gradle 會在下次執行task時保留兩個指紋。

如果文件的統計信息(即lastModified和size)沒有改變,Gradle 將重用上次運行的文件指紋。這意味著當文件的統計信息沒有更改時,Gradle 不會檢測到更改。

Gradle 還將task的代碼視為task輸入的一部分。當task、其操作或其依賴項在執行之間發生變化時,Gradle 會認為該task已過期。

Gradle 瞭解文件屬性(例如,包含 Java classpath 的屬性)是否是順序敏感的。在比較此類屬性的指紋時,即使文件順序發生變化也會導致task過時。

請註意,如果task指定瞭輸出目錄,則自上次執行以來添加到該目錄的任何文件都將被忽略,並且不會導致任務過期。這是因為不相關的任務可以共享一個輸出目錄而不會相互幹擾。如果由於某種原因這不是您想要的行為,請考慮使用TaskOutputs.upToDateWhen(groovy.lang.Closure)

一些高端操作

上面介紹的內容涵蓋瞭您將遇到的大多數用例,但有些場景需要特殊處理

將@OutputDirectory鏈接到@InputFiles

當您想將一個task的輸出鏈接到另一個task的輸入時,類型通常匹配,例如,File可以將輸出屬性分配給File輸入。

不幸的是,當您希望將一個task的@OutputDirectory中的文件作為另一個task的@InputFiles屬性(類型FileCollection)的源時,這種方法就會失效。

例如,假設您想使用 Java 編譯task的輸出(通過destinationDir屬性)作為自定義task的輸入,該task檢測一組包含 Java 字節碼的文件。這個自定義task,我們稱之為Instrument,有一個使用@InputFiles註解的classFiles屬性。您最初可能會嘗試像這樣配置task:

tasks.register<Instrument>("badInstrumentClasses") {
    classFiles.from(fileTree(tasks.compileJava.map { it.destinationDir }))
    destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}

這段代碼沒有明顯的問題,但是您如果實際運行的話可以看到compileJava並沒有執行。在這種情況下,您需要通過dependsOn在instrumentClasses和compileJava之間添加顯式依賴。因為使用fileTree()意味著 Gradle 無法推斷task依賴本身。

一種解決方案是使用TaskOutputs.files屬性,如以下示例所示:

tasks.register<Instrument>("instrumentClasses") {
    classFiles.from(tasks.compileJava.map { it.outputs.files })
    destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}

或者,您可以使用project.files(),project.layout.files(),project.objects.fileCollection()來代替project.fileTree()

tasks.register<Instrument>("instrumentClasses2") {
    classFiles.from(layout.files(tasks.compileJava))
    destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}

請記住files(),layout.files()和objects.fileCollection()可以將task作為參數,而fileTree()不能。

這種方法的缺點是源task的所有文件輸出都成為目標task的輸入文件。如果源task隻有一個基於文件的輸出,就像JavaCompile一樣,那很好。但是如果你必須在多個輸出屬性中選擇一個,那麼你需要明確告訴 Gradle 哪個task使用以下builtBy方法生成輸入文件:

tasks.register<Instrument>("instrumentClassesBuiltBy") {
    classFiles.from(fileTree(tasks.compileJava.map { it.destinationDir }) {
        builtBy(tasks.compileJava)
    })
    destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}

當然你也可以通過dependsOn添加明確的task依賴,但是上面的方法提供瞭更多的語義,解釋瞭為什麼compileJava必須預先運行。

禁用up-to-date檢查

Gradle 會自動處理對輸出文件和目錄的up-to-date檢查,但如果task輸出完全是另一回事呢?也許它是對 Web 服務或數據庫表的更新。或者有時你有一個應該始終運行的task。

這就是doNotTrackState的作用,可以使用它來完全禁用task的up-to-date檢查,如下所示:

tasks.register<Instrument>("alwaysInstrumentClasses") {
    classFiles.from(layout.files(tasks.compileJava))
    destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
    doNotTrackState("Instrumentation needs to re-run every time")
}

如果你的自定義task需要始終運行,那麼您也可以在任務類上使用註解@UntrackedTaskTask

提供自定義up-to-date檢查

Gradle 會自動處理對輸出文件和目錄的up-to-date檢查,但如果task輸出是對 Web 服務或數據庫表的更新。在這種情況下,Gradle 無法知道如何檢查task是否是up-to-date的。

這是就是TaskOutputs.upToDateWhen方法的作用,使用它我們就可以自定義up-to-date檢查的邏輯。例如,您可以從數據庫中讀取數據庫模式的版本號。或者,您可以檢查數據庫表中的特定記錄是否存在或已更改。

請註意,up-to-date檢查應該節省您的時間。不要添加比task的標準執行花費更多時間的檢查。事實上,如果一個task經常需要運行,因為它很少是up-to-date的,那麼它可能根本不值得進行up-to-date檢查,如禁用up-to-date中所述。

一個常見的錯誤是使用upToDateWhen()而不是Task.onlyIf(). 如果您想根據與task輸入和輸出無關的某些條件跳過任務,那麼您應該使用onlyIf(). 例如,如果您想在設置或未設置特定屬性時跳過task

Finalizer Task

我們常常使用dependsOn來在一個task之前做一些工作,但如果我們想要在task執行之後做一些操作,該怎麼實現呢?

這裡我們可以用到finalizedBy方法

val taskX by tasks.registering {
    doLast {
        println("taskX")
    }
}
val taskY by tasks.registering {
    doLast {
        println("taskY")
    }
}
taskX { finalizedBy(taskY) }

如上所示,taskY將在taskX之後執行,需要註意的是finalizedBy並不是依賴關系,就算taskX執行失敗,taskY也將正常執行

Finalizer task在構建創建的資源無論構建失敗還是成功都必須清理的情況下很有用,一個示例是在集成測試任務中啟動的 Web 容器,即使某些測試失敗,也應該始終關閉它。這樣看來finalizedBy類似java中的finally

要指定Finalizer task,請使用Task.finalizedBy(java.lang.Object…​)方法。此方法接受task實例、task名稱或Task.dependsOn(java.lang.Object…​)接受的任何其他輸入

總結

到這裡這篇文章已經相當長瞭,gradle自定義task上手非常簡單,但實際上有非常多的細節,尤其是要支持增量編譯時。總得來說,為瞭寫出高效的,可緩存的,不拖慢編譯速度的task,還是有必要瞭解一下這些知識的

參考資料

docs.gradle.org/current/use…

以上就是Android開發之Gradle 進階Tasks深入瞭解的詳細內容,更多關於Android開發Gradle Tasks的資料請關註WalkonNet其它相關文章!

推薦閱讀: