Gradle 依赖版本统一管理方案对比
Gradle 依赖版本统一管理方案对比
Android 项目里依赖版本管理这件事,看起来是个"配置问题",实际踩进去才知道水有多深。一个中等规模的项目,几十上百个第三方库,加上内部模块的版本对齐,光靠每个 build.gradle 里硬编码版本号,维护起来就是灾难。更麻烦的是 Gradle 本身的演进——从早期的 ext 变量,到后来的 buildSrc,再到 Gradle 7.0 推出的 Version Catalogs,以及各家 IDE 和插件厂商的各种辅助方案,选择多了,反而更纠结。这篇文章把我这几年在不同项目里试过的方案拉出来,结合具体的 Gradle 版本、API 变动和实际踩过的坑,做个彻底的梳理。
`ext` 变量:老派但还没死透
最早接触 Android 项目时,版本统一管理基本靠项目根目录 build.gradle(或者 Groovy DSL 时代的 build.gradle)里的 ext 块。写法很直白:
ext {
kotlin_version = '1.7.20'
compose_version = '1.3.0'
retrofit_version = '2.9.0'
}子模块通过 rootProject.ext.kotlin_version 引用。这个方案的优势只有一个:简单,不需要任何额外配置,所有 Gradle 版本都支持。但问题也很致命。
最大的痛点是 IDE 支持差。ext 变量本质上是 Groovy 的闭包属性,IntelliJ IDEA 和 Android Studio 对这些变量的引用跳转、重构、自动补全支持很弱。改一个版本号,IDE 的 Find Usages 经常漏掉引用点,重构重命名更是基本不可用。我 2021 年在一个 Kotlin DSL 迁移项目里试过把 ext 变量改成 val 定义在 buildscript 块里,情况稍微好一点点,但本质上还是动态类型的弱引用,编译前 IDE 无法做静态分析。
另一个隐蔽的坑是类型安全。ext 变量存的是 Object,写错成 rootProject.ext.kotlin_versoin 这种拼写错误,编译期不会报错,要到运行时才会暴露,而且错误信息往往指向引用处而不是定义处,定位很费劲。
现在新开的项目基本不会选这个方案了,但维护老项目时还会遇到。特别是有些项目卡在 Gradle 6.x 甚至更早版本,升级成本太高,ext 变量就成了技术债务的一部分。我的建议是:如果项目还在活跃开发,至少把 Gradle 升到 7.0 以上迁移到 Version Catalogs;如果是维护模式的老项目,保持现状别折腾,改出 regression 得不偿失。
`buildSrc`:Kotlin DSL 的甜蜜陷阱
Gradle 5.0 引入的 buildSrc 模块曾经是社区里的"最佳实践"明星。原理是在项目根目录建一个 buildSrc 目录,里面放独立的 Gradle 构建,编译产物自动注入到主构建的 classpath 里。用 Kotlin DSL 可以写出类型安全的版本定义:
// buildSrc/src/main/kotlin/Versions.kt
object Versions {
const val kotlin = "1.7.20"
const val compose = "1.3.0"
}
object Libs {
const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}"
}这解决了 ext 变量的两大痛点:IDE 支持完美,跳转、重构、补全都是一等公民;类型安全,拼写错误编译期就能抓住。
但 buildSrc 的坑藏在架构设计里。buildSrc 是一个完整的独立构建,任何代码修改都会触发整个 buildSrc 的重新编译,而 buildSrc 的变更又会导致主构建的完整重新配置(reconfiguration)。在大型项目里,改一个版本号触发的构建时间可能是分钟级的。我 2022 年在一个模块数 200+ 的项目里测过,单纯改 buildSrc/Versions.kt 里的一个字符串,Gradle 配置阶段花了 4 分 30 秒(Gradle 7.4,MacBook Pro M1 Max,已开 configuration cache)。这个成本对于频繁调整依赖版本的场景完全不可接受。
更深层的问题是 buildSrc 的编译产物会污染整个构建的 classpath。如果你在 buildSrc 里引入了某些库,这些库会隐式地对所有 build.gradle 可用,容易造成命名冲突和版本覆盖。我踩过一个具体的坑:在 buildSrc 里用了 kotlinx.serialization 做配置解析,结果主项目里某个插件间接依赖了不同版本的 kotlinx.serialization,运行时出现了 NoSuchMethodError,排查了整整一个下午才发现是 buildSrc 的 classpath 污染。
Gradle 官方在 7.0 之后对 buildSrc 的态度也变了。Gradle 7.0 的 release notes 里明确提到 buildSrc 的重新编译问题,并在后续版本里引入了 configuration cache 的部分缓解措施,但根本架构问题没解决。现在官方文档里 buildSrc 的推荐优先级已经明显低于 Version Catalogs。
我的判断:buildSrc 适合的场景已经收窄到"需要在版本管理之外写复杂构建逻辑"的情况,比如自定义插件、复杂的构建任务编排。纯版本管理这个单一职责,有更好的替代方案。
Version Catalogs:官方新宠,但别急着吹
Gradle 7.0 引入的 Version Catalogs(文件后缀 .toml,通常放在 gradle/libs.versions.toml)现在是官方推荐的版本管理方案。设计上很干净:把版本、库、插件的声明集中到 TOML 文件,Gradle 自动解析生成类型安全的访问器。
基本结构长这样:
[versions]
kotlin = "1.9.0"
compose = "1.5.0"
[libraries]
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
[plugins]
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }模块里引用:implementation(libs.retrofit),插件引用:alias(libs.plugins.kotlin.android)。
这个方案的优点很突出。TOML 是声明式的,Gradle 解析后生成类型安全的访问器,IDE 支持很好。版本号集中管理,库和插件统一在一个文件里。最重要的是,修改 libs.versions.toml 不会触发 buildSrc 那种级别的重新编译,Gradle 7.4 之后配合 configuration cache,配置阶段的性能损耗控制得不错。
但 Version Catalogs 的坑我踩过不少,而且有些坑是设计层面的,不是版本迭代能解决的。
第一个坑是版本对齐(version alignment)的表达能力有限。TOML 语法里 [versions] 块是纯字符串,想做点简单的版本计算都不行。比如 Compose Compiler 的版本必须严格对应 Kotlin 版本,Google 官方有明确的兼容性矩阵(Kotlin 1.9.0 对应 Compose Compiler 1.5.0,Kotlin 1.9.10 对应 1.5.1,等等)。在 buildSrc 里我可以写个函数自动映射,在 Version Catalogs 里只能硬编码两个版本号,然后在代码审查里靠人眼保证对齐。我实际遇到过因为 Kotlin 升级时漏改 Compose Compiler 版本,导致编译期报错 This version (1.4.0) of the Compose Compiler requires Kotlin version 1.8.22 的情况,错误信息还算明确,但修复就是一次没必要的 CI 失败。
第二个坑是跨项目复用困难。Version Catalogs 的文件是项目本地的,没有内置的发布和共享机制。Google 的推荐做法是用 dependencyResolutionManagement 里的 from 引用外部 catalog,但这要求把 TOML 文件发布到 Maven 仓库或者 Git 仓库,配置起来并不直观。我 2023 年在一个多仓库(multi-repo)的组织里尝试过共享 catalog,最后因为各个项目的 Gradle 版本不一致(有的卡在 7.4,有的升到 8.2),toml 文件的语法支持有差异,被迫放弃了统一,各项目各自维护一份拷贝。
第三个坑是插件版本管理的边缘 case。[plugins] 块里声明的插件,在 plugins 块里用 alias 引用时,如果插件有多个 variant 或者需要条件应用,写法会很别扭。比如 AGP(Android Gradle Plugin)的 application 和 library 插件,版本号是一样的,但在 TOML 里要声明两次,或者用一个 workaround 把插件当库来管理。Gradle 8.1 之后引入的 pluginManagement 里的 includeBuild 可以缓解一部分,但配置复杂度又上去了。
还有一个很具体的 bug:Gradle 7.6 之前的版本,Version Catalogs 和 buildSrc 同时存在时,catalog 的访问器在 buildSrc 的代码里不可用。这个限制在 Gradle 7.6 的 release notes 里有提到,但升级前排查起来很头疼——错误信息是 Unresolved reference: libs,完全看不出是版本限制。
我的建议是:新项目直接用 Version Catalogs,但要对它的局限有清醒认识。别被"官方推荐"的光环迷惑,遇到版本对齐、跨项目共享这类需求,提前做好 fallback 方案。
refreshVersions:被低估的自动化工具
如果说 Version Catalogs 是"管理",那 refreshVersions(https://github.com/jmfayard/refreshVersions)想解决的是"发现"的问题。这个插件由 Jean-Michel Fayard 开发,核心功能是自动扫描依赖的新版本,并在 TOML 文件(或 buildSrc)里标注 available updates。
安装很简单,在 settings.gradle.kts 里加插件:
plugins {
id("de.fayard.refreshVersions") version "0.60.3"
}然后运行 ./gradlew refreshVersions,它会生成或更新 versions.properties 文件,把可用的版本升级标注出来:
version.kotlin=1.9.0
## available=1.9.10
## available=1.9.20这个插件的独到之处在于它和 Version Catalogs 的集成。0.50 版本之后支持直接解析 libs.versions.toml,在 TOML 文件里用注释标注可用版本,而不是另起一个 versions.properties。这样版本管理的"单一数据源"还是 TOML 文件,不会分裂。
我用 refreshVersions 最爽的场景是技术债清理。一个搁置半年的项目,跑一次 ./gradlew refreshVersions,所有依赖的更新状态一目了然,不用逐个去 Maven Central 或者 GitHub releases 页面查。插件内置了版本过滤规则,可以排除 alpha、beta、RC 版本,只关注 stable updates,这个配置在 refreshVersions 块里调。
但 refreshVersions 的坑在于维护状态。2023 年下半年开始,项目的更新频率明显降低,0.60.3 版本之后有几个月没发新版。Gradle 8.0 的 breaking changes 导致了一些兼容性问题,社区里有人提了 PR 但合并很慢。我个人觉得这个项目处于"功能基本完成,维护人力不足"的状态,用是可以用,但别深度耦合,保持随时能剥离的灵活性。
另一个局限是 refreshVersions 只管"发现",不管"决策"。它告诉你 Kotlin 1.9.20 可用了,但不会告诉你这个版本和 Compose Compiler 1.5.5 兼不兼容。版本升级的最终决策还是需要人工判断,特别是 Android 生态里 Google 和 JetBrains 的版本矩阵经常有不一致的地方。
Renovate / Dependabot:CI 驱动的自动化
把依赖版本管理完全交给机器人,是另一种思路。Renovate(https://docs.renovatebot.com/)和 GitHub 原生的 Dependabot 都是这个路线的代表。
Renovate 的配置以 JSON 文件为主,支持 Gradle 的 buildSrc、ext 变量、Version Catalogs 等多种格式。安装方式是给 GitHub/GitLab 仓库授权 Renovate app,或者自托管 Renovate runner。配置文件的粒度很细,可以按依赖分组、设置自动合并规则、定义时区和工作时间。
我在一个 50 人左右的团队里推过 Renovate,配置长这样:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"gradle": {
"enabled": true
},
"packageRules": [
{
"matchPackagePatterns": ["^org.jetbrains.kotlin"],
"groupName": "Kotlin packages",
"schedule": ["before 4am on Monday"]
}
]
}效果很直观:每周一早上,Renovate 自动创建 PR,把 Kotlin 生态相关的依赖打包升级,CI 跑过就自动合并。团队从"人工检查版本"变成了"审阅机器人 PR",精力释放了不少。
但 Renovate 的坑在"自动化"和"控制"的平衡。默认配置下,Renovate 很激进,一个依赖一个 PR,大型项目一天能收到十几个 PR,噪音极大。必须花时间调 packageRules 做分组和聚合。另一个坑是 Gradle 支持的不完整——Renovate 解析 build.gradle 和 libs.versions.toml 的能力不错,但对自定义的 settings.gradle 逻辑、条件依赖(if (someFlag) implementation(...))处理不好,会漏掉或者误报。
Dependabot 是 GitHub 原生的,配置更简单,但功能也更单薄。它不支持 Version Catalogs 的 TOML 文件(至少 2023 年底之前还不支持),对 Gradle 的支持整体弱于 Renovate。如果你的项目已经全面转向 Version Catalogs,Dependabot 基本不可用。
这两个工具的共同局限是:它们解决的是"升级执行",不是"升级决策"。Android 生态里,AGP、Kotlin、Compose 的版本兼容性矩阵复杂,机器人不会帮你判断"这个组合能不能编译过"。我见过 Renovate 自动合并的 Kotlin 升级导致 Compose Compiler 不兼容,CI 挂了才发现,虽然没进主分支,但修复的上下文切换成本是实实在在的。
我的建议是:团队规模超过 10 人、依赖数量超过 30 个时,引入 Renovate 值得投入配置时间。小项目或者单人维护,手动管理 + refreshVersions 辅助更轻量。
Gradle 版本对齐(Platform/BOM):被忽视的官方机制
聊版本管理,很多人只关注"版本号放哪",忽略了 Gradle 内置的版本对齐机制。Gradle 的 platform 和 java-platform 插件,以及 Maven 世界的 BOM(Bill of Materials),是解决"同一个生态的版本一致性"的官方工具。
Android 开发者最熟悉的 BOM 可能是 Compose BOM(https://developer.android.com/jetpack/compose/bom)。Google 2022 年底推出的这个 BOM,把 Compose 各个 artifact(ui、foundation、material3 等)的版本绑定在一起,开发者只需要声明 BOM 版本,不用管各个子库的具体版本:
dependencies {
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
// 版本由 BOM 统一指定
}这个机制的本质是 Gradle 的 platform 依赖,它会强制该依赖树下的所有 Compose artifact 使用 BOM 指定的版本,解决"子库版本漂移"问题。
Compose BOM 的设计我觉得是 Google 对版本管理痛点的直接回应。Compose 生态里 ui、runtime、compiler 的版本必须严格对齐,之前靠文档和人肉检查,出错率高。BOM 把这种约束编码到构建系统里,降低了心智负担。
但 BOM 的局限也很明显:它只解决"同一个生态内部"的版本对齐,不解决"跨生态"的版本管理。比如 Compose BOM 不管 Kotlin 版本,也不管 AGP 版本。Kotlin 1.9.0 和 Compose Compiler 的兼容性,BOM 不覆盖,还是需要人工或者额外工具来保证。
自定义 BOM 在内部项目里也有用武之地。比如一个公司有多个 Android 模块,共享一套基础依赖(OkHttp、Retrofit、Coroutines 等),可以发布一个内部的 com.company:platform-android BOM,各模块引用这个 BOM 即可。配置成本是新建一个 java-platform 模块,发布到内部 Maven 仓库,维护成本是每次基础依赖升级要发新版 BOM。
我踩过的坑是 BOM 和 Version Catalogs 的交互。Version Catalogs 的 TOML 里可以声明 bundle 和 version.ref,但 platform 依赖的语法和常规库依赖不一样,在 TOML 里的写法容易混淆。Gradle 8.0 之前,platform(...) 在 catalog 访问器里的生成规则有 bug,某些场景下会生成错误的类型签名,编译报错信息很晦涩。
我的选择和折中方案
写到这里,该给个结论了。但我不想给一个"最佳方案"的武断判断,因为场景差异太大。
对于我现在在维护的项目(Gradle 8.2,Kotlin 1.9.10,模块数 80+),实际采用的是"Version Catalogs + Renovate + 自定义 Gradle 插件"的混合方案:
gradle/libs.versions.toml 作为唯一数据源,管理所有版本号这个方案的代价是维护成本:Renovate 的配置文件要随项目演进调整,兼容性检查插件要跟着 Google 和 JetBrains 的版本矩阵更新。但收益是明确的:过去一年里,没有一次因为依赖版本不兼容导致的 CI 失败进到了主分支。
对于更小型的项目,我会简化到"Version Catalogs + 手动管理"。refreshVersions 偶尔跑一次看看有没有大版本更新,但不进常规工作流。
对于还在用 Gradle 6.x 的老项目,我的建议是别折腾迁移到 Version Catalogs 了,Gradle 7.0 的升级成本可能很高(特别是 AGP 版本绑定的问题)。保持 buildSrc 或者 ext 变量,用 Renovate 的 Gradle 支持做基本的自动化,把精力放到更有价值的地方。
最后想提一个观察:Gradle 的版本管理方案演进,整体趋势是从"灵活但脆弱"(ext 变量)走向"约束但安全"(Version Catalogs + BOM)。这个趋势和 Kotlin 语言的设计哲学一致,也是大型软件项目治理的必然方向。但约束带来的不便是真实的,比如 Version Catalogs 里不能做版本计算、不能写条件逻辑,这些限制在特定场景下很烦人。工具选型永远是在"灵活性"和"可维护性"之间找平衡点,没有银弹。
这篇文章没覆盖到的方案还有一些,比如 Square 的 gradle-dependencies-sorter(格式化工具)、JetBrains 的 gradle-intellij-plugin 里的依赖管理辅助,以及一些团队内部用 Python/Node 脚本生成 TOML 文件的野路子。这些要么职责太窄,要么太定制化,不在主流选择的范围内。如果你有特别的场景和踩坑经历,欢迎在技术社区里分享,这类实践经验的流动比官方文档更有价值。