
先问一句你所在的项目是不是也这样——改了一行日志代码等整个项目编译、打包跑完水都接回来喝完了结果还没跑完。如果你在一个多模块的 Gradle 项目里待过这种场景应该不陌生。Gradle 的增量构建就是专门来治这个心病的。它的核心目标很朴素只构建修改过的模块与代码其余没动过的地方全部跳过。这篇文章我打算从机制讲到实操再把我这些年踩过的增量构建相关的坑和排查思路都整理出来适合刚接触 Gradle、被“全量构建”折磨的开发者也适合已经用 Gradle 但发现“明明配了增量却还是每次都全量”的团队参考。1. 增量构建到底在解决什么问题——先把思路理清1.1 构建变慢慢在哪很多团队一开始没意识到构建慢的根本原因不是“代码太多”而是“重复做了太多没用的事”。一个典型的多模块项目可能有 app、core、feature-login、feature-order 等十几个甚至几十个模块。你只改了 feature-login 里的一个常量理论上只有这一个模块的编译和它下游的打包受影响但如果没有增量构建Gradle 会老老实实地把整个依赖链上的任务全部重跑一遍。为什么因为它不知道哪些输入没变或者你的任务压根没有声明输入输出它只能默认“每次都重跑最安全”。从构建耗时分布上看这种浪费非常明显。配置阶段要执行所有项目的 build.gradle依赖解析要检查仓库里的快照编译阶段要把大量没变的 .java、.kt 再翻译一遍 class打包和测试更是直接把时间拉满。增量构建要做的就是逐个环节减少这种“确定性重复劳动”。1.2 Gradle 能做的四个层面的“增量”很多人把“增量构建”理解成“只编译改过的文件”这其实只是其中一层。Gradle 的增量体系是分层的我习惯把它拆成下面四个层面层面核心机制解决的问题任务级增量输入输出快照与 up-to-date 检查输入输出都没变的任务直接跳过编译级增量Java/Kotlin 编译器增量编译单个模块内部只重新编译受影响的类编译避免ABI 快照对比依赖模块公开 API 没变就无需重新编译构建缓存本地/远程缓存复用任务输出跨构建、跨机器复用之前跑过的结果配置缓存配置阶段结果缓存复用避免每次构建都重新执行 build.gradle 脚本这五个层面不是互斥的而是叠加生效。配置缓存解决“配置慢”任务级增量解决“重复任务执行”编译级增量和编译避免解决“重复编译”构建缓存则把“之前在任何地方跑过的结果”直接拿过来用。现在的 Gradle 构建速度能优化到“秒级”靠的就是这几层同时工作。1.3 增量构建和构建缓存的关系有不少人分不清“增量构建”和“构建缓存”这里说下我的理解。任务级增量的本质是“本地历史对比”这次跑了任务之后Gradle 记下输入输出的快照下次构建时对比发现都没变就标记 UP-TO-DATE 跳过。这种机制有个局限——换一台机器或者清理本地缓存后历史对比就没了一切又得从头跑。构建缓存则是把任务的输出结果按输入哈希值存储起来。只要任务的输入哈希相同不管是在本地、CI 还是同事的电脑上Gradle 都可以直接拉取缓存里的结果标记为 FROM-CACHE。所以构建缓存更像是增量的“远程增强版”它不取代增量但把增量的覆盖范围从“单机单次”扩大到了“任何地方”。实际项目中这两者往往是同时开启的。2. 核心机制拆解Gradle 怎么判断“要不要重新构建”2.1 任务的输入输出声明与 UP-TO-DATE 检查要理解增量构建先要理解 Gradle 里一个 Task 的基础结构输入inputs、输出outputs、动作action。Gradle 在第一次执行任务时会对输入和输出做快照记录然后保存执行历史。第二次构建时它先重新计算输入和输出的状态再和历史快照对比如果完全一致任务直接跳过在日志里标记为 UP-TO-DATE只要有一个文件不同、一个属性值变了这个任务就会重新执行。这里最关键的一点是Gradle 不会自动知道你的任务“吃了什么、吐了什么”。内置任务比如 JavaCompile、Copy 都已经声明好了但你自己写的自定义任务如果没有声明输入输出那 Gradle 每次都会执行。这也是很多项目“明明配了增量却不生效”的最常见原因。我在改造老项目时每次看到自定义任务没有 inputs/outputs 声明基本就明白为什么构建一直快不起来了。2.2 常用输入输出注解清单与避坑Gradle 提供了几种注解来标记任务属性核心是这几个注解适用对象典型场景Input单个可序列化属性值版本号、开关、字符串参数InputFile单个文件配置文件、模板文件InputFiles/Classpath文件集合、目录源文件目录、依赖 classpathOutputFile单个输出文件生成的 jar、报告OutputDirectory输出目录build/classes、build/generatedInternal不影响任务结果的属性临时对象、运行时状态Optional可能不存在的输入可选配置文件声明这些注解时我踩过几个坑。首先是输出目录和输入目录不能重叠有些偷懒的写法会把 build 目录既当输入又当输出结果就是快照永远对不上任务永远重跑。其次是不要用当前时间、随机数作为Input属性这等于主动告诉 Gradle“我的输入每次都变”缓存直接失效。最后是Optional要谨慎用它只用于“可能不存在”的合法输入不是让你省事不传参的。2.3 编译增量与注解处理最容易踩的坑JavaCompile 任务本身支持增量编译但一旦引入了注解处理器情况就复杂了。Gradle 对注解处理器采取偏保守的策略如果处理器不支持增量模式Gradle 就无法准确判断“哪些类会受影响”于是只能扩大重编译范围甚至退化成全量重编译。这也就引出了很多人见过的那条 IDEA 提示“java: jps 增量注解进程已禁用。部分重新编译的编译结果可能不准确。使用构建进程”。这条提示出现的原因是 IntelliJ IDEA 默认用自己的 JPS 构建系统来编译而 JPS 检测到某些注解处理器与它的增量编译机制不兼容为了不产错误结果主动禁用了增量注解进程。注意这里说的“构建进程”指的是构建工具进程也就是当你把 IDEA 的“Build and run using”设置为 Gradle 之后由 Gradle 来统一负责编译这个提示就不会成为限制。我的建议是项目里有 Lombok、MapStruct 这类注解处理器的时候尽量让 Gradle 统一接管编译而不是混用 IDEA 的 JPS 和 Gradle 两套机制。如果需要保留注解处理器又想尽量增量可以检查 Gradle 日志里是否有关键字眼如果某个任务因为注解处理器不兼容而无法增量Gradle 会降级并提示使用 non-incremental mode。这时候只能升级注解处理器、换兼容版本或者接受对该模块的全量重编译。2.4 编译避免Compile Avoidance与 ABI 快照编译避免是我觉得 Gradle 最被低估的一个功能。它的核心思想是如果模块 A 依赖模块 BB 的实现变了但公开 API方法签名、注解等没变那么 A 就不需要重新编译。因为 Java 编译只关心 class 文件对外暴露的接口不关心方法体内部怎么实现的。Gradle 在 Java 9 之后对 ABI 快照的支持更完善它会为每个 classpath 上的文件生成 ABI 快照。现在 Java/Kotlin 项目的多模块增量构建很大程度上依赖这一机制。实测下来在核心模块里修改一个私有方法的实现下游所有依赖模块的 compile 任务大部分都会显示 UP-TO-DATE这比“看到模块 M 变了就全量重编下游”高效得多。但如果你的项目用了 Kapt、某些会修改 class 字节码的插件编译避免可能被破坏因为 Gradle 无法确定字节码变换是否会影响 ABI。3. 实操从配置到验证让项目真正跑起增量构建3.1 环境与全局配置别让版本和参数拖后腿在看具体任务配置前先检查 gradle.properties。我建议至少保证下面这些参数存在org.gradle.daemontrue org.gradle.paralleltrue org.gradle.cachingtrue org.gradle.configuration-cachetrue org.gradle.configuration-cache.problemswarn org.gradle.jvmargs-Xmx4g -XX:MaxMetaspaceSize1gorg.gradle.daemontrue复用 Gradle 守护进程避免每次构建都启动 JVM。org.gradle.paralleltrue模块间无依赖关系的任务并行执行多模块项目收益很直接。org.gradle.cachingtrue开启构建缓存配合增量构建效果翻倍。org.gradle.configuration-cachetrue缓存配置阶段结果避免每次构建都重新执行所有 build.gradle。新版本 Gradle 对它的支持已经很成熟但如果项目里有很多老插件可以先开警告模式也就是同时配置problemswarn观察一段时间再彻底开启。这里要提醒一点热词里很多人搜“gradle 安装配置”“IDEA 配置 gradle”其实配置的关键不是“把 Gradle 装上”而是“让构建参数处于正确状态”。如果你用的是 wrapper尽量保持一个较新的 Gradle 版本老项目盲目升到 9.x 可能遇到插件兼容问题但长期停在 6.x 也享受不到配置缓存和编译避免的改进。3.2 多模块项目改造哪些配置直接影响增量效果多模块项目最影响增量的是模块依赖声明方式。我见过很多项目大量使用api而不是implementation这等于让依赖传递范围扩大下游模块的 classpath 里塞进了本来不需要的依赖任何一点变化都可能触发下游重编译。如果不是需要暴露给下游的 API一律用implementation。其次是自定义任务的写法。假设你有一个生成版本文件的任务abstract class GenerateVersionFileTask extends DefaultTask { Input abstract PropertyString getVersion() OutputFile abstract RegularFileProperty getOutputFile() TaskAction void generate() { outputFile.get().asFile.text version${version.get()} } } tasks.register(generateVersionFile, GenerateVersionFileTask) { version project.version.toString() outputFile layout.buildDirectory.file(generated/version.properties) }注意这里用了tasks.register而不是task。后者是立即创建任务前者是延迟创建不会在配置阶段就触发任务对象的完整初始化对配置缓存和配置速度都有好处。abstract classProperty的写法让 Gradle 能自动感知输入输出避免了手写 getter/setter 时的遗漏。还有一个实操技巧不要在配置阶段直接执行耗时操作。比如在 build.gradle 顶层写def files fileTree(src).collect { ... }这种代码在每次配置阶段都会跑而且可能被配置缓存标记为不可缓存任务。正确的做法是把这类操作放进doFirst或doLast也就是任务真正执行时才做。3.3 验证增量是否生效的三种方法配置完之后怎么确认增量真的生效了我常用三种方式。第一种是连续跑两次同样的任务观察第二次日志里的状态标记。比如./gradlew :app:compileDebugJavaWithJavac --info ./gradlew :app:compileDebugJavaWithJavac --info第二次如果出现UP-TO-DATE说明任务级增量生效如果出现FROM-CACHE说明命中了构建缓存。第二种是改一个模块的源码后再构建看哪些任务重新执行。正常情况下只有被改模块及其直接下游的 compile、package、transform 等任务会执行其它模块应该保持 UP-TO-DATE。第三种是用构建扫描./gradlew build --scan构建扫描会生成一份网页报告能直接在报告里看到每个任务的耗时、缓存命中情况、是否 UP-TO-DATE、为什么缓存 miss。排查“我明明配了增量但它每次都全量跑”这类问题时构建扫描是效率最高的工具。我一般先看“Task execution”列表按耗时排序优先处理那些每次都在跑、耗时长、且没有命中缓存的任务。3.4 进阶本地构建缓存与远程构建缓存本地构建缓存开启后Gradle 会在用户目录下维护一个构建缓存目录通常位于~/.gradle/caches/build-cache-1。它的作用是把任务输出按输入哈希存储起来即使任务历史被清理了只要输入哈希一致仍然能命中缓存。远程构建缓存更适合团队和 CI 场景。配置方式是在 settings.gradle 里加上buildCache { local { enabled true } remote(HttpBuildCache) { url https://build-cache.example.com/cache/ push true } }CI 上跑过一遍后开发者在本地拉代码再构建时大部分重活都能直接从远程缓存拉取不需要本地再编译一遍。不过要提醒一句远程缓存不要裸奔在公网至少加上身份认证输出的构建产物可能包含内部信息这点一定要有安全意识。4. 常见问题与排查技巧实录4.1 配置缓存不生效配置阶段依然慢表现gradle build每次都在 Configuration 阶段停留很久日志里出现类似于“Configuration cache entry could not be reused”或者一大堆 problem 提示。原因通常是构建脚本里使用了不兼容配置缓存的 API。最常见的有在配置阶段执行外部命令、直接读取系统属性来修改任务行为、使用project.afterEvaluate做太多事情、动态创建任务且无法序列化。排查方法是在 gradle.properties 里开启org.gradle.configuration-cache.problemswarn然后跑一次构建Gradle 会把所有不兼容点列出来逐个修就行。我处理过的项目里很大一部分“慢”并不是编译慢而是配置阶段每天都要花好几秒。把配置阶段时间压到 1 秒以内后整个构建体验会明显不同。4.2 注解处理器让“部分重新编译”变成全量表现模块里只改了一个类但整个模块的 .class 全部重新生成甚至下游模块也重新编译。同时 IDEA 里可能看到“java: jps 增量注解进程已禁用。部分重新编译的编译结果可能不准确。使用构建进程”提示。这条 IDEA 提示的本意不是说代码错了而是 JPS 发现注解处理器不支持增量为了安全不生成不可靠的编译结果只能禁用增量。如果你不想看到这种提示或者想得到更稳定的增量效果建议在 IDEA 的 Build Tools 里把 Gradle 的“Build and run using”从默认的 Gradle 设置确认打开让所有编译统一由 Gradle 负责。如果你用 Kotlin还应该确认kotlin.incrementaltrue以及 kapt 的增量模式是否开启。注意有些注解处理器本身就不支持增量这类问题只能通过升级处理器版本来解决如果实在不行接受单模块全量编译也比整个项目全量好。4.3 依赖缓存损坏、下载超时怎么处理表现构建报Gradles dependency cache may be corrupt或者下载依赖时出现java.net.SocketTimeoutException。依赖缓存损坏的常见原因是网络中断导致下载了一半的 jar 被当成完整依赖缓存下来。处理方法很简单定位到报错的依赖删除~/.gradle/caches/modules-2/files-2.1下对应的 group/artifact 目录然后重新同步。如果损坏范围很大直接删掉整个 modules-2 目录重新下载也行代价只是多花点下载时间。下载慢和超时是另一个痛点。Gradle wrapper 的 distributionUrl、依赖下载都可能因为网络原因非常慢。我通常会在两个层面处理一是把 maven 仓库换成镜像源在 settings.gradle 里配置阿里云或其他国内镜像二是如果 wrapper 的 distribution 下载太慢手动下载对应 zip 放到~/.gradle/wrapper/dists对应的临时目录里。这里特别提醒不要为了“快点拿到最新依赖”就随手加--refresh-dependencies这个参数会强制刷新所有动态版本把构建拖慢一大截而且和增量缓存完全是两回事。4.4 任务永远不缓存时间戳与输出污染表现任务输入输出都没变化但每次构建还是执行日志里始终没有 UP-TO-DATE。原因通常出在“输入”上。我排查过几种典型情况任务输入里包含当前时间戳或随机字符串某个InputFiles指向的目录包含另一任务的输出文件导致每次构建这个目录内容都在变文件权限变化也会触发快照不一致但这类相对少见还有就是把临时文件放在了源目录下被输入收集时误判。遇到这种问题先用构建扫描或--info日志找到具体是哪个任务再看它的输入声明。如果是时间戳就把时间戳移到任务动作里而不是当输入如果输入目录总是变化考虑收缩输入范围只声明真正参与计算的源文件。4.5 Gradle 版本与 JVM 版本不匹配的排查表现构建直接报The projects Gradle version 6.7.1 is incompatible with the Gradle JVM version或者Could not install Gradle distribution from ...。这些问题虽然不直接属于“增量构建”但会挡住你体验增量构建的路。Gradle 版本和 JDK 版本有对应关系JDK 太新、Gradle 太旧时Gradle 直接拒绝启动。处理方式有三种升级 wrapper 到与新 JDK 兼容的 Gradle 版本或者给项目配置一个合适版本的 JDK又或者用 Gradle Toolchains 指定编译用的 JDK 版本。Could not install Gradle distribution大多是网络问题导致 distribution 下载失败。解决办法和依赖下载类似换 distributionUrl 镜像或者手动下载 zip 后放到 wrapper 的 dists 目录下。当前各种热词里频繁出现的 Gradle 9.x 版本对新项目来说功能更全、配置更快但老插件项目升级前最好还是先在分支上试跑一遍构建扫描。最后分享一点个人体会。我接手过的几个老项目一开始都抱怨“Gradle 就是慢”但真正用构建扫描把任务一个个扒开看之后发现慢的原因大多是自定义任务没声明输入输出、模块依赖用了过宽的 api、配置阶段执行了不该执行的代码。这些东西不是 Gradle 的问题是使用姿势的问题。给新项目从第一天就把任务输入输出写清楚比等项目大了再回头改造省太多力气。如果你现在正被“全量构建”折磨不如先从./gradlew build --scan开始看看你那台机器上到底哪些任务在重复劳动。