使用新 Android Gradle 外掛加速您的應用構建

Android開發者發表於2022-04-06

自 2020 年底,Android Gradle 外掛 (AGP) 已經開始使用新的版本號規則,其版本號將與 Gradle 主要版本號保持一致,因此 AGP 4.2 之後的版本為 7.0 (目前最新的版本為 7.2)。在更新 Android Studio 時,您可能會收到一併將 Gradle 更新為最新可用版本的提示。為了獲得最佳效能,建議您使用 Gradle 和 Android Gradle 外掛這兩者的最新版本。Android Gradle 外掛的 7.0 版本更新帶來了許多實用的特性,本文將著重為您介紹其中的 Gradle 效能改進、配置快取和外掛擴充套件等方面的內容。

如果您更喜歡通過視訊瞭解此內容,請在此處檢視:

https://www.bilibili.com/vide...

△ 使用新 Android Gradle 外掛加速您的應用構建

Gradle 的效能改進

Kotlin 符號處理優化

Kotlin 符號處理 (Kotlin Symbol Processing,簡稱 KSP) 是 kapt (Kotlin annotation processing tool) 的替代品,它為 Kotlin 語言帶來了一流的註解處理能力,處理速度最快可以達到 kapt 的兩倍。目前已經有不少知名的軟體庫提供了相容 KSP 的註解處理器,比如 Room、Moshi、Kotishi 等等。因此我們建議,當您的應用中所用到的各種註解處理器都支援 KSP 時,應該儘快從 kapt 遷移到 KSP。

非傳遞性 R 類

啟用非傳遞性 R 類 (non-transitive R-class) 後,您應用中的 R 類將只會包含在子專案中宣告的資源,依賴項中的資源會被排除在外。這樣一來,子專案中的 R 類大小將會顯著減少。

這一改動可以在您向執行時依賴項中新增新資源時,避免重新編譯下游模組。在這種場景下,可以給您的應用帶來 40% 的效能提升。另外,在清理構建產物時,我們發現效能有 5% 到 10% 的改善。

您可以在 gradle.properties 檔案中新增下面的標記:

android.nonTransitiveRClass=true

△ 在 gradle.properties 中開啟非傳遞性 R 類功能

您也可以在 Android Studio Arctic Fox 及以上版本使用重構工具來啟用非傳遞性 R 類,具體需要您執行 Android Studio 選單欄的 Refactor --> Migrate to Non-transitive R Classes。這種方法還可以在必要時幫助您修改相關原始碼。目前,AndroidX 庫已經啟用此特性,因此 AAR 階段的產物中將不再包含來自傳遞性依賴項的資源。

Lint 效能優化

從 Android Gradle 外掛 7.0 版本開始,Lint 任務可以顯示為 "UP-TO-DATE",即如果模組的原始碼和資源沒有更改,那麼就不需要對該模組進行 Lint 分析任務。您需要在 build.gradle 中新增選項:

// build.gradle

android {
  ...
  lintOptions {
    checkDependencies true
  }
}

△ 在 build.gradle 中開啟 lint 效能優化

如此一來,Lint 分析任務就可以在各個模組中並行執行,從而顯著提升 Lint 任務執行的速度。

從 Android Gradle 外掛的 7.1.0-alpha 13 版本開始,Lint 分析任務相容了 Gradle 構建快取 (Gradle build cache),它可以通過 複用其他構建的結果來減少新構建的時間:

△ 不同 AGP 版本中 Lint 時間比較

△ 不同 AGP 版本中 Lint 時間比較

我們在一個演示專案中開啟了 Gradle 構建快取並設定 checkDependencies 為 true,然後分別使用 AGP 4.2、7.0 和 7.1 進行構建。從上圖中可看出,7.0 版本的構建速度是 4.2 的兩倍;並且在使用 AGP 7.1 時,由於所有 Lint 分析任務都命中了快取而帶來了更加顯著的速度提升。

您不但可以直接通過更新 Android Gradle 外掛版本獲得更好的 Lint 效能,還能通過一些配置來進一步提升效率。其中一種方法是使用可快取的 Lint 分析任務。要啟用 Gradle 的構建快取,您需要在 gradle.properties 檔案中開啟下面的標記 (參見 Build Cache):

