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. 自测
- 配置阶段执行了几次?为什么在那里读文件会拖慢每次构建?
- 任务缺
@OutputFile会导致什么现象?如何在日志里确认? - 增量构建与构建缓存复用的范围差别是什么?哪个能跨机器?
- 远程缓存为什么要把
isPush限制在 CI 身份上? - 配置缓存"不可以复用"和"报错失败"分别意味着什么?
- Gradle 与 Maven 的依赖调解规则差异,会在什么情况下制造线上异常?