2581 字
约 8 分钟
17
S06-X12 · Gradle 与构建缓存

Maven 项目迁到 Gradle,第一次 ./gradlew build 感觉飞快,第三天开始每次三分多钟,改一行代码把全工程重编一遍。原因通常不是 Gradle 慢,而是任务没有被正确声明输入输出:编译器不知道什么没变,只好全部重跑。加速能力全建立在"输入指纹"上,指纹算错,增量构建、构建缓存、配置缓存三件事同时失效。按 Gradle 8 拆。


1. 三阶段:配置阶段不做重活

阶段 做的事 耗时特征 违规后果
初始化(Initialization) 读 settings.gradle、决定参与哪些项目 短 —
配置(Configuration) 执行所有 build.gradle,构建 Task 图 只跑一次,每个子项目都执行 这里读文件/发请求 → 每次构建都付一遍
执行(Execution) 按依赖顺序跑 Task 只在需要时 —
// 反例:配置阶段就解析文件
def props = new Properties()
file("version.properties").withInputStream { props.load(it) }   // 每次构建、每个项目都读
version = props.getProperty("version")

// 正例:延迟到执行期,或直接用 providers
version = providers.fileContents(layout.projectDirectory.file("version.properties"))
        .asText.map { it.readLines().find { l -> l.startsWith("version=") }.split("=")[1] }

--configuration-cache 生效后,配置阶段结果被复用,build.gradle 里的配置期副作用会直接报错——它先把坏习惯变成失败,再给速度。


2. build.gradle → build.gradle.kts 迁移要点

项 Groovy DSL Kotlin DSL
属性赋值 version = "1.0"(实际是 setProperty) version = "1.0"(真实赋值)
插件应用 apply plugin: 'java' plugins { java }
依赖写法 implementation 'g:a:1.0' implementation("g:a:1.0")
任务配置 tasks.withType(JavaCompile) { ... } tasks.withType<JavaCompile> { ... }
类型检查 无,拼错运行期才炸 编译期报错、IDE 补全
迁移代价 — 语法重写,老插件可能更难读

迁移不是纯语法替换,重点是把字符串键改成强类型访问器:libs.versions.toml 会生成 libs.jackson.databind 这样的属性,拼错立刻编译失败。


3. 任务输入输出:决定"是否重跑"

一个任务只有把输入输出声明完整,Gradle 才能判断 UP-TO-DATE:

abstract class GenVersionFile : DefaultTask() {
    @get:Input abstract val version: Property<String>       // 影响产物的值
    @get:InputFile abstract val template: RegularFileProperty
    @get:OutputFile abstract val output: RegularFileProperty

    @TaskAction
    fun run() {
        output.get().asFile.writeText(template.get().asFile.readText().replace("@V@", version.get()))
    }
}
没声明的东西 表现 修法
环境变量 System.getenv("BUILD_NUMBER") 改了环境变量仍显示 UP-TO-DATE,产物是旧的 @Input + providers.environmentVariable
读取的文件 文件变了不重跑 @InputFile / @InputFiles
系统属性、时间戳 不可缓存(每次指纹都不同) 用 @Input 固定值,别把 now() 写进产物
输出目录 增量判断失效、缓存键不稳定 @OutputDirectory

"每次重跑"最典型的原因就是缺 @OutputFile:Gradle 找不到可比较的上次产物,只能当它变了。


4. 增量构建 ≠ 构建缓存

能力 判定依据 复用范围 跨机器 省的是
增量构建(incremental) 本机上次执行的输入/输出快照 同一工作目录 否 执行时间
构建缓存(build cache) 输入指纹(含 classpath、编译器版本)的哈希 任意工作目录 是(远程缓存) 执行时间
配置缓存(configuration cache) 配置阶段产物 同一机器 否 配置时间
守护进程(daemon) — 同一机器 否 JVM 启动与类加载

三者互补,但都要求任务可缓存:任务里用 doLast 改输入之外的路径、读 System.currentTimeMillis()、依赖绝对路径,缓存键就不稳定,命中率归零。


5. 远程构建缓存

// settings.gradle.kts
buildCache {
    local { isEnabled = true; directory = File(rootDir, ".gradle-cache") }
    remote<HttpBuildCache> {
        url = uri("https://cache.example.com/cache/")
        isPush = System.getenv("CI") == "true"    // 只让 CI 写,开发者只读
        isEnabled = true
        credentials { username = "builder"; password = System.getenv("CACHE_PWD") }
    }
}
取舍 本地缓存 远程缓存
命中范围 单机 全团队 / CI
运维成本 零 需要存储、鉴权、清理策略
失效风险 小 缓存投毒、密钥泄漏、无限增长
推荐策略 始终开 CI 写、开发读;按分支隔离 key

远程缓存的边界只有一句:缓存里的产物会被直接放进 classpath,所以 isPush 必须绑在可信身份上。


6. 配置缓存:把配置阶段也存下来

