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 在解析阶段会做这样的事:

  1. 遍历所有依赖声明,收集所有对 com.example:core 的版本请求。
  2. 发现有冲突:同时请求 1.0.0 和 2.0.0。
  3. 应用默认的 newest 策略 → 选择 2.0.0。
  4. 保留选中的版本,丢弃 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 兼容性。它不相信语义化版本号,它只相信数字大小。

避坑指南:当你发现某个运行时异常是 NoSuchMethodError 或 AbstractMethodError 时,90% 的可能是 Gradle 默认策略选错了版本。我曾经连续一周被这种坑折磨后,养成了在重大升级前先跑 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 如何一步步做出“选新”的决定,以及背后可能存在的风险区域。

Gradle 默认冲突解决流程(自动选择最新版本) 模块A → 依赖X:1.0 模块B → 依赖X:2.0 项目 → 直接依赖X:1.5 Gradle 发现冲突 版本 1.0, 2.0, 1.5 → 触发 newest 策略 选中最新版本 X:2.0 ⚠️ 三大局限 1️⃣ 版本号大小 ≠ 兼容性 2️⃣ 传递依赖可能暗含 API 变化 3️⃣ 缺少锁定机制,被动升级 结果:运行时可能抛出 NoSuchMethodError / AbstractMethodError 甚至编译通过但功能异常

回头看看这个策略值不值得信任

我不建议完全依赖默认策略。它方便是真的方便,尤其是在快速原型阶段,你不想被版本问题分心。但到了生产环境,尤其是团队协作的项目里,沉默的依赖升级会引入无法预期的风险。

我个人的做法是:

  • 开发阶段:用默认策略,配合 dependencies 检查。
  • CI 阶段:开启依赖锁定,锁定所有配置(包括 test、runtime)。
  • 发布前:手动执行 dependencyInsight 审查每个有冲突的依赖,评估是否需要显式声明版本。

这里有个表格可以快速对比不同环境下的推荐设置:

环境 默认策略 推荐操作
本地开发 newest (自动) 无锁定,快速迭代
CI 持续集成 newest (自动) 建议加上锁定,防止环境差异
预发布 / 生产 newest (自动) 必须加锁,并手动审核冲突
一句话总结:Gradle 的默认「选新」策略是面向便利的,不是面向稳定的。你作为架构师或高级开发者,要清楚它的局限,并在关键路径上主动控制依赖版本。

其实除了默认的 newest 策略,Gradle 还提供了 fail(遇到冲突直接报错)和 force(强制指定版本,忽略其他声明)两种策略。但默认策略是基础,理解了它为什么不够好,后面的章节我们才能聊怎么用 force、怎么用依赖约束来根治问题。