使用新 Android Gradle 插件加速您的应用构建

2022-04-01 20:05:09 浏览数 (1)

自 2020 年底,Android Gradle 插件 (AGP) 已经开始使用新的版本号规则,其版本号将与 Gradle 主要版本号保持一致,因此 AGP 4.2 之后的版本为 7.0 (目前最新的版本为 7.2)。在更新 Android Studio 时,您可能会收到一并将 Gradle 更新为最新可用版本的提示。为了获得最佳性能,建议您使用 Gradle 和 Android Gradle 插件这两者的最新版本。Android Gradle 插件的 7.0 版本更新带来了许多实用的特性,本文将着重为您介绍其中的 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 文件中添加下面的标记:

代码语言:javascript复制
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 中添加选项:

代码语言:javascript复制
// 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 时间比较

我们在一个演示项目中开启了 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):

代码语言:javascript复制
org.gradle.caching=true

△ 在 gradle.properties 中开启 Gradle 构建缓存

另一种可改进 Lint 分析任务性能的方法是,在您条件允许的情况下给 Lint 分配更多的内存。

同时,我们建议您在 应用模块 的 Gradle 配置中为 lintOptions 块添加:

代码语言:javascript复制
checkDependencies true

△ 在模块的 build.gradle 中添加 checkDependencies 标记

虽然这样不能让 Lint 分析任务更快执行,但能够让 Lint 在分析您指定应用时捕捉到更多问题,并且为整个项目生成一份 Lint 报告。

Gradle 配置缓存

△ Gradle 构建过程和阶段划分

每当 Gradle 开始构建时,它都会创建一个任务图用于执行构建操作。我们称这个过程为配置阶段 (configuration phase),它通常会持续几秒到数十秒。Gradle 配置缓存可以将配置阶段的输出进行缓存,并且在后续构建中复用这些缓存。当配置缓存命中,Gradle 会并行执行所有需要构建的任务。再加上依赖解析的结果也被缓存了,整个 Gradle 构建的过程变得更加快速。

这里需要说明,Gradle 配置缓存和构建缓存是不同的,后者缓存的是构建任务的产物。

△ Build 配置的输入内容

在构建过程中,您的构建设置决定了构建阶段的结果。所以配置缓存会将诸如 gradle.properties、构建文件等输入捕获,放入缓存中。这些内容同您请求构建的任务一起,唯一地确定了在构建中要执行的任务。

△ 配置缓存带来的性能提升

上图展示包含 24 个子项目的 Gradle 构建示例,这组构建使用了最新版本的 Kotlin、Gradle 和 Android Gradle 插件。我们分别记录全量构建、有 ABI 变动和无 ABI 变动增量构建场景下启用配置缓存前后的对比。这里用添加新公有方法的方式进行增量构建,对应了 "有 ABI 变动" 的数据;用修改既有方法的实现来进行增量构建,对应了 "无 ABI 变动" 的数据。显而易见,所有三个构建场景都出现了 20% 的速度提升。

接下来,结合代码,一探配置缓存的工作原理:

代码语言:javascript复制
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 工具面板

我们在 Android Studio 的 Arctic Fox 版本添加了 Build Analyzer 工具来帮助您检查构建是否兼容配置缓存。当您的构建任务完成后,打开 Build Analyzer 面板,可以看到刚才构建配置过程花费的时间。如上图所示,配置构建过程总共使用了 9.8 秒。点击 Optimize this 链接,新面板中会显示更多信息,如下图所示:

△ Build Analyzer 提供的兼容性报告

如图,构建用到的所有插件都兼容配置缓存功能。点击 "Try Configuration cache in a build",IDE 会更新您的 gradle.properties 文件,在其中启用配置缓存。在不完全兼容的情况下,Build Analyzer 也可能会建议您将某些插件更新到与配置缓存兼容的新版本。如果您的构建与配置缓存不兼容,那么构建任务会失败,Build Analyzer 会提供相应的调试信息供您参考。

一个不兼容配置缓存的例子:

代码语言:javascript复制
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 服务注入来执行外部进程并获取输出信息。下面是修改后的代码供您参考:

