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

2,075 阅读11分钟

什么是配置缓存?

配置缓存是一个提升 IDE 和命令行构建速度的基础构建块。这是 Gradle 6.6 版本提供的一个高度实验性功能,它可以使构建系统记录一次任务的图谱信息,并在接下来的构建中进行复用,从而避免再一次配置整个工程。这一功能也是配置阶段改进的延续,这些改进中引入了 惰性配置 (lazy configuration),以避免在构建的配置阶段进行不必要的工作。这些改进对于快速迭代开发的重要性不言自明,而后者也是 Android Studio 团队所持续关注的一个用例。

性能改进

这一功能的主要目标便是提升构建速度。在 Android 版 Santa Tracker 工程的基准化分析中,对于启用了配置缓存的构建过程,我们测量出其在 Android Studio 中的总构建时间减少了 35% (从 688ms 到 443ms,测试平台为 Linux,使用 Intel® Xeon® Gold 6154 CPU @ 3.00GHz )。下图展示了使用和不使用配置缓存进行 100 次构建的平均总构建时间 (以毫秒为单位):

对于一些工程,配置阶段可能会消耗 10 秒钟以上,节省时间的效果也因此更加显著。无论运行的是全新构建、增量构建还是更新构建,配置阶段的开销都是相同的。要衡量您的构建过程中配置阶段所消耗的时间,可以以空运行模式 (dry run mode) 运行任务,例如: ./gradlew :app:assembleDebug --dry-run

为了进一步避免重复运行配置过程,配置缓存还允许来自同一工程的任务并行运行。以前,只有利用 Worker API 的任务可以同时运行,但是由于配置缓存可以确保任务独立且无法访问全局共享状态 (例如 Project 实例),因此可以默认启用此行为。而且,依赖关系解析结果可以在运行间进行缓存,从而有助于优化整体构建时间。

如何试用?

配置缓存功能现在还处于实验阶段,我们希望您可以尝试它并向我们提供反馈。为了在您的构建中使用它,需要保证所有工程所应用的所有插件都是兼容的,这是为了安全地 (反) 序列化任务图。您可能需要更新某些 Gradle 插件。您可以通过此 issue 来获取受支持插件的完整列表,如果您使用的插件不在其中,请在它们的问题跟踪器中提交问题,并从 Gradle 问题中链接至该 issue。 

最新版的 Android Gradle 插件版本为 4.1 (目前为 4.1.0-rc03),但如果您希望获取所有的错误修复,请尝试最新的 4.2 版本 (目前为 4.2.0-alpha13)。Gradle 的版本应为 6.6,同时如果您正在使用 Kotlin,请将 Kotlin Gradle 插件更新为最新的 1.4 版 (相关 Kotlin issue)。最后使用以下代码更新 gradle.properties:

org.gradle.unsafe.configuration-cache=true
# 小心使用这一标记,因为有些插件还没有完全兼容
org.gradle.unsafe.configuration-cache-problems=warn

查看所有 Android Gradle 插件版本,请参考如下页面:

maven.google.com/web/index.h…

如果启用了配置缓存,您应该可以在第一次运行时通过 Android Studio 的 Build 输出窗口或命令行看到 "Calculating task graph as no configuration cache is available for tasks…" (由于当前任务没有可用配置,正在生成任务图谱...) 字样;而在第二次运行中会复用配置缓存,所以输出中会包含 "Reusing configuration cache. (复用配置缓存)"。

无论您遇到任何问题,都可以在 Android Studio issue 跟踪 或 Gradle issue 跟踪 中向我们反馈。

它是如何工作的?

想要深入了解配置缓存,我们要从了解构建的配置阶段开始。就算您开启了配置缓存,第一次构建仍会经历这一过程。在配置阶段,所有被包含的工程 (在评估 settings.gradle 时获取) 都会依据其构建文件的评估结果进行配置。通常首先会应用所有插件,同时 DSL 对象会被实例化;接下来会继续评估构建文件,而 DSL 对象将会被分配您所指定的值。当构建文件的评估完成时,会调用 Android Gradle 插件 (以及许多遵循相同模式的其他插件) 的 Project.afterEvaluate 回调。在此回调的调用期间,Android Gradle 插件会完成其绝大部分的工作,包括创建变体以及注册任务。