org.gradle.caching=true

△ 在 gradle.properties 中開啟 Gradle 構建快取

另一種可改進 Lint 分析任務效能的方法是,在您條件允許的情況下給 Lint 分配更多的記憶體。

同時,我們建議您在 應用模組 的 Gradle 配置中為 lintOptions 塊新增:

checkDependencies true

△ 在模組的 build.gradle 中新增 checkDependencies 標記

雖然這樣不能讓 Lint 分析任務更快執行,但能夠讓 Lint 在分析您指定應用時捕捉到更多問題,並且為整個專案生成一份 Lint 報告。

Gradle 配置快取

△ Gradle 構建過程和階段劃分

△ Gradle 構建過程和階段劃分

每當 Gradle 開始構建時,它都會建立一個任務圖用於執行構建操作。我們稱這個過程為配置階段 (configuration phase),它通常會持續幾秒到數十秒。Gradle 配置快取可以將配置階段的輸出進行快取,並且在後續構建中複用這些快取。當配置快取命中,Gradle 會並行執行所有需要構建的任務。再加上依賴解析的結果也被快取了,整個 Gradle 構建的過程變得更加快速。

這裡需要說明,Gradle 配置快取和構建快取是不同的,後者快取的是構建任務的產物。

△ Build 配置的輸入內容

△ Build 配置的輸入內容

在構建過程中,您的構建設定決定了構建階段的結果。所以配置快取會將諸如 gradle.properties、構建檔案等輸入捕獲,放入快取中。這些內容同您請求構建的任務一起,唯一地確定了在構建中要執行的任務。

△ 配置快取帶來的效能提

△ 配置快取帶來的效能提升

上圖展示包含 24 個子專案的 Gradle 構建示例,這組構建使用了最新版本的 Kotlin、Gradle 和 Android Gradle 外掛。我們分別記錄全量構建、有 ABI 變動和無 ABI 變動增量構建場景下啟用配置快取前後的對比。這裡用新增新公有方法的方式進行增量構建,對應了 "有 ABI 變動" 的資料;用修改既有方法的實現來進行增量構建,對應了 "無 ABI 變動" 的資料。顯而易見,所有三個構建場景都出現了 20% 的速度提升。

接下來,結合程式碼,一探配置快取的工作原理:

project.tasks.register("mytask", MyTask).configure {
  it.classes.from(project.configurations.getByName("compileClasspath"))
  it.name.set(project.name)
}

△ 配置快取工作原理示例

在 Gradle 計算任務執行圖之前,我們尚處於配置階段。此時可以使用 Gradle 提供的 project、task 容器、configuration 容器等全域性物件來建立包含宣告的輸入和輸出的任務。如上程式碼中,我們註冊了一個任務並進行相應配置。您可以在其中看到全域性物件的多種用法,比如 project.tasks 和 project.configurations。

△ 儲存配置快取的過程

△ 儲存配置快取的過程

當所有任務都配置完成後,Gradle 可以根據我們的配置計算出最終的任務執行圖。隨後配置快取會將這個任務執行圖快取起來,並將各個任務的執行狀態進行序列化,再放入快取中。從上圖可以看到,所有的任務輸入也會被儲存到快取中,因此它們必須是特定的 Gradle 型別,或是可以序列化的資料。

△ 載入配置快取的過程

△ 載入配置快取的過程

最終,當某個配置快取被命中時,Gradle 會使用快取條目來建立任務例項。所以只有先前已經被序列化的狀態才會在新例項化的任務執行時被引用,這個階段也不允許使用對全域性狀態的引用。

△ 新的 Build Analyzer 工具皮膚

△ 新的 Build Analyzer 工具皮膚

我們在 Android Studio 的 Arctic Fox 版本新增了 Build Analyzer 工具來幫助您檢查構建是否相容配置快取。當您的構建任務完成後,開啟 Build Analyzer 皮膚,可以看到剛才構建配置過程花費的時間。如上圖所示,配置構建過程總共使用了 9.8 秒。點選 Optimize this 連結,新皮膚中會顯示更多資訊,如下圖所示:

