带过多模块工程的朋友,应该都体会过这种场景:只改了一行工具类代码,点一下构建,然后眼睁睁看着进度条从第一个模块开始一路编译过去,十几秒甚至几分钟就没了。这种时候所有人都会问同一个问题——Gradle 增量构建到底能不能只构建修改过的模块与代码?答案是可以,但前提是你得真正理解它怎么判断“修改过”,以及你的工程结构有没有给它判断的依据。这篇博客就是围绕这件事展开的,适合被构建速度折磨的 Android/Java 开发者,也适合刚接触 Gradle 想少走弯路的同学。
1. 多模块工程为什么越改越慢:全量构建的账要算清楚
1.1 全量构建的成本到底花在哪
一个常见的多模块工程,比如 app 壳模块依赖业务模块 A/B/C,业务模块又依赖基础库 base。平时跑一次 debug 构建,要完成 Java/Kotlin 编译、资源合并、AAR 打包、dex、lint、测试等任务。如果只改了一个 TextView 的文案,理想情况下应该只重编这个字符串所在的模块,甚至只需要重新处理资源。但很多工程实际跑起来和 clean build 差别不大,所有模块的 compile 任务全部重新执行,改动一个小常量要等好几分钟。
问题出在哪?Gradle 本身是一个任务调度框架,它不保证任何任务默认跳过。只有任务自己声明了稳定的输入输出,并且 Gradle 检测到输入输出没有变化,它才会把任务标记为 UP-TO-DATE 然后跳过。如果你的模块依赖关系太粗,或者某个任务没有声明完整的输出目录,Gradle 的增量判断就失效,退化成全量重跑。换句话说,全量构建的成本不只是来自编译器本身,更多来自调度器无法判断“哪些可以不动”。
举一个实际例子:某个任务的输出目录同时被多个任务写入,或者输出目录里混入上次构建留下的随机文件名,Gradle 对输出做快照时发现“和上次不一样”,就判定输出已变化,下次构建重跑。这类细节看似微小,在多模块工程里会被放大成几十秒甚至几分钟的无效耗时。
1.2 Gradle 不是编译器,而是任务调度器
理解增量构建,首先要放下“Gradle 是编译器”的错觉。编译器把源码变成字节码,Gradle 做的是把编译、打包、拷贝、测试这些动作编排成一张任务图,然后逐个执行。真正做增量编译的是 Javac/Kotlin 编译器内部的增量机制,Gradle 的增量构建是在任务级别做跳过判断。这是两层。
任务级别的意思是:Gradle 判断某个 Task 的输入(源文件、依赖 jar、参数)和输出(class 文件、jar、报告)相比上次执行有没有变化。没变,标记 UP-TO-DATE,不执行动作,时间接近 0。代码级别是:即使 Task 被判定为需要执行,编译器也可能只重新编译变更文件涉及的那部分源码,比如 Java 增量编译只重新编译受影响的类。
所以“只构建修改过的模块与代码”这句话,其实是两个层面叠加的效果:模块靠任务快照跳过,代码靠编译器增量能力。这两层缺一不可。任务级判断失效,编译器再聪明也没用,因为编译器拿到的是全量源码目录;代码级失效,模块虽然“跳过”成功,模块内部依然会退化成整模块重编,时间照样久。
1.3 增量构建的预期收益:不是所有场景都能跳过
这里要给预期先降降温。增量构建不是银弹,它能跳过的,是那些输入输出完全稳定、且和本次改动无关的任务。像 packageDebug、assembleDebug 这种最终打包任务,只要前面产物有变化,它必然重跑;单元测试任务如果配了“总是执行”,也不会跳过。
合理的预期是:模块级跳过能省掉与改动无关模块的编译、资源合并、打包时间;代码级增量能省掉同模块内无关类的重编时间。但最终组装、签名、安装这些步骤该跑还是跑。评判增量构建是否健康,不是看“是否零任务执行”,而是看“改动单个模块时,无关模块是否真的被跳过,改动点所在模块内部是否没有全量重编”。
这个标准听起来简单,实际工程里能稳定做到的并不多。接下来我会讲底层判断逻辑、工程化拆分方法,以及我自己踩过的一些坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 判断“这次到底该不该重跑”:快照机制与 UP-TO-DATE
2.1 Task 输入输出快照如何工作
Gradle 的每个 Task 都可以声明 inputs 和 outputs。执行一个 Task 之前,Gradle 会对当前 inputs 的内容指纹(文件哈希/大小/路径、属性的序列化值)和 outputs 的内容指纹做一次与上次执行完保存的指纹比对。完全一致,任务标记 UP-TO-DATE,直接跳过;不一致,执行动作,执行完保存新快照。
关键就在“完全一致”这四个字。比如你声明 inputs 为某个目录,目录里多了一个文件,指纹就变了,任务重跑。如果某个 Task 没有声明 outputs,Gradle 不知道它会产生什么,没法判断是否 UP-TO-DATE,所以每次都会执行。很多自定义 Gradle 插件偷懒,copy/zip 任务没声明输出,它们就会成为增量构建链路里的排水口。
一个形象类比:快照机制很像收快递时核对包裹清单。输入是“预计有哪些东西”,输出是“收到后箱子的状态”。如果清单和箱子状态都和上次一样,收件员直接签收不拆箱;只要有一个小件不同,就得重新拆箱清点。Gradle 对文件系统做的就是这件事,只不过它用哈希和属性比较,而不是肉眼。
2.2 从构建日志里读懂 Gradle 的决策
判断增量是否生效,最直接的办法是看日志。用 --info 跑一次构建,能看到每个任务的决策状态。常见的几种状态:
UP-TO-DATE:输入输出没有变化,任务跳过,最理想。FROM-CACHE:从构建缓存命中,没有重新执行,结果相同但来自缓存。SKIPPED:任务被条件跳过(比如onlyIf返回 false),不是因为没变化。NO-SOURCE:任务没有输入源文件,也没必要执行。- 没有状态标注:任务真正执行了。
实际日志看起来大概是这样的:
bash复制> Task :base:compileDebugJavaWithJavac UP-TO-DATE
> Task :feature:compileDebugKotlin
> Task :app:packageDebug
如果无关模块的任务大多 UP-TO-DATE,只有改动模块相关任务在执行,说明模块级增量是健康的。如果所有模块的 compile 任务都被重新执行,没有任何 UP-TO-DATE,说明某个上游输入变了,或者 Gradle 没法稳定判断。
我的习惯是用 ./gradlew :app:assembleDebug --info,把输出重定向到文件再搜索。直接终端看也行,但日志量很大,容易刷屏。加任务名能更精准地观察指定任务及其依赖链。
2.3 为什么只改了一个文件,整个模块还是重编了
这是最常被问到的问题。其实原因往往不在 Gradle 主流程,而在几个容易被忽略的细节。
第一,动态版本依赖。如果模块依赖了 com.example:lib:1.0.+ 或者 SNAPSHOT 依赖,Gradle 每次构建都可能去远程仓库检查新版本。旧版本对应缓存指纹变了,依赖它的模块就不得不重新编译,调试期尤其痛苦。
第二,任务配置里带时间戳或随机值。比如输出文件名加了 System.currentTimeMillis()、任务参数有构建时间、某个输入由外部命令动态生成,快照每次都不同。
第三,注解处理器和代码生成。KAPT/APT、Dagger、Room 这类处理器会读取源码并生成代码,Javac/Kotlin 的增量编译为了安全,经常在存在注解处理器时退化为全量。改一个类,整个模块编译重跑,这是最常见的“模块没跳过”类问题。解决办法是换用 KSP(如果生态允许),或者至少定义好处理器的输入输出。
第四,自定义任务没有声明完整输入输出,或者把输出写到默认构建目录之外。Gradle 检查不到外部输出,但又检测到外部文件变化,就把相关任务重新判定。这个我放到第 5 部分的排查手册里展开。
3. 模块依赖关系是增量的命门:拆分与依赖治理
3.1 依赖方向决定重跑范围:低层改动向上扩散
多模块工程的增量范围,本质由依赖图决定。假设模块 A 依赖模块 B,B 的源码变化会导致 B 重编,同时 A 的编译任务也被触发,因为 A 依赖的 B 的 class/jar 变了。如果 B 还依赖 C,改 C 会让 B 重编,也会让 A 重编。也就是说,越底层的模块改动,扩散范围越大。
所以一个反直觉的结论是:增量构建做得好不好,不只看 Gradle 配置,更看模块边界。如果基础模块里塞了大量易变代码,比如网络层、工具类、业务页面全在同一个 base 模块里,任何一次小改动都会扩散到所有上游模块。这种情况就算 Gradle 快照机制再灵敏,也帮不了你,因为依赖图就是这么画的。
理想的依赖图是:底层相对稳定,只放很少变的基础能力;中间层按业务或功能拆分,模块之间尽量不互相依赖;最上层是应用壳,负责组装。这样改动收敛在单个模块内,增量构建价值才能发挥。
3.2 用 implementation 收窄依赖,避免 API 依赖污染
Gradle 的 java-library 插件里,api 和 implementation 的区别很多人知道但要重视:implementation 不会把依赖传给下游模块的编译 classpath,api 会。
如果你对底层库用了 api,下游模块在编译时也会把这个库放进 classpath。这不仅扩大编译范围,还会让底层库的版本变化影响范围变大。反过来,用 implementation 可以把依赖关系限制在当前模块内,下游模块不知道也不关心这个依赖,于是它们的输入快照不包含这个 jar 路径,底层依赖变化时下游模块更容易保持 UP-TO-DATE。
我检查依赖链时常用 ./gradlew :app:dependencies --configuration debugCompileClasspath,经常发现一些“当年顺手写的 api”把公共库暴露给了所有业务模块。改成 implementation 之后,增量构建命中率肉眼可见提高。
注意 api 不是不能用,而是只在“你需要把依赖的类型暴露给下游”时才用。比如模块接口方法签名直接引用了某个库的类型,下游需要编译配合,这时必须 api。绝大多数场景,implementation 就够了。
3.3 惰性配置与按需配置:让 Gradle 少算一点
Gradle 默认会配置整个工程的所有模块,哪怕你只想执行 :app:assembleDebug,它也会先读一遍所有 build.gradle、创建所有 Task。模块多的时候,配置阶段可能就要好几秒甚至十几秒。配置阶段虽然不算“编译”,但它会拖慢每次构建,也容易掩盖增量构建的真实收益。
一个思路是开启 configure-on-demand,在 gradle.properties 里加 org.gradle.configureondemand=true。它会让 Gradle 只配置这次构建需要的模块,而不是全部。注意它依然算孵化中,某些插件或动态配置可能不兼容,建议先在本地试跑一遍。
另一个更推荐的是配置缓存 Configuration Cache,在第 6 部分详细说。它的目标是把配置阶段完全跳过,一次记录配置结果,后续直接复用。配合按需配置,构建总时间会更接近“真正干活的时间”。
3.4 模块划分的几条实操建议
结合工程改造经验,几条模块划分建议:
- 按稳定程度分层,不要按页面分包。工具类、网络层、基础组件放底层,业务页面按业务线独立成模块。
- 同一层模块之间禁止互相依赖。两个业务模块都要用一个通用组件,把组件下沉到公共模块,而不是互相依赖。
- 资源放依赖方向明确的模块里,避免通过“资源覆盖”造成隐式依赖。隐式依赖是快照机制最难发现的,因为 Gradle 看到的输入没变,运行期行为变了。
- 拆不动模块,至少把自定义插件和构建逻辑拆出来,减少构建脚本本身的改动频率。
模块拆分不是越细越好。每个模块都有配置成本、编译成本、同步成本。粒度应该以“是否能独立迭代、独立测试、独立发布”为标准,而不是为了满足架构洁癖。
4. 实测验证:只改一个模块,看看其他模块是否真的被跳过
4.1 用 --info 做一次精准观察
说了这么多原理,给一个可以直接照做的实验。假设工程有 app、feature、base 三个模块,feature 依赖 base,app 依赖 feature。第一次全量构建完成后,改一行 base 里的代码,再跑一次:
bash复制./gradlew :app:assembleDebug --info > build_log.txt
打开 build_log.txt,搜索 > Task 看每个任务后的状态。预期是:
- :base 模块的 compile、processResources 等任务重新执行
- :feature 模块的 compile 任务重新执行(因为 feature 依赖 base,base 的 jar 变了)
- :app 模块的 compile 任务重新执行(同上)
- 与这三个模块无关的任务,如果存在,保持
UP-TO-DATE
然后把改动改到 feature 模块本身(不碰 base),再跑一次。预期是 :base 相关任务保持 UP-TO-DATE,:feature 和 :app 相关任务重跑。如果日志里 :base 也重跑了,说明输入里混入了不该变化的东西,比如 feature 通过 api 泄漏了依赖,或者全局配置里带了不稳定属性。
这一步能帮你判断模块级增量是否正常。如果正常但构建时间还是很久,再看代码级增量。
4.2 用 --profile 生成构建报告,看时间去向
命令行加 --profile,完成后在 build/reports/profile 目录生成 HTML 报告,里面有 Task Execution 和 Configuration 两个表。Task Execution 列出每个任务实际耗时和执行/跳过状态。
我习惯先看有没有耗时异常的任务,比如一个七八秒的 compile 任务,确认它是否真的需要执行;再看配置阶段耗时,如果每次都超过 2 秒,就值得投入配置缓存。报告里也能看到依赖解析耗时,能定位“下载依赖”占的比重。
构建扫描(Build Scan)在命令行加 --scan 能生成共享链接,方便团队协作时把性能数据发给别人。不过是否上传构建数据要看公司合规要求,本地 HTML 报告已经能满足日常优化。
4.3 实验:改底层模块 vs 改上层模块的差异
把上面实验量化一下。我在一个 20 个模块的 Android 工程里测过:改一个底层公共模块的 String 常量,全量构建 2 分 10 秒;增量但底层模块不稳定时,1 分 40 秒,只省了 30 秒。后来把公共模块拆稳,改成只改上层业务模块,构建时间直接降到 25 秒。
这个对比说明:增量构建的收益大小,取决于你改的代码在哪一层。底层模块的任意改动,权重天然就高。所以优化重点之一是让底层模块“稳定”,把易变代码往上移。这也呼应了第 3 部分的依赖治理思路。
验证实验结果时,记得用同一台机器、同样的缓存状态、同样的 Gradle 版本,控制变量,否则不同环境的文件系统差异会影响快照判断。
5. 增量构建失灵的现场:来自真实工程的踩坑排查手册
5.1 坑一:Gradle/JDK 版本不一致,构建一开始就状态不对
很多项目的 Gradle wrapper 和本机 JDK 版本不一致,会看到类似 “The project's Gradle version x is incompatible with the Gradle JVM version” 的报错。最常见原因是 IDE 里配置的 Gradle JVM 比项目要求的版本高或低。
它和增量构建的关系在于:JDK 版本不一致时,Gradle 可能重新解析整个构建环境,很多任务输入里带了 toolchain 信息或 JDK 路径,不同构建用不同 JDK,快照就变了,本来能 UP-TO-DATE 的任务全部失效。所以版本不一致不是“换一下 JVM 就好”,它会让增量缓存反复失效。
我的建议是:项目里用 Gradle Toolchain 显式声明 Java 版本,让所有开发者统一 JDK;README 写清楚推荐 JDK 和 Gradle 版本组合;CI 和本地的 Gradle JVM 保持一致。网上搜到的版本号看看就好,升级前务必确认插件兼容,不然又是另一个坑。
5.2 坑二:Gradle 下载慢、镜像配置不当拖垮首次构建
增量构建的快乐建立在“依赖已下载”的前提下。首次构建如果从网上下载 Gradle 发行版和依赖,慢到让人怀疑人生,体验会非常差。
Gradle 发行版下载慢的常见解法是改 wrapper 的 distributionUrl,换成国内镜像源地址;依赖下载慢则是在 settings.gradle 配置阿里云等镜像仓库。注意镜像要写在 pluginManagement 和 dependencyResolutionManagement 两个地方,否则插件和依赖走不同仓库,效果打折。
这个配置属于“环境变量”,不直接影响增量判断,但直接影响依赖解析耗时。依赖解析每次去远程仓库检查 SNAPSHOT,就不只是慢的问题,而是会让快照失效。我习惯把所有仓库配置做成 init script 或统一 settings 片段,团队共享。
5.3 坑三:本地 Maven 仓库里的 SNAPSHOT 依赖让快照失效
说到 SNAPSHOT,有个经典场景:本地开发时改了某个库,mavenLocal() 里有 SNAPSHOT 包,工程依赖它。Gradle 对 SNAPSHOT 默认行为是检查远程仓库是否有新版本,即使本地文件没变,也会因为“检查行为”导致依赖缓存失效,进而触发下游重新编译。
如果只是个人开发,可以把 SNAPSHOT 检查策略调短,比如:
groovy复制configurations.all {
resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
}
或者干脆用 composite build / 项目依赖替换本地 jar 依赖。我推荐用 includeBuild,本地库像项目模块一样参与增量构建,既能改代码即时生效,又能复用任务快照。
“gradle 拉取本地maven仓库包”这类问题本质就是 SNAPSHOT 依赖解析。团队里确实用 mavenLocal,也要限制范围,不要把它放在仓库列表第一个位置,否则所有项目都受影响。
5.4 坑四:注解处理器和代码生成把增量编译打回原形
Java/Kotlin 增量编译有先决条件:不能有动态生成源文件、不能用不兼容的注解处理器等。Kotlin 项目用了 KAPT 处理 Room、Dagger 这些注解时,KAPT 增量模式受限,Kotlin 编译器经常选择全量编译该模块。
我在工程里试过把 KAPT 换成 KSP 处理部分注解,单模块编译时间降了 40%。如果业务能从 KAPT 迁到 KSP,收益非常明显。暂时不能迁,至少要把注解处理器声明的输入输出搞对,KAPT 有 kapt.include.compile.classpath 等参数可以微调,但效果有限。
还有一个隐蔽点:有些插件会在编译前生成代码,比如 protobuf、Dagger 的 generated sources。如果生成目录被全局 clean 或者每次输出到不同路径,增量判断就会失效。遇到这种情况,要么稳定生成目录,要么把生成代码看成输入的一部分来管理,避免内容每次不同。
5.5 排查手册:从日志到依赖链,一步步定位问题
如果增量构建不生效,我按下面顺序排查,你也可以直接复制到工作流程:
- 跑
./gradlew :app:assembleDebug --info > log.txt,搜> Task,列出所有没有UP-TO-DATE的任务。 - 对每个重跑的任务,看它的 inputs 是否包含不该变的东西,可以用
./gradlew :base:compileDebugKotlin --info看它检测到哪些输入变化。 - 检查依赖解析:
./gradlew :app:dependencies --configuration debugCompileClasspath,看有没有动态版本、SNAPSHOT、api 泄漏。 - 检查自定义任务:没写 inputs/outputs 的补上;输出目录不稳定的改成稳定路径。
- 检查全局配置:gradle.properties 里是否开了 build cache、configuration cache;关闭一些“总执行”的 doLast 任务。
- 最后做一次
./gradlew clean,确保当前脏状态不会干扰判断,然后重复第 1 步。
这套流程我用了很多次,绝大多数“莫名其妙全量重编”的问题都出在第 2、3 步。一步步确认总比重建工程快。
6. 再进一步:Build Cache、并行与 Configuration Cache 叠加优化
6.1 Build Cache:把构建产物缓存到本地和远端
任务快照解决的是“改动无关的任务跳过”,Build Cache 解决的是“即使要执行,也可以从缓存读取结果”。当一个任务的输入指纹在缓存里命中,它会从 FROM-CACHE 恢复输出,而不是重新执行。
本地 build cache 默认在 ~/.gradle/caches/build-cache-1,只要 gradle.properties 不关掉就能用。它的好处是:切换分支、revert 代码、clean 之后,很多任务能直接命中缓存,不用重新编译。
团队协作时可以配远端构建缓存,比如基于对象存储的实现。这样 CI 上编译过的产物,本地也能直接拉取,跨机器复用。需要提醒的是,构建缓存里的产物和任务输入输出绑定,任何不稳定输入都会污染缓存命中。所以 build cache 和增量构建是同一套逻辑,上游治理不好,缓存命中率也上不去。
6.2 并行构建的正确姿势
org.gradle.parallel=true 会让 Gradle 并行执行互相不依赖的任务。模块多、CPU 核数多时收益明显。但并行不是“无脑开”,并行任务会占内存,模块多、编译器内存吃紧时反而会因为 GC 停顿更慢。
我一般先看机器核数和内存,把 org.gradle.workers.max 调到 CPU 核数的一半到三分之二,再设置合适 JVM 堆内存,比如:
properties复制org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
并行构建和增量构建叠加时,日志顺序不再直观,可以继续用 --info 看任务状态,但定位问题时要习惯任务之间的并发关系。
另一个常见误区:并行构建解决不了“改一个底层模块导致所有上游重跑”的问题,它只能让这些重跑任务并发执行。依赖链很长时,并行能缩短墙钟时间,但不能减少总工作量。这也是我前面强调依赖治理的原因。
6.3 Configuration Cache:把配置阶段也缓存起来
Gradle 7.4 之后 Configuration Cache 不再是实验性开关,现在很多项目默认开启后收益明显。它的核心是把 build.gradle 脚本执行一次的配置结果序列化,后续构建直接复用,配置阶段从几秒压缩到一两百毫秒。
开启方式:
properties复制org.gradle.configuration-cache=true
使用过程中最常见的坑是:某些插件或脚本在配置阶段访问了不该访问的东西,比如读取环境变量、System.currentTimeMillis()、输出日志。这些问题会报 “Configuration cache problems” 提示。解决方法是把这类动态行为延迟到任务执行阶段,或者用 Provider 惰性取值。
要和 configure-on-demand 区分:configure-on-demand 是“少配置一些模块”,configuration cache 是“缓存配置结果”。两者可以同时开,但 configuration cache 收益通常更大。老插件生态先在模块上试验,确认没有兼容问题再全量开启。
6.4 推荐配置组合:一份可以直接抄的 gradle.properties
最后给一份目前常用的配置,针对中小型多模块工程:
properties复制org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.workers.max=8
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
注意 configuration-cache.problems=warn 很关键,它能让老插件报错时不至于直接阻断构建,先观察问题再逐步修复。需要远端缓存时再加对应配置,不需要就不用加。
这一套配置不神奇,它只是把任务快照、build cache、并行、配置缓存全部串起来。增量构建的命中率,最终靠稳定的模块依赖和完善的任务输入输出声明。工具只是放大器,工程结构才是本源。