在评估 DSL 以及注册任务之后,接下来的阶段会构建一个任务图。您所要求执行的任务以及它们所依赖的任务都会被完全配置。这一过程将会持续到触达没有依赖的叶子任务为止。配置的这一阶段将会输出一个任务图,Gradle 中的调度机制会使用该任务图来运行构建操作。当任务图被完成后,配置缓存会将其存储在磁盘中 (在 Gradle 6.6 中位于根工程的 .gradle/configuration-cache directory 目录下) 。它可以序列化所有的 Gradle-managed 类型 (如 FileCollectionPropertyProvider) 以及所有用户定义的可序列化类型。在此阶段结束时,每个任务的状态都将被完全记录并保留下来。

在第二次构建时,假设 Gradle 能够复用记录的缓存,则会加载所请求任务的任务图、跳过 DSL 评估,任务配置等。这意味着所有任务都将被实例化,而它们的所有属性都将从缓存中加载。从这一时刻起,构建过程基本与无缓存构建无异,区别只是默认情况下可以并行运行任务以及复用缓存中的依赖项解析结果的优势。

为了保证正确性,Gradle 会持续跟踪会影响已缓存的任务图的所有输入,包括构建文件、请求执行的任务以及配置过程中对于 Gradle 和系统属性的的访问。请求运行一组不同的任务会产生一个不同的任务图,所以需要创建一个新的缓存记录。一个需要使状态失效的例子是: 您修改了 build 文件或 buildSrc,并向环境变量或系统属性传递了一个不同的值。为了检测这类变更,构建系统会创建一个缓存任务图时所使用的 build 文件的快照;此外,它还会检测 buildSrc 中是否有未更新的任务。最后,任何会影响配置阶段的值都应当被包装为 Gradle-managed 类型,这有助于构建系统对配置阶段中所使用的变量进行持续跟踪。

使用兼容的 Gradle API

构建中应用的所有 Gradle 插件都必须与配置缓存兼容,Gradle 也因此引入了一组新的 API。下面是我们对于配置缓存和新 API 所带来的约束进行的考察:

在任务中使用 Project 实例

Gradle 插件中最常见的兼容性问题来自于在任务操作中使用 Task.getProject()。在使用配置缓存时,为了保持每个任务完全独立,任务将无法访问这一共享状态。由于 Project 实例可以访问 TaskContainerConfigurationContainer 以及其他在启用缓存的运行期间不会填充的对象,从而导致反映出无效的状态,所以禁用它是必须的。引入了很多可替代的 API,比如用于延迟对象创建的 ObjectFactory,还有可以用于获取项目文件系统分布情况的接口,比如 ProjectLayout,如果需要在构建中启动进程,可以使用 ExecOperations。您可以参考 完整的 API 列表 来进行迁移工作。

访问 Gradle/系统 属性与环境变量

如果您使用系统属性、Gradle 属性、环境变量或者额外文件来指定构建的逻辑输入时,会产生怎样的结果?构建系统已经在跟踪 build 文件的修改,但是任何影响任务图的额外值都应当使用 ProviderFactory API 进行获取。下面的示例展示了如何获取影响配置的 enableTask 系统属性值,以及如何获取仅作为任务输入的系统属性 anotherFlag。如果前者的值发生改变,则缓存失效;而如果后者的值改变,则缓存会被复用,而任务也不会处于最新的状态:

val systemProperty = project.providers.systemProperty("enableTask").forUseAtConfigurationTime()
if (systemProperty.orNull == "enabled") {
    project.tasks.register("myTask", …) {
        it.anotherFlag.set(project.providers.systemProperty("anotherFlag"))
    }
}

在内部,Gradle 会对在配置阶段解析的值提供者 (value provider) 进行持续跟踪,每个值提供者都会被视为一个构建逻辑输入。另外,除非调用 Provider.forUseAtConfigurationTime(),否则无法解析提供者,从而使得意外引入配置阶段输入的情况很难发生。如前文所述,任何 Gradle 会在 build 文件发生改变时使配置缓存失效,这一特性与 ProviderFactory API 一起确保了 Gradle 可以捕获影响任务图的所有内容。

在任务间共享工作

如果您希望可以在任务间共享一些工作,例如: 避免多次连接到网络服务器或者避免多次解析某些信息,那么可以使用兼容配置缓存的 共享构建服务 来进行实现。就像任务一样,构建服务可以包含输入信息,并且这些内容会在第一次运行后序列化。缓存的运行将会简单地反序列化参数并实例化任务所需的构建服务。构建服务的额外好处是它与构建生命周期非常契合,如果您希望在构建完成后释放一些资源,那么在您的构建服务中使用 AutoCloseable 便可以实现这一功能。由于无法被安全地序列化至磁盘,添加构建监听的操作与配置缓存不兼容。