△ Build Analyzer 提供的相容性報告

△ Build Analyzer 提供的相容性報告

如圖,構建用到的所有外掛都相容配置快取功能。點選 "Try Configuration cache in a build",IDE 會更新您的 gradle.properties 檔案,在其中啟用配置快取。在不完全相容的情況下,Build Analyzer 也可能會建議您將某些外掛更新到與配置快取相容的新版本。如果您的構建與配置快取不相容,那麼構建任務會失敗,Build Analyzer 會提供相應的除錯資訊供您參考。

一個不相容配置快取的例子:

abstract class GetGitShaTask extends DefaultTask {
  @OutputFile File getOutputFile() { return new File(project.buildDir, "sha.txt") }
  @TaskAction void process() {
    def stdout = new ByteArrayOutputStream()
    project.exec {
      it.commandLine("git", "rev-parse", "HEAD")
      standardOutput = stdout
    }
    getOutputFile().write(stdout.toString())
  }
}
project.tasks.register("myTask", GetGitShaTask)

我們有一個計算當前的 Git SHA 並將結果寫入輸出檔案的任務。它會執行一個 git 命令,然後將輸出內容寫入給定檔案中。我們在啟用配置快取的情況下執行這個構建任務,會出現兩個與配置快取相關的問題:

△ 配置快取報告的內容

△ 配置快取報告的內容

當您的構建任務與配置快取不相容時,Gradle 會生成一個包含了問題列表和詳細資訊的 HTML 檔案。在我們的例子中,這個 HTML 檔案會包含圖中的內容:

△ 配置快取錯誤報告

△ 配置快取錯誤報告

您可以從這些內容中找到各個出錯點對應的堆疊跟蹤資訊。如示例中構建指令碼的第 5 和第 11 行導致了這些問題。回看原始檔,您會發現第一個問題是因為返回輸出檔案位置的函式中使用了 project.buildDir 方法;第二個問題是因為 TaskAction 中使用了 project 變數,這是由於啟用配置快取後,我們無法在執行時訪問全域性狀態。

我們可以對上面的程式碼進行一些修改。為了在執行時呼叫 project.buildDir 方法,我們可以在任務屬性中儲存必要的資訊,這樣就可以一起被存入配置快取中了。另外,我們可以使用 Gradle 服務注入來執行外部程式並獲取輸出資訊。下面是修改後的程式碼供您參考:

abstract class GetGitShaTask extends DefaultTask {
  @OutputFile abstract RegularFileProperty getOutputFile()
  @javax.inject.Inject abstract ExecOperations getExecOperations()
  @TaskAction void process() {
    def stdout = new ByteArrayOutputStream()
    getExecOperations().exec {
      // ...
    }
    getOutputFile().get().asFile.write(stdout.toString())
  }
}
project.tasks.register("myTask", GetGitShaTask) {
  getOutputFile().set(
    project.layout.buildDirectory.file("sha.txt")
  )
}

△ 使用 Gradle 服務注入來執行外部程式 (與配置快取相容的構建任務例子)

您可以從新程式碼發現,我們在任務註冊期間,將輸出檔案的位置捕獲並存入了某個屬性中,然後通過注入的 Gradle 服務來執行 git 命令並獲得命令的輸出資訊。這段程式碼還有另外一個好處,由於 Gradle 的延遲屬性是實際使用時才計算的,所以 buildDirectory 發生的變動會自動反映在任務的輸出檔案位置上。

關於 Gradle 配置快取和如何遷移您的構建任務的更多資訊,請參閱:

擴充套件 Android Gradle 外掛

不少開發者都發現在自己的構建任務中,有一些操作是無法通過 Android Gradle 外掛直接實現的。所以接下來我們會著重探討如何通過 AGP 新增的 Variant 和 Artifact API 來實現這些功能。

△ Android Gradle 外掛的執行結構

△ Android Gradle 外掛的執行結構

