前言
Gradle
是Android
的构建工具,它的主要目标就是实现快速的编译构建,而这主要就是通过缓存实现的。本文主要介绍Gradle
的缓存机制,具体包括以下内容
Gradle
缓存机制Gradle
内存缓存Gradle
项目缓存Gradle
本机缓存Gradle
远程缓存
Gradle
缓存机制
说起Gradle
缓存,我们首先想到的可能就是build-cache
,但是Gradle
缓存机制远没有这么简单,如下图所示:
纵向来划分的话,Gradle
缓存可以划分为配置阶段缓存,执行阶段缓存与依赖缓存三部分
横向来划分的话,Gradle
缓存可以划分为内存缓存,项目缓存,本机缓存,远程缓存四个级别
下面我们就按照横向划分的方式来详细介绍一下Gradle
的缓存机制
Gradle
内存缓存
Gradle
内存缓存主要是通过Gradle Daemon
进程(即守护进程)实现的
那么Gradle
守护进程是什么呢?起什么作用?
守护进程是作为后台进程运行的计算机程序,而不是在交互式用户的直接控制之下
Gradle
在 Java 虚拟机
(JVM
) 上运行,并使用多个需要大量初始化时间的支持库。因此,有时启动起来似乎有点慢。
而这个问题的解决方案就是 Gradle Daemon
:一个长期存在的后台进程,它可以更快地执行构建。主要是通过避免耗时的初始化操作,以及将有关的项目数据保存在内存中来实现
同时是否使用Daemon
来执行构建对于使用都是透明的,它们使用起来基本一致,用户只需要配置是否使用它
获取守护进程状态
由于守护进程对用户来说几乎是透明的,因此我们在平常几乎不会接触到Daemon
进程,但是当我们执行构建时可能会看到以下提示:
1 |
Starting a Gradle Daemon, 1 busy and 6 stopped Daemons could not be reused, use --status for details |
这是说目前有6个已经终止的守护进程与一个忙碌的守护进程,因此需要重新启动一个守护进程,我们可以使用./gradlew --status
命令来获取守护进程状态,可以获取以下输出
1 2 3 4 5 |
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
)。例如,HotSpot
(Oracle
提供的 JVM
实现)在代码运行时将对其进行优化。优化是渐进的而不是瞬时的。也就是说,代码在执行过程中逐渐优化,这意味着后续构建可以更快的执行。使用 HotSpot
的实验表明,JIT
优化通常需要 5 到 10 次构建才能稳定。因此守护进程的第一次构建和第十次构建之间的构建时间差异可能非常大。
守护进程还允许在多次构建之间进行内存缓存。例如,构建所需的类(例如插件、构建脚本)可以在构建之间保存在内存中。同样,Gradle
可以维护构建数据的内存缓存,例如任务输入和输出的哈希值,用于增量构建。
为了检测文件系统的变化并计算需要重新构建建的内容,Gradle
会在每次构建期间收集有关文件系统状态的大量信息。守护进程可以重用从上次构建中收集的信息并计算出需要重新构建的文件。这可以为增量构建节省大量时间,其中两次构建之间对文件系统的更改次数通常很少
总得来说,守护进程主要做了以下工作:
- 在多次构建之间重用,只需初始化一次,节省初始化时间
- 虚拟机
JIT
优化,代码越执行越快,因此在同一个守护进程中构建,后续构建也将越快 - 多次构建之中可以对构建脚本,构建插件,构建数据等进行内存缓存,以加快构建速度
- 可以检测两次构建之间的文件系统的变化,并计算出需要重新构建的文件,方便增量构建
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
中添加以下代码
1 2 3 |
# configuration cache org.gradle.unsafe.configuration-cache=true org.gradle.unsafe.configuration-cache-problems=warn |
当然打开Configuration Cache
之后可能会有一些适配问题,如果是第三方插件,可尝试升级版本解决
如果是项目中自定义Task
不支持的话,还需要适配一下Configuration Cache
,适配Configuration Cache
的核心思路其实很简单:不要在Task
执行阶段调用外部不可序列化的对象(比如Project
与Variant
)
1 2 3 4 5 6 7 8 |
android { applicationVariants.all { variant -> def mergeAssetTask = variant.getMergeAssetsProvider().get() mergeAssetTask.doLast { project.logger(variant.buildType.name) } } } |
如上所示,在doLast
阶段调用了project
与variant
对象,这两个对象是在配置阶段生成的,但是又无法序列化,因此这段代码无法适配Configuration Cache
,需要修改如下:
1 2 3 4 5 6 7 8 9 |
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
:
- 在命令行添加
--build-cache
,Gradle
将只为此构建使用Build Cache
。 gradle.properties
中添加org.gradle.caching=true
,Gradle
将尝试为所有构建重用以前构建的输出,除非通过--no-build-cache
明确禁用.
启用构建缓存后,它将在 Gradle
用户主目录中存储构建输出。
可缓存Task
由于Task
描述了它的所有输入和输出,Gradle
可以计算一个构建缓存Key
,Key
基于其输入唯一地定义任务的输出。该构建缓存Key
用于从构建缓存请求先前的输出或将新输出存储在构建缓存中。如果之前的构建输出已经被其他人存储在缓存中,那你就可以直接复用之前的结果
构建缓存Key
由以下属性组成,与up-to-date
检查类似:
Task
类型及其classpath
- 输出属性的名称
DSL
通过TaskInputs
添加的属性的名称和值Gradle
发行版、buildSrc
和插件的类路径- 构建脚本影响任务执行时的内容
同时Task
还需要添加@CacheableTask
注解以支持构建缓存,需要注意的是@CacheableTask
注解不会被子类继承
如果查看源码的话,可以发现JavaCompile
,KotlinCompile
等Task
都添加了@CacheableTask
注解
总得来说,支持构建缓存的Task
与支持up-to-date
的Task
基本一致,只需要添加一个@CacheableTask
注解,当up-to-date
检查失效时(比如项目内缓存被清除),则会尝试使用构建缓存,如下所示:
1 2 3 4 5 6 7 8 |
> 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 小时. 可以通过以下方式调整缓存有效期:
1 2 3 4 |
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
时间变得难以接受. 我们通常需要将内部仓库放在最前, 同时明确指定哪些依赖可以去这里下载:
1 2 3 4 5 6 7 8 9 |
repositories { maven { url = uri("http://repo.mycompany.com/maven2") content { includeGroup("com.test") } } ... } |
如上所示,指定了com.test
的group
可以去指定的仓库下载
远程Build Cache
上面介绍了本地的Build Cache
,Build Cache
可以把之前构建过的 task
结果缓存起来, 一旦后面需要执行该 task
的时候直接使用缓存结果. 与增量编译不同的是, cache
是全局的, 对所有构建都生效.
Build Cache
不仅可以保存在本地($GRADLE_USER_HOME/caches)
, 也可以使用网络路径。
在 settings.gradle
中加入如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// 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
,以加快编译速度,而不是每次更新代码都需要在本机重新编译