从迁移 Android Gradle 插件获得的经验教训

在努力使 Android Gradle 插件兼容配置缓存的过程中,我们学到了一些可能对插件和脚本作者有用的东西。

首先,在启用配置缓存后,如果在构建输出中看到下面这样的内容,不要气馁,因为许多问题都是重复的,可以轻松解决:

428 problems were found reusing the configuration cache, 4 of which seem unique.

(在复用配置缓存后,发现了 428 处问题,其中 4 处看起来比较特别)

通过迁移到新的 API,我们可以轻松解决许多问题。例如:

旧代码

abstract class MyTask: DefaultTask() {
    @TaskAction
    fun process() {
        project.exec(…)
        project.logger().log(…)
    }
}

迁移过的代码

abstract class MyTask: DefaultTask() {
   
   @get:Inject
   abstract val execOperations: ExecOperations
   
   @TaskAction
   fun process() {
       execOperations.exec(…)
       this.logger.log(…)
   }
}

如果您仍在任务中使用 Project 实例,那么您需要找到一个替代 API。对于大多数情况,都会有一个兼容的 API,您只需直接迁移即可。

另一个方便之处是避免了在任务创建时创建不可序列化或者开销昂贵的对象,作为替代,会在我们的任务操作中需要时才创建它们。例如,在下面的示例中,我们不必强制要求 Handler 类型可被序列化,因为我们仅在需要时才创建它:

旧代码

abstract class Mytask: DefaultTask() {
    private val handler: Handler by lazy { createHandler(someInput) }
    
    @TaskAction
    fun process() {
        handler.doSomething(…)
    }
}

迁移过的代码

abstract class Mytask: DefaultTask() {
    
    @TaskAction
    fun process() {
        val handler = createHandler(someInput)
    }
}

在创作任务时,请确保任务输入正确反映了任务在执行过程中所需的一切。避免访问环境对象或任何可以从 Project 实例访问的其他对象。例如: 如果您的插件创建了配置,请将其作为 FileCollection 传递给任务。如果您需要构建目录位置,请将其记录在 task 的属性中:

旧代码

abstract class MyTask: DefaulTask() {
    private val userConfiguration: MyDslObjects
    
    @InputFiles
    fun getClasses(): FileCollection {
        return project.configurations.getByName(userConfiguration.name)
    }
  
    @Internal
    fun getBuildDir(): File {
        return project.buildDir
    }
  
    @TaskAction
    fun process() { … }
}

迁移过的代码

abstract class MyTask: DefaulTask() {
    @get:InputFiles
    abstract val classes: ConfigurableFileCollection
   
    @get:Internal
    abstract val buildDir: DirectoryProperty
   
    @TaskAction
    fun process() { … }
}

project.tasks.register("myTask", MyTask::class.java) {
    it.classes.from(project.configurations.getByName(userConfiguration.name))
    it.buildDir.set(project.layout.buildDirectory)
}

Android Gradle 插件曾依赖的一种常见模式,是在首次使用时初始化一些对象,将其存储在静态字段中,并利用构建监听器在构建完成时清除这些状态。正如上文所述,针对这种用例应当使用 共享构建服务。请参阅下面的示例以了解如何使用它:

abstract MyBuildService: BuildService<BuildServiceParameters.None>, AutoCloseable {
    
    fun doAndCacheSomeComplexWork() { ... }
 
    override fun close() {
        // 清除所有状态,释放内存
    }
}

abstract class MyTask: DefaultTask() {
    @get:Internal
    abstract val myService: Property<MyBuildService>
}

最后一条建议是,当您实现自定义可序列化类型时,要注意被序列化的内容。确保不要序列化派生属性,并让这些属性成为临时的或使用函数作为替代。举例来说,在缓存运行时,您将会为 allLines 属性获取到一个旧的值,因此这一操作是必须的。

旧代码

class StringsFromFiles(private val inputs: FileCollection) {
    val allLines = inputFiles.files.flatMap { it.readLines() }
}

迁移过的代码

class StringsFromFiles(private val inputs: FileCollection):  Serializable {
    
    fun getAllLines() {
        return inputFiles.files.flatMap { it.readLines() }
    }
}

配置缓存目前还处于实验阶段,我们希望您可以尝试并向我们提供反馈。您可以通过 Android Studio issue 跟踪 或 Gradle 的 issue 跟踪 向我们报告您所遇到的任何问题。

编码愉快!