build 型別 (buildTypes) 和產品變種 (productFlavors) 都是您專案的 build.gradle 檔案中的概念。Android Gradle 外掛會根據您的這些定義生成不同的變體物件,並對應各自的構建任務。這些構建任務的輸出會被註冊為與任務對應的工件 (artifact),並且根據需要被分為公有工件和私有工件。早期版本的 AGP API 允許您訪問這些構建任務,但是這些 API 並不穩健,因為每個任務的具體實現細節是會發生改變的。Android Gradle 外掛在 7.0 版本中引入了新的 API,讓您可以訪問到這些變體物件和一些中間工件。這樣一來,開發者就可以在不操作構建任務的前提下改變構建行為。

修改構建時產生的工件

在這個部分,我們要通過修改 asset 的工件來向 APK 新增額外的 asset,程式碼如下:

// buildSrc/src/main/kotlin/AddAssetTask.kt
abstract class AddAssetTask: DefaultTask() {
  @get:Input
  abstract val content: Property<String>
 
  @get:OutputDirectory
  abstract val outputDir: DirectoryProperty
 
  @TaskAction
  fun taskAction() {
    File(outputDir.asFile.get(), "extra.txt").writeText(content.get())  
  }
}

△ 向 APK 新增額外的 asset

上面的程式碼定義了一個名為 AddAssetTask 的任務,它只有一個字串輸入內容屬性和一個輸出目錄屬性 (DirectoryProperty 型別)。這個任務的作用是將輸入字串寫入輸出目錄中的檔案。隨後我們需要在 ToyPlugin.kt 中編寫一個外掛,利用 Variant 和 Artifact API 來將 AddAssetTask 的例項連線到對應的工件:

// buildSrc/src/main/kotlin/ToyPlugin.kt
abstract class ToyPlugin: Plugin<Project> {
  override fun apply(project: Project) {
    val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java)
 
    androidComponents.onVariants { variant ->
      val taskProvider = 
        project.tasks.register(variant.name + "AddAsset", AddAssetTask::class.java) {
          it.content.set("foo")
        }
 
      // 核心部分
      variant.artifacts
        .use(taskProvider)
        .wireWith(AddAssetTask::outputDir)
        .toAppendTo(MultipleArtifact.ASSETS)
    }
  }
}

△ 將 AddAssetTask 例項連線到對應的工件

上述程式碼中的核心部分會將任務的輸出目錄新增到 asset 目錄的集合中,並正確連線任務依賴項。這段程式碼中我們將額外 asset 的內容硬編碼為 "foo",但後面的步驟我們會對這裡進行更改,還請您閱讀時留意。

△ 可供開發者操作的中間工件舉例

△ 可供開發者操作的中間工件舉例

上圖中展示了您可以訪問到的幾種中間工件,我們的 Toy 示例中就用到了其中的 ASSETS 工件。Android Gradle 外掛為不同工件提供了額外的訪問方式,比如當您想要校驗某個工件的內容時,可以通過下面的程式碼來獲得 AAR 工件:

androidComponents.onVariants { variant ->
  val aar: RegularFileProperty = variant.artifacts.get(AAR)
}

△ 獲取 AAR 工件

請參閱 Android 開發者文件 Variant API、工件和任務 獲取關於 Android Gradle 外掛新 Variants 和 Artifact API 的資料,這些資料可以幫助您更深入瞭解如何與中間工件進行互動。

修改和擴充套件 DSL

接下來我們需要修改 Android Gradle 外掛的 DSL,從而允許我們設定額外 asset 的內容。新版本的 Android Gradle 外掛允許您為自定義外掛編寫額外的 DSL 內容,所以我們會用這種方式來編輯每個構建型別的額外 asset。下面的程式碼展示了我們對模組的 build.gradle 檔案的修改。

// app/build.gradle
 
android {
  ...
  buildTypes {
    release {
      toy 
        content = "Hello World"
      }
    }
  }
}

△ 在 build.gradle 中新增自定義 DSL

另外,為了能夠擴充套件 Android Gradle 外掛的 DSL,我們需要建立一個簡單的介面。您可以參照下面一段程式碼:

// buildSrc/src/main/kotlin/ToyExtension.kt
 
interface ToyExtension {
  var content: String?
}

△ 定義 toyExtension 介面

