Gradle 的默认「选新」策略
我相信很多同学刚接触 Gradle 时,都会发现一个现象:明明配置文件里没写版本号,项目却跑得好好的。那是因为 Gradle 替你做了决定——它默认采用的冲突解决策略是「自动选择最新版本」。
说白了,就是当两个依赖同时引入同一个库的不同版本时,Gradle 会直接挑那个版本号最“大”的,然后无视其他版本。这个策略名字叫 newest,是 Gradle 内置的默认方案。
ModuleVersionConflictStrategy::newest → 直接选用解析到的最高版本号。
为什么 Gradle 要这样设计?
我个人习惯把这个策略理解为“乐观合并”。Gradle 团队认为大多数情况下,新版本是兼容旧版本的——毕竟现代语义化版本(SemVer)规范里,版本号递增往往代表 bug 修复或新功能,而不是破坏性变更。但现实嘛……大家心里都清楚,理想很丰满,现实很骨感。
我曾经在一个微服务项目里,就因为 Gradle 的“选新”策略踩过坑。当时我们依赖了 A 和 B 两个库,它们分别依赖 C 的不同版本——一个 2.1.0,一个 2.3.0。Gradle 自动选了 2.3.0,结果 C 在 2.2.0 里偷偷改了一个内部 API 的签名,没有进 Minor 版本号(嗯,就是那种“我改了个私有方法参数类型,应该没影响吧”的情况)。结果我们的代码在运行时直接抛 NoSuchMethodError。那一次排查花了整整两天。
默认策略怎么生效?
我们来看一个具体例子。假设你的项目有下面这个依赖关系:
// 项目 build.gradle
dependencies {
implementation 'com.example:lib-a:1.0.0'
implementation 'com.example:lib-b:2.0.0'
}
再假设 lib-a 和 lib-b 都传递依赖了同一个库 com.example:core,版本分别是 1.0.0 和 2.0.0。
Gradle 在解析阶段会做这样的事:
- 遍历所有依赖声明,收集所有对
com.example:core的版本请求。 - 发现有冲突:同时请求 1.0.0 和 2.0.0。
- 应用默认的
newest策略 → 选择 2.0.0。 - 保留选中的版本,丢弃 1.0.0 所有依赖路径。
你可以通过 ./gradlew dependencies 看一下最终解析结果,会清楚看到版本被“升级”了。
./gradlew dependencyInsight --dependency core 可以快速查看某个依赖的冲突选择过程,我每次排查依赖地狱的第一步都是跑这个命令。
局限一:版本号大小 ≠ 版本质量
Gradle 判断“最新”的依据是版本号的比较规则。比如 2.0.0 > 1.9.9,这没问题。但问题是,版本号大的不一定更稳定,也不一定向后兼容。
你想想看,一个库的 2.0.0 可能是大重构,删掉了所有旧的 public API。如果某条传递依赖必须用 1.x 的某个函数,那运行时就是一场灾难。
我曾经遇到过一种非常典型的情况:
- 项目依赖了
com.fasterxml.jackson:jackson-databind:2.9.8 - 另一个库传递依赖了
2.11.0 - Gradle 自动选了 2.11.0,结果 2.10.0 以后某个注解处理的行为变了,导致我们自定义的序列化器全部失效。
嗯,这就是“新版本”的陷阱。它不会帮你做兼容性测试,只是机械地比较数字。
局限二:传递依赖的「暗雷」
我曾经被传递依赖害得最惨的一次,是来自一个第三方 SDK。那个 SDK 稳定版只支持 Guava 20.0,但它的某个可选模块传递依赖了 Guava 27.0。SDK 核心代码在运行时调用了 Guava 20.0 的 API,而 Guava 27.0 中那个 API 被标记为 deprecated 且行为变了——Gradle 自动选了 27.0,然后 SDK 就崩溃了。
为什么会这样?因为 Gradle 的 newest 策略只检查直接依赖和一级传递依赖,它不会去分析每个版本之间的 API 兼容性。它不相信语义化版本号,它只相信数字大小。
dependencies 做版本树审查的习惯。
局限三:缺少“锁依赖”机制
Maven 默认不会自动升级传递依赖的版本,它只遵循最近定义的原则。但 Gradle 在默认情况下会积极地选择最新版本——这听起来像是好事,但其实等于你的项目随时可能因为一个间接依赖的发布而“被动升级”,完全不给你控制权。
我记得有个项目,上线前一天 CI 突然编译失败,排查后发现是一个 SNAPSHOT 依赖在下午发布了新版本,Gradle 自动用了那个新版,结果 API 变了。从那以后,我再也不相信默认策略了 —— 我推荐所有生产项目都开启依赖锁定(dependency locking),把每个依赖的解析版本固定下来。
开启方式很简单:
// settings.gradle.kts
dependencyLocking {
lockAllConfigurations()
}
// 然后执行 ./gradlew dependencies --write-locks
// 会生成 gradle/dependency-locks/*.lockfile
锁了以后,每次构建都会使用锁文件中的版本,不会自动升级。需要升级时手动执行 --write-locks 更新锁文件。
Gradle 默认策略执行流程
为了让你更直观地理解,我画了一张图。它展示了在多依赖冲突场景下,Gradle 如何一步步做出“选新”的决定,以及背后可能存在的风险区域。
回头看看这个策略值不值得信任
我不建议完全依赖默认策略。它方便是真的方便,尤其是在快速原型阶段,你不想被版本问题分心。但到了生产环境,尤其是团队协作的项目里,沉默的依赖升级会引入无法预期的风险。
我个人的做法是:
- 开发阶段:用默认策略,配合
dependencies检查。 - CI 阶段:开启依赖锁定,锁定所有配置(包括 test、runtime)。
- 发布前:手动执行
dependencyInsight审查每个有冲突的依赖,评估是否需要显式声明版本。
这里有个表格可以快速对比不同环境下的推荐设置:
| 环境 | 默认策略 | 推荐操作 |
|---|---|---|
| 本地开发 | newest (自动) | 无锁定,快速迭代 |
| CI 持续集成 | newest (自动) | 建议加上锁定,防止环境差异 |
| 预发布 / 生产 | newest (自动) | 必须加锁,并手动审核冲突 |
其实除了默认的 newest 策略,Gradle 还提供了 fail(遇到冲突直接报错)和 force(强制指定版本,忽略其他声明)两种策略。但默认策略是基础,理解了它为什么不够好,后面的章节我们才能聊怎么用 force、怎么用依赖约束来根治问题。