$ ./gradlew build --configuration-cache
$ ./gradlew build --configuration-cache-problems=warn   # 先降级观察
现象 含义 处理
Configuration cache entry stored 首次,正常 —
Configuration cache entry reused 命中,省掉配置时间 —
problems were found ... build failed 插件在配置期做了不可缓存的事 升级插件,或临时 =warn
configuration cache cannot be reused because ... 输入变了(如 -P 参数、环境变量) 正常失效,非故障

不兼容的典型来源:老插件在配置期读 System.getenv、把 Project 对象带到执行期、用 afterEvaluate 做副作用。主流插件基本可用,自研插件是主要障碍。


7. 版本目录与依赖锁

# S06-X12 · Gradle 与构建缓存
[versions]
jackson = "2.17.2"
springBoot = "3.3.6"

[libraries]
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
spring-boot-bom  = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" }

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }
dependencies {
    implementation(platform(libs.spring.boot.bom))
    implementation(libs.jackson.databind)
}
机制 解决的问题 何时用
版本目录 libs.versions.toml 版本散落各处、拼写不一致 多模块项目默认
platform() / BOM 传递依赖版本统一 与 Spring Boot 共存时
依赖锁 dependencyLocking 传递依赖漂移,构建不可重现 对可重现性有硬要求
resolutionStrategy.force 临时压制某个冲突 应急,事后要清理

依赖锁要先 --write-locks 生成 gradle.lockfile 并入库;不加锁的多模块项目,同一天两次构建可能拿到不同版本。


8. --scan 与 --profile

命令 产出 看什么
./gradlew build --scan 上传到 scans.gradle.com,生成网页报告 任务耗时排名、缓存命中率、弃用 API
./gradlew build --profile 本地 build/reports/profile/*.html 配置 vs 执行时间占比
./gradlew build --info 控制台日志 每个任务为何重跑

--info 里那句 Task :compileJava is not up-to-date because ... 是排错第一手证据。


9. 多项目结构

settings.gradle.kts
├── include(":core", ":service", ":api")
├── dependencyResolutionManagement { repositories { ... } }
└── buildCache { ... }
文件 管什么 常见错误
settings.gradle.kts 参与项目、仓库、缓存、插件管理 把仓库写进各模块 build.gradle,导致解析不一致
根 build.gradle.kts subprojects / allprojects 公共配置 在 allprojects 里做重活,配置期成本翻倍
模块 build.gradle.kts 模块自身依赖与任务 重复声明版本

Gradle 8 推荐 repositoriesMode.set(FAIL_ON_PROJECT_REPOS),从机制上禁止模块各自加仓库——仓库不一致是"publish 了却找不到"的头号原因。


10. 与 Maven 的对照

维度 Maven 3.9 Gradle 8
模型 声明式 POM,约定强 编程式,灵活
增量构建 编译插件内部处理,粒度粗 任务级输入输出,粒度细
跨机器缓存 无内置 远程 build cache
依赖调解 最短路径 + 先声明 默认取最高版本
多模块 聚合 + 继承分离 统一 settings + 项目树
学习曲线 低,坑在依赖 高,坑在生命周期
适合 传统 Java 后端、组织要求稳定 多语言、CI 时长敏感、需要定制

依赖调解的差异常被忽略:Maven 取"最近",Gradle 取"最高"。同一棵树迁过去版本整体上跳,NoSuchMethodError 会换一种形式出现。


11. 排错表

现象 原因 处理
每次构建全量重编 任务缺输入/输出声明 看 not up-to-date 原因,补注解
改了环境变量产物不变 未声明为输入 补 @Input
缓存命中率极低 任务不可缓存 移除不确定输入,@CacheableTask
配置缓存报错降级 插件在配置期做副作用 升级插件,先 --configuration-cache-problems=warn
找不到依赖,Maven 正常 仓库配置不一致 / 无 mavenLocal 之外的源 统一 dependencyResolutionManagement
版本各处不一致 无版本目录 引入 libs.versions.toml

12. 边界表

场景 该用的手段 理由
单机日常提速 daemon + 增量构建 成本为零,收益立现
团队共享产物 远程构建缓存 跨机器复用的唯一办法
CI 配置时间占比高 配置缓存 直接砍掉配置阶段
排查"为什么重跑" --info 的 not up-to-date 官方给出的唯一权威原因
完全可重现 依赖锁 + 固定 JDK 版本 指纹稳定的前提

一句话:Gradle 的速度是"声明完整"换来的。声明准确三件事一起生效,含糊则一起失效,看起来就像 Gradle 比 Maven 慢。


13. 自测

  1. 配置阶段执行了几次?为什么在那里读文件会拖慢每次构建?
  2. 任务缺 @OutputFile 会导致什么现象?如何在日志里确认?
  3. 增量构建与构建缓存复用的范围差别是什么?哪个能跨机器?
  4. 远程缓存为什么要把 isPush 限制在 CI 身份上?
  5. 配置缓存"不可以复用"和"报错失败"分别意味着什么?
  6. Gradle 与 Maven 的依赖调解规则差异,会在什么情况下制造线上异常?
S06-X12 · Gradle 与构建缓存
https://www.clxhxhhr.top/posts/5978/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。