定義好介面之後,我們需要為每一個 build 型別新增新定義的擴充套件:

// buildSrc/src/main/kotlin/ToyPlugin.kt
 
abstract class ToyPlugin: Plugin<Project> {
  override fun apply(project: Project) {
    val android = project.extensions.getByType(ApplicationExtension::class.java)
 
    android.buildTypes.forEach {
      it.extensions.add("toy", ToyExtension::class.java)
    }
    // ...
  }
}

△ 為所有 build 型別新增新定義的擴充套件

您也可以使用自定義介面擴充套件產品變種,不過在這個例子中我們不需要這樣做。我們還需要對 ToyPlugin.kt 作進一步修改,讓外掛可以獲取到我們在 DSL 中為每個變體定義的 asset 內容:

// buildSrc/src/main/kotlin/ToyPlugin.kt
abstract class ToyPlugin: Plugin<Project> {
  override fun apply(project: Project) {
    // ...
    // 注意這裡省略了上一段程式碼增加的內容
    val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java)
 
    androidComponents.onVariants { variant ->
      val buildType = android.buildTypes.getByName(variant.buildType)
      val toyExtension = buildType.extensions.findByName("toy") as? ToyExtension
 
      val content = toyExtension?.content ?: "foo"
      val taskProvider = 
        project.tasks.register(variant.name + "AddAsset", AddAssetTask::class.java) {
          it.content.set(content)
        }
 
      // 注意這裡省略了修改工件的部分
      // ...
    }
  }
}

△ 在產品變體中使用自定義 DSL

上述程式碼中,我們增加了一段程式碼用於獲取新增的 toyExtension 定義的內容,也就是剛才修改 DSL 時為每個 build 型別定義的額外 asset。需要您注意,我們這裡定義了備選 asset 內容,也就是當您沒有為某個 build 型別定義 asset 時,會預設使用的值。

使用 Variant API 新增自定義屬性

您還可以用類似擴充套件 DSL 的方法來擴充套件 Variant API,具體來說就是向 Android Gradle 外掛的 Variant 物件中新增您自己的 Gradle 屬性或某種 Gradle Provider。相比僅擴充套件 DSL,擴充套件 Variant API 有這樣一些優勢:

  1. DSL 值是固定的,但自定義變體屬性可以使用構建任務的輸出,Gradle 會自動處理所有構建任務的依賴項。
  2. 您可以很方便地為每個變體的自定義變體屬性設定獨立的值。
  3. 與自定義 DSL 相比,自定義變體屬效能提供與其他外掛之間更簡單、穩健的互動。

當我們需要新增自定義變體屬性時,首先要建立一個簡單的介面:

// buildSrc/src/main/kotlin/ToyVariantExtension.kt
 
interface ToyVariantExtension {
  val content: Property<String>
}
 
// 比較之前的 ToyExtension (您不需要在程式碼中包括這部分) 
interface ToyExtension {
  val content: String?
}

△ 定義帶有自定義變體屬性的擴充套件 (對比普通擴充套件)

通過與先前的 ToyExtension 定義對比,您會注意到我們使用了 Property 而不是可空字串型別。這樣做是為了與 Android Gradle 外掛內部的程式碼習慣保持一致,既能支援您將任務的輸出作為自定義屬性的值,又避免您再去考慮複雜的外掛排序過程。其他外掛也可以設定屬性值,至於發生在 Toy 外掛之前還是之後都沒有影響。下面的程式碼展示了使用自定義屬性的方式:

// app/build.gradle
androidComponents {
  onVariants(
    selector().all(),
    { variant ->
      variant.getExtension(ToyVariantExtension.class)
        ?.content
        ?.set("Hello ${variant.name}")
    }
  )
}

△ 在 build.gradle 中使用帶有自定義變體屬性的擴充套件

雖然這樣的寫法沒有直接擴充套件 DSL 那樣簡單,但它可以很方便地為每個變體設定自定義屬性的值。相應的,還需要修改 ToyPlugin.kt 檔案:

// buildSrc/src/main/kotlin/ToyPlugin.kt
 
abstract class ToyPlugin: Plugin<Project> {
  override fun apply(project: Project) {
    // ...
    // 注意這裡省略了部分內容
    val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java)
 
