Gradle 都做了哪些缓存?

前言

GradleAndroid的构建工具,它的主要目标就是实现快速的编译构建,而这主要就是通过缓存实现的。本文主要介绍Gradle的缓存机制,具体包括以下内容

  1. Gradle缓存机制
  2. Gradle内存缓存
  3. Gradle项目缓存
  4. Gradle本机缓存
  5. Gradle远程缓存

Gradle缓存机制

说起Gradle缓存,我们首先想到的可能就是build-cache,但是Gradle缓存机制远没有这么简单,如下图所示:

纵向来划分的话,Gradle缓存可以划分为配置阶段缓存,执行阶段缓存与依赖缓存三部分

横向来划分的话,Gradle缓存可以划分为内存缓存,项目缓存,本机缓存,远程缓存四个级别

下面我们就按照横向划分的方式来详细介绍一下Gradle的缓存机制

Gradle内存缓存

Gradle内存缓存主要是通过Gradle Daemon进程(即守护进程)实现的

那么Gradle守护进程是什么呢?起什么作用?

守护进程是作为后台进程运行的计算机程序,而不是在交互式用户的直接控制之下

GradleJava 虚拟机 (JVM) 上运行,并使用多个需要大量初始化时间的支持库。因此,有时启动起来似乎有点慢。

而这个问题的解决方案就是 Gradle Daemon:一个长期存在的后台进程,它可以更快地执行构建。主要是通过避免耗时的初始化操作,以及将有关的项目数据保存在内存中来实现

同时是否使用Daemon来执行构建对于使用都是透明的,它们使用起来基本一致,用户只需要配置是否使用它

获取守护进程状态

由于守护进程对用户来说几乎是透明的,因此我们在平常几乎不会接触到Daemon进程,但是当我们执行构建时可能会看到以下提示:

Starting a Gradle Daemon, 1 busy and 6 stopped Daemons could not be reused, use --status for details

这是说目前有6个已经终止的守护进程与一个忙碌的守护进程,因此需要重新启动一个守护进程,我们可以使用./gradlew --status命令来获取守护进程状态,可以获取以下输出

   PID STATUS   INFO
 82904 IDLE     7.3.3
 81804 STOPPED  (stop command received)
 50304 STOPPED  (by user or operating system)
 59118 STOPPED  (by user or operating system)

你可能会好奇,为什么我们的机器上会有多个守护进程?

Gradle 将创建一个新的守护进程而不是使用一个已经在运行的守护进程有几个原因。基本规则是,如果没有现有的空闲或兼容的守护程序可用,Gradle 将启动一个新的守护程序。Gradle 将杀死任何闲置 3 小时或更长时间的守护进程,因此您不必担心手动清理它们。

如何停止现有守护进程

如前所述,守护进程是一个后台进程。每个守护进程都会监控其内存使用量与系统总内存的比较,如果可用系统内存不足,则会在空闲时自行停止。如果您出于任何原因想明确停止运行守护进程,只需使用命令./gradlew --stop

或者如果你想直接禁用守护程序的话,您可以通过命令行选项添加--no-daemon,或者在gradle.properties中添加org.gradle.daemon=false

Gradle 3.0之后守护进程默认开启,构建速度得到了很大的提升,因此在通常情况下不建议关闭守护进程

守护进程如何使构建更快?

Gradle 守护进程是一个长期存在的进程。在多次构建之间,它将空闲地等待下一个构建。这有一个明显的好处,即多个构建只需要初始化一次,而不是每个构建一次。

同时现代 JVM 性能优化的一个重要部分是运行时代码优化(即JIT)。例如,HotSpotOracle 提供的 JVM 实现)在代码运行时将对其进行优化。优化是渐进的而不是瞬时的。也就是说,代码在执行过程中逐渐优化,这意味着后续构建可以更快的执行。使用 HotSpot 的实验表明,JIT优化通常需要 5 到 10 次构建才能稳定。因此守护进程的第一次构建和第十次构建之间的构建时间差异可能非常大。

守护进程还允许在多次构建之间进行内存缓存。例如,构建所需的类(例如插件、构建脚本)可以在构建之间保存在内存中。同样,Gradle 可以维护构建数据的内存缓存,例如任务输入和输出的哈希值,用于增量构建。

为了检测文件系统的变化并计算需要重新构建建的内容,Gradle 会在每次构建期间收集有关文件系统状态的大量信息。守护进程可以重用从上次构建中收集的信息并计算出需要重新构建的文件。这可以为增量构建节省大量时间,其中两次构建之间对文件系统的更改次数通常很少

总得来说,守护进程主要做了以下工作:

  1. 在多次构建之间重用,只需初始化一次,节省初始化时间
  2. 虚拟机JIT优化,代码越执行越快,因此在同一个守护进程中构建,后续构建也将越快
  3. 多次构建之中可以对构建脚本,构建插件,构建数据等进行内存缓存,以加快构建速度
  4. 可以检测两次构建之间的文件系统的变化,并计算出需要重新构建的文件,方便增量构建

