Version Catalogs 与 BOM:现代 Android 依赖治理的最佳实践
Version Catalog 和 BOM 经常被放在一起讨论,但它们解决的是两类完全不同的问题。
Version Catalog 是“依赖坐标的通讯录”:它把 androidx.core:core-ktx:1.17.0 这类字符串集中命名,让模块通过 libs.androidx.core.ktx 引用。BOM 是“版本约束的规则书”:它进入依赖解析图,告诉 Gradle 某一组模块应该如何对齐版本。
如果把依赖治理比作一座仓库,Version Catalog 是货架标签,方便所有人用同一套名字取货;BOM 是采购合同,真正影响最终允许进入仓库的版本。只统一标签而没有规则,冲突时 Gradle 仍然会按依赖解析引擎的规则选择版本。
Catalog 只是声明入口,不是解析规则
典型的 gradle/libs.versions.toml 长这样:
[versions]
agp = "9.2.0"
kotlin = "2.3.4"
okhttp = "5.3.2"
[libraries]
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
模块中使用:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
dependencies {
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
}
这段代码最终仍然会变成普通依赖声明。Catalog 不会阻止传递依赖把 OkHttp 升级到另一个版本,也不会强制所有模块使用同一套版本。Gradle 官方文档也明确指出,Catalog 中声明的版本通常不是 enforced,实际解析结果仍可能被冲突仲裁、constraints、platform 改写。
所以,Catalog 的正确定位是:
- 消灭魔法字符串。
- 提供 IDE 类型安全访问器。
- 集中维护常用版本和插件坐标。
- 让模块脚本表达“我要哪个库”,而不是反复复制坐标。
它不是锁文件,也不是依赖解析策略。
类型安全访问器如何生成
Gradle 在配置构建前会读取 libs.versions.toml,生成类型安全访问器。okhttp-logging 会映射成 libs.okhttp.logging,插件别名会映射成 libs.plugins.kotlin.android。
这个生成过程带来两个工程约束:
libs.versions.toml
|
|-- 解析 alias / version / bundle / plugin
v
Gradle 生成 accessor class
|
|-- build.gradle.kts 编译时引用 libs.xxx
v
脚本获得静态类型提示
第一,别名命名会影响生成的访问器层级。androidx-core-ktx 会形成 libs.androidx.core.ktx,过深或冲突的命名会让 API 不好用。
第二,Catalog 是构建脚本编译输入。频繁大规模改动会触发脚本重新编译,因此不要把动态业务配置塞进 Catalog。
BOM 进入解析图
BOM 本质上是一个 packaging=pom 且包含 <dependencyManagement> 的 Maven POM。Gradle 导入 BOM 时,会把其中的版本声明转成 dependency constraints。
dependencies {
implementation(platform("com.google.firebase:firebase-bom:34.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
}
解析时不是 firebase-auth “没有版本”,而是它的版本由 platform 约束补齐:
consumer configuration
|
+-- platform(firebase-bom)
| `-- constraints:
| firebase-auth -> 24.x
| firebase-firestore -> 26.x
|
+-- firebase-auth:(no version)
`-- firebase-firestore:(no version)
这解决的是一组库的内部兼容性。Firebase、Compose、Jackson、Spring 这类生态通常有多个模块并行演进,单独指定每个版本很容易拼出一个官方未验证过的组合。BOM 把“这一批模块应该一起用什么版本”作为平台规则发布出来。
platform 与 enforcedPlatform 的边界
Gradle 提供两种导入方式:
implementation(platform("group:artifact:version"))
implementation(enforcedPlatform("group:artifact:version"))
platform 添加普通约束,仍然允许解析引擎在更强约束或冲突规则下选择其他版本。enforcedPlatform 会强制覆盖图中的版本,并且这种强制可能传递给下游消费者。
工程中应优先使用 platform。enforcedPlatform 像一把强制锁,适合应用工程内部兜底,不适合随意放进可发布 library。一个 library 如果把 enforced constraint 泄露出去,下游应用可能被迫接受你的版本选择,造成难以诊断的冲突。
Catalog 与 BOM 的组合方式
最佳实践不是二选一,而是组合使用:
[versions]
firebaseBom = "34.7.0"
[libraries]
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-auth = { module = "com.google.firebase:firebase-auth" }
firebase-firestore = { module = "com.google.firebase:firebase-firestore" }
dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth)
implementation(libs.firebase.firestore)
}
这里 Catalog 管名字,BOM 管版本对齐。它们的职责没有重叠。
Android 工程的依赖治理分层
大型 Android 工程中,可以按风险把依赖治理分四层:
| 层次 | 工具 | 解决的问题 |
|---|---|---|
| 坐标命名 | Version Catalog | 所有模块用同一套依赖别名 |
| 生态对齐 | BOM / platform | 一组相关库版本兼容 |
| 强约束 | constraints / strict version | 禁止已知坏版本、修复安全漏洞 |
| 复现锁定 | dependency locking / verification metadata | 构建复现和供应链校验 |
不要把所有问题都塞进 libs.versions.toml。例如某个传递依赖存在漏洞,正确做法通常是添加 constraints 或升级平台,而不是只改一个 catalog 版本后假设图里所有路径都会跟着变化。
常见错误模型
错误一:以为 Catalog 能解决冲突。
Catalog 只负责把声明写得漂亮。最终版本仍由依赖图决定。诊断冲突要用 dependencyInsight,不是盯着 TOML。
错误二:所有库都写死版本。
对于 BOM 管理的生态,子模块依赖最好不写版本,让 platform 统一对齐。
错误三:在 library 中滥用 enforcedPlatform。
这会把你的强制版本传播给下游,破坏消费者的依赖决策权。
错误四:把插件版本和运行时代码库版本混在一起理解。
[plugins] 控制构建脚本 classpath,[libraries] 控制项目源码 classpath,它们作用在两张不同的图上。
工程风险与观测清单
Version Catalogs 与 BOM:现代 Android 依赖治理的最佳实践 一旦进入真实 Android 工程,最大的风险不是单个 API 写错,而是构建行为失去可解释性:一次小改动触发大面积重编译,CI 偶发超时,缓存命中却产物不可信,或者发布后才发现某条 variant 管线没有被覆盖。
因此,学习这个主题时要同时建立两套模型:一套解释底层机制,一套解释工程风险、观测信号、回滚策略和审计边界。前者让你知道系统为什么这样运行,后者让你在生产环境里能证明它确实按预期运行。
关键风险矩阵
| 风险点 | 触发条件 | 直接后果 | 观测方式 | 缓解策略 |
|---|---|---|---|---|
| 输入声明缺失 | 构建逻辑读取未声明文件或环境变量 | UP-TO-DATE 或缓存结果错误 | 使用 --info 和 Build Scan 查看输入变化 |
把所有影响输出的状态建模为 @Input 或 Provider |
| 绝对路径泄漏 | 任务 key 包含本机路径 | CI 与本地缓存无法复用 | 对比不同机器的 cache key 变化 | 使用相对路径敏感性和路径归一化 |
| 配置期副作用 | build script 执行 I/O、Git、网络请求 | 任意命令都变慢,configuration cache 失效 | 执行 help --scan 观察配置期耗时 |
把副作用移动到任务动作并声明输入输出 |
| Variant 污染 | 对所有 variant 注册重型任务 | debug 构建被 release 逻辑拖慢 | 查看 realized tasks 和 task timeline | 使用 selector 精确匹配目标 variant |
| 权限外溢 | 插件或脚本读取 CI secret、用户目录 | 构建不可复现,存在供应链风险 | 审计构建日志和环境变量访问 | 使用最小权限和显式 secret 注入 |
| 并发竞争 | 多个任务写同一输出目录 | 产物互相覆盖或偶发失败 | 检查 overlapping outputs 报告 | 每个任务拥有独立输出目录 |
| 缓存污染 | 不可信分支向远程缓存 push | 全团队复用错误产物 | 统计 remote cache push 来源 | 只允许受信任 CI 写入远程缓存 |
| 回滚困难 | 构建逻辑与业务变更混在一起 | 发布失败时无法快速定位 | 变更审计和构建 scan 对比 | 构建逻辑独立提交、独立验证 |
| 降级缺口 | 新 Gradle/AGP API 无兜底策略 | 升级失败后阻塞全线开发 | 记录兼容矩阵和失败任务 | 保留可回滚版本和迁移开关 |
| 资源释放遗漏 | 自定义任务打开文件句柄或进程未关闭 | Windows/CI 上清理失败或锁文件 | 观察 daemon 日志和文件锁错误 | 使用 Worker API 或 try/finally 释放资源 |
需要持续观测的指标
- 配置阶段耗时是否随模块数量线性或超线性增长。
- 单次本地 debug 构建的关键路径任务是谁。
- CI clean build 与 incremental build 的耗时差距。
- 远程 Build Cache 的 hit rate、miss 原因和下载耗时。
- Configuration Cache 的命中率和失效原因。
- Kotlin/Java 编译任务是否被不相关资源或依赖变化触发。
- 资源合并、DEX、R8、打包任务是否在小改动后全量重跑。
- 自定义插件是否提前实现了无关任务。
- 构建日志中是否出现未声明输入、重叠输出、deprecated API。
- 发布产物是否能追溯到唯一的源码提交、依赖锁和构建扫描。
- 失败是否可稳定复现,还是只在特定机器、特定并发下出现。
- 变更是否影响开发构建、测试构建和发布构建三条路径。
回滚与降级策略
- 构建逻辑变更与业务代码变更分开提交,便于二分定位。
- Gradle、AGP、Kotlin、JDK 升级必须保留兼容矩阵和回滚版本。
- 新插件能力先只接入一个低风险模块,再扩大到全工程。
- 远程缓存先 pull 后 push,确认产物稳定后再允许 CI 写入。
- 新增插桩、生成代码、资源处理逻辑必须提供开关。
- 发布构建失败时,优先回滚构建逻辑版本,而不是清空所有缓存碰运气。
- 对 CI 超时设置分阶段日志,确认卡在配置、依赖解析还是任务执行。
- 对不可恢复的构建产物变更记录迁移步骤,避免开发者本地状态残留。
最小验证矩阵
| 验证场景 | 命令或动作 | 期望信号 |
|---|---|---|
| 空任务配置成本 | ./gradlew help --scan |
配置期没有无关重任务 |
| 本地增量构建 | 连续执行同一 assemble 任务 | 第二次大量任务 UP-TO-DATE |
| 缓存复用 | 清理输出后启用 build cache | 可缓存任务出现 FROM-CACHE |
| Variant 隔离 | 分别构建 debug/release | 只出现目标 variant 相关任务 |
| CI 可复现 | 干净工作区执行 release 构建 | 不依赖本机隐藏文件 |
| 依赖稳定 | 执行 dependencyInsight | 版本选择可解释,无动态漂移 |
| 配置缓存 | --configuration-cache 连跑两次 |
第二次复用配置缓存 |
| 发布审计 | 记录 scan、mapping、签名信息 | 产物可追溯、可回滚 |
审计问题
- 这段构建逻辑是否有明确所有者,还是散落在多个模块脚本里。
- 它是否读取了没有声明为输入的文件、环境变量或系统属性。
- 它是否在配置阶段执行了本应放到任务动作里的工作。
- 它是否对所有 variant 生效,还是只应该对某些 variant 生效。
- 它是否可以在没有网络、没有本地 IDE 状态的 CI 中运行。
- 它是否把权限、密钥、签名文件路径写进了仓库。
- 它是否破坏了并发执行,例如多个任务写同一个目录。
- 它是否能在失败时输出足够日志,帮助定位根因。
- 它是否能通过一个开关降级,避免阻塞全工程构建。
- 它是否有最小复现样例或 TestKit/集成测试覆盖。
- 它是否会让下游模块承担不必要的依赖或任务成本。
- 它是否能在升级 Gradle/AGP 后继续工作,还是依赖内部 API。
反模式清单
- 用
clean掩盖输入输出声明错误。 - 用
afterEvaluate修补本可以用 Provider 表达的依赖关系。 - 用动态版本解决依赖冲突,却让构建不可复现。
- 把所有公共配置塞进一个巨型 convention plugin。
- 在 debug 构建默认开启 release 级别的重型优化。
- 在任务动作里读取
project或全局 configuration。 - 在多个任务中共享同一个临时目录。
- 缓存命中率异常时只重启 CI,不分析 miss reason。
- 把构建扫描链接当作可选附件,而不是性能回归证据。
- 用本地 IDE 成功运行证明 CI 发布链路安全。
最小实操脚本
./gradlew help --scan
./gradlew :app:assembleDebug --scan --info
./gradlew :app:assembleDebug --build-cache --info
./gradlew :app:assembleDebug --configuration-cache
./gradlew :app:dependencies --configuration debugRuntimeClasspath
./gradlew :app:dependencyInsight --dependency <module> --configuration debugRuntimeClasspath
这组命令覆盖配置期、执行期、缓存、配置缓存和依赖解析五条主线。任何 Version Catalogs 与 BOM:现代 Android 依赖治理的最佳实践 相关的改动,都应该能用其中至少一条命令解释它带来的行为变化。