系列文章《Android Gradle 构建体系深度实践(续)》第 1 篇
推荐阅读时间:10 分钟
前言
上一季我们把 Gradle 生命周期、Transform/Artifacts API 和自定义插件聊完了。这一季我们把镜头往前挪一点,看看构建提速里最容易被低估、也最容易踩坑的一环——构建缓存(Build Cache)。
很多人对缓存的理解还停留在 org.gradle.caching=true。打开开关确实有收益,但当团队变大、模块变多、CI 机器变成几十台的池子,本地磁盘缓存很快就不够用了。远程构建缓存的价值就在这里:同一套代码,只要 CI 编译过一次,全公司都可以直接复用。
这三篇文章会沿着“缓存 → CI 分片 → 跨平台协同”的脉络展开。第一篇先聊远程构建缓存怎么真正落地:
本地缓存和远程缓存的命中规则到底差在哪
怎么搭一个生产可用的 Gradle Build Cache Node
哪些 Task 适合缓存,哪些坚决不能缓存
缓存未命中时怎么排查
一、Build Cache 不是简单的“编译结果复用”
1.1 本地缓存 vs 远程缓存
Gradle 的构建缓存分为两种:
它们的命中逻辑本质上一样:用 Task 的 inputs(源码、依赖、编译参数)算一个 key,如果 outputs(class、jar、aar、apk)已经存在,就直接拉下来,跳过执行。
差别在于来源。本地缓存只能命中你自己机器上之前跑过的结果;远程缓存可以把 CI 上的编译结果分享给所有人。一个 200 模块的工程,在 CI 上跑完一次 assembleRelease 后,开发机切换分支再编译时,大量中间产物可以直接从远程拉取。
1.2 为什么远程缓存收益比想象的大
一个常见误区:远程缓存只对 CI 有用。实际上它的核心价值在开发机。
假设你早上从 main 切到一个 feature 分支,改了 3 个文件。没有远程缓存时,Gradle 需要重新编译所有被影响的模块,可能涉及 40 个模块。有了远程缓存,只要这些模块在 CI 上被 main 分支编译过,你的开发机就能直接下载 .class 和 .aar,把增量编译变成“几乎零编译”。
实际能感受到收益的场景主要是:
clean build 场景:CI 已经编译完,开发机只做下载和打包
切换分支:大量中间产物命中缓存
多人协作:共用基础库编译结果
当然,收益取决于模块拆分粒度和缓存命中率。后面会讲怎么监控这两个指标。
1.3 构建缓存和 Configuration Cache 不是一回事
Gradle 8 之后还有两个容易混淆的概念:
Build Cache:缓存 Task 的输出,解决“重复编译”。
Configuration Cache:缓存 Configuration 阶段的结果,解决“每次构建都要重新执行所有 build 脚本”的问题。
两者可以一起开:
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
Configuration Cache 对自定义插件的兼容性要求更高。如果插件里用了 buildscript 或访问了 project 的某些动态属性,可能会触发 configuration-cache-problems。建议先在开发机开启 warn 模式跑一段时间,把警告清完再强制启用。
二、搭建远程缓存:Gradle Build Cache Node
2.1 选型
Gradle 官方提供两种远程缓存方案:
Gradle Enterprise / Develocity:商业方案,自带缓存、构建扫描、趋势分析。适合预算充足的大团队。
Gradle Build Cache Node:独立可部署的缓存服务,免费使用(需接受许可协议),可以架在内部服务器或云主机上。
下面以 Build Cache Node 为例,演示从下载到上线的完整流程。
2.2 启动 Build Cache Node
从 Gradle 官网下载 build-cache-node 的 jar,创建一个启动脚本 start-cache-node.sh:
#!/bin/bash
java -jar build-cache-node-20.0.jar \
--port 5071 \
--data-dir /data/gradle-build-cache \
--max-size 100GiB
启动后访问 http://cache-node.internal:5071,会看到管理页面。首次需要设置 admin 密码和缓存大小。
2.3 项目接入
在 settings.gradle.kts 里配置远程缓存:
pluginManagement { /* ... */ }
dependencyResolutionManagement { /* ... */ }
buildCache {
local {
isEnabled = true
directory = File(rootDir, "build-cache-local")
removeUnusedEntriesAfterDays = 7
}
remote(HttpBuildCache::class.java) {
url = uri("http://cache-node.internal:5071/cache/")
isEnabled = true
isPush = !System.getenv().containsKey("CI") // 只有 CI 推送
credentials {
username = "ci-bot"
password = System.getenv("BUILD_CACHE_PASSWORD")
}
}
}
关键点:
isPush通常只在 CI 上开启,避免开发机把有问题的产物推上去。密码不要写死在仓库里,走环境变量或 CI secret。
缓存 URL 建议用 HTTPS 或内网专线,避免中间人篡改编译产物。
2.4 权限与隔离
如果公司有多条业务线,建议按产品线划分缓存空间,而不是共用同一个 Node。Build Cache Node 支持按用户/密码隔离,但更简单的方式是部署多个实例,分别对应:
cache-android.internalcache-ios.internalcache-kmp.internal
这样可以避免一条业务线的异常产物污染另一条业务线,也方便单独清理。
2.5 高可用部署建议
Build Cache Node 单点故障会直接影响 CI。生产环境建议前面挂一层反向代理做负载均衡和 TLS 终止:
server {
listen 443 ssl;
server_name gradle-cache.example.com;
ssl_certificate /etc/nginx/ssl/cache.crt;
ssl_certificate_key /etc/nginx/ssl/cache.key;
location /cache/ {
proxy_pass http://cache-node-pool/;
proxy_set_header Host $host;
client_max_body_size 2G;
}
}
后端可以跑两个 Node 实例,共享同一个 NFS/S3 数据目录,或者各自独立、由负载均衡按 key 哈希分发。缓存数据本身是无状态的,丢了也只是下次重新编译,不会破坏构建正确性,所以高可用设计可以相对简单。
另外要定期做容量监控。缓存是按 LRU 淘汰的,但如果写入速度持续大于淘汰速度,磁盘会写满。建议设置告警:当磁盘使用超过 80% 时扩容或清理。
三、让 Task 真正可缓存
3.1 默认缓存行为
很多内置 Task(如 JavaCompile、KotlinCompile、MergeResources、DexBuilder)默认已经支持缓存。但 AGP 里有一些 Task 因为依赖环境或路径,默认不缓存,比如:
签名相关的 Task
使用绝对路径的 Task
非确定性输出的 Task
3.2 自定义 Task 怎么支持缓存
回顾上一季的 AndroidVariantTask,如果你写了一个自定义 Task,想让它进缓存,必须满足三个条件:
用
@CacheableTask注解所有输入用
@InputFile、@InputDirectory、@Input声明输出用
@OutputFile、@OutputDirectory声明,且路径稳定
示例:
@CacheableTask
abstract class GenerateApiDocTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val schemaFile: RegularFileProperty
@get:Input
abstract val packageName: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
// 读取 schemaFile,生成 API 文档到 outputDir
}
}
@PathSensitive(PathSensitivity.RELATIVE) 很重要。如果用 ABSOLUTE,不同机器上的绝对路径不同,会导致 key 不一致,缓存永远命中不了。
3.3 这些 Task 不要缓存
缓存不是万能药。下面几类 Task 缓存了反而有害:
输出包含时间戳或随机数的 Task
依赖本机环境变量/绝对路径的 Task
执行外部网络请求的 Task
测试 Task(结果可能依赖运行时的环境状态)
如果你发现缓存命中后产物异常,优先检查 Task 输出是否稳定。可以用 diff -r 对比两次执行的输出目录。
3.4 用 TaskOutputCachingListener 做诊断
Gradle 提供了 TaskOutputCachingListener 接口,可以在构建脚本里注册,打印每个 Task 的缓存命中详情:
gradle.taskGraph.whenReady {
val listener = object : TaskOutputCachingListener {
override fun onCachedOutputIdentified(task: Task, origin: OriginMetadata) {
println("[CACHE HIT] ${task.path} from ${origin.executor} at ${origin.executionTime}")
}
override fun onCacheMiss(task: Task) {
println("[CACHE MISS] ${task.path}")
}
}
gradle.addListener(listener)
}
在 CI 里开启这个日志,结合 Build Scan 可以快速定位哪些 Task 总是 miss。注意这个接口在 Gradle 新版本中可能有变动,需要以当前版本文档为准。
四、命中率的监控与排查
4.1 用 Build Scan 看缓存命中
运行一次 build 后生成 Build Scan:
./gradlew assembleDebug --scan
在扫描报告的 Build cache 标签页里,你能看到:
总共尝试了多少次缓存命中
命中/未命中的 Task 数量
未命中的原因分类:output not found / input changed / not cacheable
4.2 常见未命中原因
4.3 一个排查脚本
在 CI 里加一行诊断,方便复盘:
./gradlew assembleDebug \
--build-cache \
-Dorg.gradle.caching.debug=true \
--profile \
--scan
--profile 会生成 build/reports/profile 下的 HTML 报告,里面能看到每个 Task 的执行时间和缓存状态。配合 --scan 几乎可以定位所有缓存问题。
五、实战:把 clean build 从“重编译”变成“重下载”
5.1 我们的配置
一个 180 模块的 Android 项目,接入远程缓存后的核心配置:
buildCache {
local {
isEnabled = true
removeUnusedEntriesAfterDays = 3
}
remote(HttpBuildCache::class.java) {
url = uri("https://gradle-cache.example.com/cache/")
isEnabled = true
isPush = providers.environmentVariable("CI").isPresent
credentials {
username = "ci"
password = providers.environmentVariable("CACHE_PWD").get()
}
}
}
CI pipeline 在 assembleRelease 成功后推送缓存。开发机默认只拉取。
5.2 效果观察
在 main 分支最新 commit 上,CI 已经跑完一次完整编译。开发机切到该 commit 执行 clean build:
未开启远程缓存:约 11 分钟
开启远程缓存:约 3 分钟
这 3 分钟里,大部分时间花在下载缓存条目和最后的打包链接上,真正的 Kotlin/Java 编译被大量跳过。注意,这个数字和团队网络、模块粒度强相关,不要盲目照搬,但方向是确定的。
5.3 避坑清单
不要把签名信息当输入:签名文件变更会触发全量缓存失效。建议把签名 Task 排除在缓存外。
统一 JDK 版本:不同 JDK 编译出的字节码可能不同,CI 和开发机尽量用同一个大版本。
Gradle 版本一致:Gradle/AGP/Kotlin 插件版本差一个小版本,缓存 key 可能就不一样。
清理策略要配好:缓存无限增长会撑爆磁盘,建议按 7 天滚动清理,或按容量上限自动淘汰。
5.4 缓存治理要落到制度上
技术方案只是第一步。要让远程缓存长期有效,建议建立几条团队规则:
每次升级 Gradle/AGP/Kotlin 插件后,先在 CI 上跑一轮完整缓存预热
禁止在个人开发机上开启
isPush=true,避免污染公共缓存每周 review 一次 Build Scan 的缓存命中率趋势,命中率持续下降要专项排查
把缓存命中率纳入 CI 健康度看板,和构建时长一起监控
小结与下篇预告
远程构建缓存是把“编译一次,处处复用”落到实处的关键基础设施。它不是改个开关就能生效的魔法,而是需要:
一个稳定的缓存服务
合理的推送/拉取权限
对 Task 输入输出的规范化声明
命中率的持续监控
下一篇我们把目光转向 CI。当工程大到单台机器编译要几十分钟时,光有缓存还不够,还需要构建分片和测试分片来进一步缩短反馈时间。
参考阅读
Gradle Build Cache 官方文档
Gradle Build Cache Node 部署指南
AGP 缓存兼容 Task 列表