Gradle项目缓存

在内存缓存之后,就是项目级别的缓存,项目级别的缓存主要存储在根目录的.gradle与各个模块的build目录中,其中configuration-cache存储在.gradle目录中,而各个Task的执行结果存储在我们熟悉的build目录中

配置阶段缓存

我们知道,Gradle 的生命周期可以分为大的三个部分:初始化阶段(Initialization Phase),配置阶段(Configuration Phase),执行阶段(Execution Phase)。

在任务执行阶段,Gradle提供了多种方式实现Task的缓存与重用(如up-to-date检测,增量编译,build-cache等)

除了任务执行阶段,任务配置阶段有时也比较耗时,目前AGP也支持了配置阶段缓存Configuration Cache,它可以缓存配置阶段的结果,当脚本没有发生改变时可以重用之前的结果

在越大的项目中配置阶段缓存的收益越大,module比较多的项目可能每次执行都要先配置20到30秒,尤其是增量编译时,配置的耗时可能都跟执行的耗时差不多了,而这正是configuration-cache的用武之地

目前configuration-cache还是实验特性,如果你想要开启的话可以在gradle.properties中添加以下代码

# configuration cache
org.gradle.unsafe.configuration-cache=true
org.gradle.unsafe.configuration-cache-problems=warn

当然打开Configuration Cache之后可能会有一些适配问题,如果是第三方插件,可尝试升级版本解决

如果是项目中自定义Task不支持的话,还需要适配一下Configuration Cache,适配Configuration Cache的核心思路其实很简单:不要在Task执行阶段调用外部不可序列化的对象(比如ProjectVariant)

android {
    applicationVariants.all { variant ->
        def mergeAssetTask = variant.getMergeAssetsProvider().get()
        mergeAssetTask.doLast {
           project.logger(variant.buildType.name)
        }
    }
}

如上所示,在doLast阶段调用了projectvariant对象,这两个对象是在配置阶段生成的,但是又无法序列化,因此这段代码无法适配Configuration Cache,需要修改如下:

android {
    applicationVariants.all { variant ->
    	def buildTypeName = variant.buildType.name
        def mergeAssetTask = variant.getMergeAssetsProvider().get()
        mergeAssetTask.doLast {
           logger(buildTypeName)
        }
    }
}

如上所示,提前读取出buildTypeName,因为它是String类型,可以被序列化,后续在执行阶段调用也没有问题了

总得来说,Configuration Cache适配并不复杂,但如果你的项目中自定义Task比较多的等方面,那可能就是个体力活了,比如 AGP 兼容 Configuration Cache 就修了 400 多个 ISSUE

Task输出缓存

Task输出缓存即我们最熟悉的各模块build目录,当我们调用./gradlew clean时清理的也是这部分缓存

任何构建工具的一个重要部分是避免重复工作。在编译过程中,就是在编译源文件后,除非发生了影响输出的更改(例如源文件的修改或输出文件的删除),无需重新编译它们。因为编译可能会花费大量时间,因此在不需要时跳过该步骤可以节省大量时间。

如上图所示,Task最基本的功能就是接受一些输入,进行一系列运算后生成输出。比如在编译过程中,Java源文件是输入,生成的classes文件是输出。Task的输出通常在build目录

Task的输入没有发生变化,则理论上它的输出也没有发生变化,那么此时该Task就可以标记up-to-date,跳过执行阶段,直接复用上次执行的输出,相信你在多次执行构建的时候看到过这个标记

当然,自定义Task要支持up-to-date需要明确输入与输出,关于具体的细节可以查看:Gradle 进阶(一):深入了解 Tasks

Gradle本机缓存

Gradle本机缓存即Gradle User Home路径下的caches目录,有时当我们运行./gradlew clean之后,重新编译项目还是很快,这是因为还有本机Build Cache的原因

本质上Build Cache与项目内up-to-date检查类似,都是在判断输入没有发生变化时可以直接跳过Task,不同之处在于,Build Cache可以在多个项目间复用

Build Cache开启

默认情况下,Build Cache并未启用。您可以通过以下几种方式启用Build Cache

  1. 在命令行添加--build-cacheGradle 将只为此构建使用Build Cache
  2. gradle.properties中添加org.gradle.caching=true,Gradle 将尝试为所有构建重用以前构建的输出,除非通过--no-build-cache明确禁用.

启用构建缓存后,它将在 Gradle 用户主目录中存储构建输出。

可缓存Task

由于Task描述了它的所有输入和输出,Gradle 可以计算一个构建缓存KeyKey基于其输入唯一地定义任务的输出。该构建缓存Key用于从构建缓存请求先前的输出或将新输出存储在构建缓存中。如果之前的构建输出已经被其他人存储在缓存中,那你就可以直接复用之前的结果

构建缓存Key由以下属性组成,与up-to-date检查类似:

  • Task类型及其classpath
  • 输出属性的名称
  • DSL 通过TaskInputs添加的属性的名称和值
  • Gradle 发行版、buildSrc 和插件的类路径
  • 构建脚本影响任务执行时的内容