代码语言:javascript复制
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 配置缓存和如何迁移您的构建任务的更多信息,请参阅:

  • Gradle 文档
  • 深入探索 Android Gradle 插件的缓存配置

扩展 Android Gradle 插件

不少开发者都发现在自己的构建任务中,有一些操作是无法通过 Android Gradle 插件直接实现的。所以接下来我们会着重探讨如何通过 AGP 新增的 Variant 和 Artifact API 来实现这些功能。

△ Android Gradle 插件的执行结构

build 类型 (buildTypes) 和产品变种 (productFlavors) 都是您项目的 build.gradle 文件中的概念。Android Gradle 插件会根据您的这些定义生成不同的变体对象,并对应各自的构建任务。这些构建任务的输出会被注册为与任务对应的工件 (artifact),并且根据需要被分为公有工件和私有工件。早期版本的 AGP API 允许您访问这些构建任务,但是这些 API 并不稳健,因为每个任务的具体实现细节是会发生改变的。Android Gradle 插件在 7.0 版本中引入了新的 API,让您可以访问到这些变体对象和一些中间工件。这样一来,开发者就可以在不操作构建任务的前提下改变构建行为。

修改构建时产生的工件

在这个部分,我们要通过修改 asset 的工件来向 APK 添加额外的 asset,代码如下:

代码语言:javascript复制
// 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 的实例连接到对应的工件:

代码语言:javascript复制
// 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 工件:

代码语言:javascript复制
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 文件的修改。

代码语言:javascript复制
// app/build.gradle
 
android {
  ...
  buildTypes {
    release {
      toy 
        content = "Hello World"
      }
    }
  }
}

△ 在 build.gradle 中添加自定义 DSL

另外,为了能够扩展 Android Gradle 插件的 DSL,我们需要创建一个简单的接口。您可以参照下面一段代码:

代码语言:javascript复制
// buildSrc/src/main/kotlin/ToyExtension.kt
 
interface ToyExtension {
  var content: String?
}

△ 定义 toyExtension 接口

定义好接口之后,我们需要为每一个 build 类型添加新定义的扩展:

代码语言:javascript复制
// 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 内容:

代码语言:javascript复制
// 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 相比,自定义变体属性能提供与其他插件之间更简单、稳健的交互。

当我们需要添加自定义变体属性时,首先要创建一个简单的接口:

代码语言:javascript复制
// buildSrc/src/main/kotlin/ToyVariantExtension.kt
 
interface ToyVariantExtension {
  val content: Property<String>
}
 
// 比较之前的 ToyExtension (您不需要在代码中包括这部分) 
interface ToyExtension {
  val content: String?
}

△ 定义带有自定义变体属性的扩展 (对比普通扩展)

通过与先前的 ToyExtension 定义对比,您会注意到我们使用了 Property 而不是可空字符串类型。这样做是为了与 Android Gradle 插件内部的代码习惯保持一致,既能支持您将任务的输出作为自定义属性的值,又避免您再去考虑复杂的插件排序过程。其他插件也可以设置属性值,至于发生在 Toy 插件之前还是之后都没有影响。下面的代码展示了使用自定义属性的方式:

代码语言:javascript复制
// app/build.gradle
androidComponents {
  onVariants(
    selector().all(),
    { variant ->
      variant.getExtension(ToyVariantExtension.class)
        ?.content
        ?.set("Hello ${variant.name}")
    }
  )
}

△ 在 build.gradle 中使用带有自定义变体属性的扩展

虽然这样的写法没有直接扩展 DSL 那样简单,但它可以很方便地为每个变体设置自定义属性的值。相应的,还需要修改 ToyPlugin.kt 文件:

代码语言:javascript复制
// 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 块将自定义变体属性赋值给任务输入了。这个过程很简单,请参考下面的代码:

代码语言:javascript复制
// 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 的上游子项目。

感谢所有开发者们的支持,感谢大家试用我们的预览版工具并提供问题反馈。请您持续关注我们的进展,也欢迎您遇到问题时与我们沟通。

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!

0 人点赞