    androidComponents.beforeVariants { variantBuilder ->
      val buildType = android.buildTypes.getByName(variantBuilder.buildType)
      val toyExtension = buildType.extensions.findByName("toy") as? ToyExtension
 
      val variantExtension = project.objects.newInstance(ToyVariantExtension::class.java)
      variantExtension.content.set(toyExtension?.content ?: "foo")
      variantBuilder.registerExtension(ToyVariantExtension::class.java, variantExtension)
 
      // 注意這裡省略了部分內容
      // ...
    }
  }
}

△ 註冊帶有自定義變體屬性的 AGP 擴充套件

在這段程式碼裡,我們建立了 ToyVariantExtension 的例項,首先用 toy DSL 中的值作為自定義變體屬性對應的 Property 的預設值,隨後將這個例項註冊到變體物件上。您會發現我們使用了 beforeVariants 而不是 onVariants,這是由於變體擴充套件必須在 beforeVariants 塊中註冊,只有這樣,onVariants 塊中的其他外掛才可以使用新註冊的擴充套件。另外需要您注意,我們在 beforeVariants 塊中獲取了自定義 toy DSL 中的值,這個操作其實是安全的。因為當呼叫 beforeVariants 回撥時,DSL 的值會被當作最終結果並鎖定,也就不會產生額外的安全問題。獲取到 toy DSL 中的值後,我們將它賦值給自定義變體屬性,並最終在變體上註冊新的擴充套件 (ToyVariantExtension)。

完成 beforeVariants 塊的各項操作後,我們可以繼續在 onVariants 塊將自定義變體屬性賦值給任務輸入了。這個過程很簡單,請參考下面的程式碼:

// buildSrc/src/main/kotlin/ToyPlugin.kt
 
abstract class ToyPlugin: Plugin<Project> {
  override fun apply(project: Project) {
    // ...
    // 注意這裡省略了上一段展示內容
 
    androidComponents.onVariants { variant ->
      val content = variant.getExtension(VariantExtension::class.java)?.content
      val taskProvider = 
        project.tasks.register(variant.name + "AddAsset", AddAssetTask::class.java) {
          it.content.set(content)
        }
 
      // 注意這裡省略了修改工件的部分
      // ...
    }
  }
}

△ 使用自定義變體屬性

上面這段程式碼很好地展示了使用自定義變體屬性的優勢,特別是當您有多個需要以變體專用的方式進行互動的外掛時更是如此。如果其他外掛也想設定您的自定義變體屬性,或者將屬性用於它們的構建任務,也只需要使用類似上述 onVariants 程式碼塊的方式。

如果您想要了解更多關於擴充套件 Android Gradle 外掛的內容,敬請關注我們的 Gradle 與 AGP 構建 API 系列文章。您也可以閱讀 Android 開發者 文件: 擴充套件 Android Gradle 外掛 或者研讀 GitHub 上的 AGP Cookbook。在不久的將來,我們還會推出更多構建和同步方面的改進,敬請關注。

下一步工作

Project Isolation

Gradle Project Isolation 是基於配置快取的一個新特性,旨在提供更快地構建和同步速度。每個專案的配置都是彼此隔離的,不允許跨專案的引用,於是 Gradle 可以快取每個專案的同步 (sync) 結果,每當構建檔案發生變化,只有受影響的專案會被重新配置。目前這個功能還在開發中,您可以在 gradle.properties 檔案中新增 org.gradle.unsafe.isolated-projects=true 開關來嘗試這個特性 (需要 Gradle 7.2 及以上版本) 。

改進 Kotlin 增量編譯

我們還和 JetBrains 一起合作改進 Kotlin 的增量編譯,目標是支援所有的增量編譯場景,比如修改 Android 資源、新增外部依賴項或修改非 Kotlin 的上游子專案。

感謝所有開發者們的支援,感謝大家試用我們的預覽版工具並提供問題反饋。請您持續關注我們的進展,也歡迎您遇到問題時與我們溝通。

歡迎您 點選這裡 向我們提交反饋,或分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支援!

相關文章