同时Task还需要添加@CacheableTask注解以支持构建缓存,需要注意的是@CacheableTask注解不会被子类继承

如果查看源码的话,可以发现JavaCompileKotlinCompileTask都添加了@CacheableTask注解

总得来说,支持构建缓存的Task与支持up-to-dateTask基本一致,只需要添加一个@CacheableTask注解,当up-to-date检查失效时(比如项目内缓存被清除),则会尝试使用构建缓存,如下所示:

> gradle --build-cache assemble 
:compileJava FROM-CACHE 
:processResources 
:classes 
:jar 
:assemble 

BUILD SUCCESSFUL

如上所示,当build cache命中时,该Task会被标记为FROM-CACHE

本地依赖缓存

除了Build Cache之外,Gradle User Home目录还包括本地依赖缓存,所有远程下载的aar都在cache/modules-2目录下

这些aar可以在本地所有项目间共享,通过这种方式可以有效避免不同项目之间相同依赖的反复下载

需要注意的是,我们应该尽量使用稳定依赖,避免使用动态(Dynamic) 或者快照(SNAPSHOT) 版本依赖

当我们使用稳定依赖版本,当下载成功后,后续再有引用该依赖的地方都可以从缓存读取, 避免缓慢的网络下载

而动态和快照这两种版本引用会迫使 Gradle 链接远程仓库检查是否有更新的依赖可用, 如果有则下载后缓存到本地.默认情况下,这种缓存有效期为 24 小时. 可以通过以下方式调整缓存有效期:

configurations.all {
    resolutionStrategy.cacheDynamicVersionsFor(10, "minutes")     // 动态版本缓存时效
    resolutionStrategy.cacheChangingModulesFor(4, "hours")        // 快照版本缓存时效
}

动态版本和快照版本会影响编译速度, 尤其在网络状况不佳的情况下以及该依赖仅仅出现在内部repo的情况下. 因为Gradle会串行查询所有repo, 直到找到该依赖才会下载并缓存. 然而这两种依赖方式失效后就需要重新查询和下载.

同时这动态版本与快照版本也会导致Configuration Cache失效,因此应该尽量使用稳定版本

Gradle远程缓存

镜像repo

Gradle下载aar有时非常耗时,一种常见的操作时添加镜像repo,比如公开的阿里镜像等。或者部署公司内部的镜像repo,以加快在公司网络的访问速度,也是很常见的操作。

关于Gradle仓库配置还有一些小技巧:Gradle 在查找远程依赖的时候, 会串行查询所有repo中的maven地址, 直到找到可用的aar后下载. 因此把最快和最高命中率的仓库放在前面, 会有效减少configuration阶段所需的时间.

除了顺序以外, 并不是所有的仓库都提供所有的依赖, 尤其是有些公司会将业务aar放在内部搭建的仓库上. 这种情况下如果盲目增加repository会让Configuration时间变得难以接受. 我们通常需要将内部仓库放在最前, 同时明确指定哪些依赖可以去这里下载:

repositories {
    maven {
        url = uri("http://repo.mycompany.com/maven2")
        content {
            includeGroup("com.test")
        }
    }
    ...
}

如上所示,指定了com.testgroup可以去指定的仓库下载

远程Build Cache

上面介绍了本地的Build CacheBuild Cache 可以把之前构建过的 task 结果缓存起来, 一旦后面需要执行该 task 的时候直接使用缓存结果. 与增量编译不同的是, cache 是全局的, 对所有构建都生效.

Build Cache 不仅可以保存在本地($GRADLE_USER_HOME/caches), 也可以使用网络路径。

settings.gradle 中加入如下代码:

// settings.gradle.kts
buildCache {
    local<DirectoryBuildCache> {
        directory = File(rootDir, "build-cache")

        // 编译结果是否同步到本地缓存. local cache 默认 true
        push = true

        // 无用缓存清理时间
        removeUnusedEntriesAfterDays = 30
    }

    remote<HttpBuildCache> {
        url = uri("https://example.com:8123/cache/")

        // 编译结果是否同步到远程缓存服务器. remote cache 默认 false
        push = false

        credentials {
            username = "build-cache-user"
            password = "some-complicated-password"
        }

        // 如果遇到 https 不授信问题, 可以关闭校验. 默认 false
        isAllowUntrustedServer = true
    }
}

通常我们在 CI 编译脚本中 push = true, 而开发人员的机器上 push = false 避免缓存被污染.

当然,要实现Build Cache在多个机器上的共享,需要一个缓存服务器,官方提供了两种方式搭建缓存服务器: Docker镜像和jar包,详情可参考 Build Cache Node User Manual,这里就不缀述了。

总得来说,远程Build Cache应该也是一个可行的方案,试想如果我们有一个高性能的打包机,当每次打码提交时,都自动编译生成Build Cache,那么开发人员都可以高效地复用同一份Build Cache,以加快编译速度,而不是每次更新代码都需要在本机重新编译

参考链接


Gradle 都做了哪些缓存?

发布者

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注