Administrator
发布于 2026-08-30 / 42 阅读
0
0

远程构建缓存实战:从 Local Cache 到团队级 Build Cache Node

系列文章《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 的构建缓存分为两种:

类型

存储位置

作用范围

典型命中场景

Local Build Cache

~/.gradle/caches/build-cache-1

当前开发机

同一台机器反复切换分支

Remote Build Cache

远程 HTTP 服务,如 Build Cache Node

整个团队/CI 集群

同事已经编译过同一 commit,你 pull 下来直接用

它们的命中逻辑本质上一样:用 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 官方提供两种远程缓存方案:

  1. Gradle Enterprise / Develocity:商业方案,自带缓存、构建扫描、趋势分析。适合预算充足的大团队。

  2. 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.internal

  • cache-ios.internal

  • cache-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,想让它进缓存,必须满足三个条件:

  1. 用 @CacheableTask 注解

  2. 所有输入用 @InputFile、@InputDirectory、@Input 声明

  3. 输出用 @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 常见未命中原因

现象

可能原因

处理

所有 Task 都未命中

远程缓存未启用或 URL 配错

检查 settings.gradle.kts 和网络

同一 commit CI 命中但本地未命中

路径敏感设为 ABSOLUTE

改为 RELATIVE

部分自定义 Task 未命中

缺少 @CacheableTask 或输入未声明

补全注解和输入

缓存命中率波动

缓存空间满了,旧产物被清理

增大 max-size 或缩短保留周期

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 避坑清单

  1. 不要把签名信息当输入:签名文件变更会触发全量缓存失效。建议把签名 Task 排除在缓存外。

  2. 统一 JDK 版本:不同 JDK 编译出的字节码可能不同,CI 和开发机尽量用同一个大版本。

  3. Gradle 版本一致:Gradle/AGP/Kotlin 插件版本差一个小版本,缓存 key 可能就不一样。

  4. 清理策略要配好:缓存无限增长会撑爆磁盘,建议按 7 天滚动清理,或按容量上限自动淘汰。

5.4 缓存治理要落到制度上

技术方案只是第一步。要让远程缓存长期有效,建议建立几条团队规则:

  • 每次升级 Gradle/AGP/Kotlin 插件后,先在 CI 上跑一轮完整缓存预热

  • 禁止在个人开发机上开启 isPush=true,避免污染公共缓存

  • 每周 review 一次 Build Scan 的缓存命中率趋势,命中率持续下降要专项排查

  • 把缓存命中率纳入 CI 健康度看板,和构建时长一起监控

小结与下篇预告

远程构建缓存是把“编译一次,处处复用”落到实处的关键基础设施。它不是改个开关就能生效的魔法,而是需要:

  • 一个稳定的缓存服务

  • 合理的推送/拉取权限

  • 对 Task 输入输出的规范化声明

  • 命中率的持续监控

下一篇我们把目光转向 CI。当工程大到单台机器编译要几十分钟时,光有缓存还不够,还需要构建分片和测试分片来进一步缩短反馈时间。


参考阅读

  • Gradle Build Cache 官方文档

  • Gradle Build Cache Node 部署指南

  • AGP 缓存兼容 Task 列表


评论