简介:面向Android开发者的Gradle依赖统一管理示例工程,适合已具备基础构建知识、希望集中维护版本号与依赖配置的开发者参考。压缩包共34个文件、约100KB,包含gradle构建脚本、properties配置、xml清单、Java源码与jar包,并以bat批处理脚本和png示意图辅助整理常用命令与操作流程。目录围绕根工程、app模块及config.gradle展开,可帮助读者理解将依赖版本、模块配置与构建逻辑抽离统一管理的做法,同时保留ProGuard规则、Git忽略配置和README说明,便于快速对照改造现有项目。已有3661人学习下载,可作为梳理Gradle常用命令、排查依赖冲突和规范项目构建的轻量参考。
1. Gradle 依赖的统一管理:为什么“能跑就别动”最后一定会翻车
上个季度接手一个十四模块的老工程,光 okhttp 就有 3.14、4.9、4.12 三个版本同时躺在运行时依赖树里,谁引的、什么时候引的,没人说得清。Gradle 依赖的统一管理解决的正是这类问题:把版本号、坐标、插件版本从散落各处的 build.gradle 字符串里抽出来,收口到一份可读、可比对、能被 IDE 补全的声明文件中。它适合模块数超过 3 个、或多人协作半年以上的 Android/Java 工程;单模块小 Demo 硬套只会多一层心智负担,反而拖慢节奏。下面按我实际迁移的顺序讲:先把 catalog 骨架立住,再谈存量代码怎么批量搬,然后是构建逻辑复用,最后落到 CI 卡口。
2. 立 catalog 骨架:libs.versions.toml 的四个区块与别名映射规则
2.1 四个区块各管什么,哪些东西不该塞进来
Version Catalog 的载体是gradle/libs.versions.toml,Gradle 7.4 起正式可用,8.x 上是默认推荐形态。整个文件只有四类顶层表:[versions]存纯版本字符串,[libraries]存坐标(group/name/version 或 module + version.ref),[bundles]把若干 library 别名打包成一个引用,[plugins]存插件 id 与版本。理解这个划分的关键是:[versions]是唯一的版本真源,其他区块只能version.ref引用它,不能内联写字面量——一旦内联,统一管理就破功了。
反过来,有些东西不该放进去。构建脚本里自己定义的自定义任务参数、buildTypes里的签名配置、flavorDimensions这类业务向配置,都不属于依赖坐标范畴,塞进 toml 只会让文件变成第二个gradle.properties。我一般只放三类:第三方 maven 坐标、Gradle 插件、以及跨模块必须对齐的 SDK 版本号(compileSdk、minSdk、targetSdk可以放[versions]里,然后用libs.versions.compileSdk.get().toInt()读,这是常见做法)。
还有一点容易被忽略:[bundles]不是越多越好。曾见过有人把整个androidx全家桶打成一个 bundle,结果每个模块只要用其中一个库就得整包引入,编译期看着清爽,实际把依赖面撑大了。bundle 的合理粒度是“同一个使用场景下必然一起出现的一组”,比如网络层三件套(retrofit + converter + logging-interceptor)、测试层的 junit + mockito。超过五个就别打 bundle 了,说明这组依赖本身没有强内聚。
2.2 手写一份能直接跑起来的 toml
下面这份是我从一个真实迁移项目里脱敏后的骨架,覆盖了常见 Android 工程的依赖面。文件放在工程根目录的gradle/libs.versions.toml,Gradle 会自动识别,不需要任何额外声明。
# gradle/libs.versions.toml [versions] # 构建工具链版本,注意 agp 与 gradle wrapper 版本要匹配 agp = "8.2.2" kotlin = "1.9.22" # SDK 版本收口,避免各模块各写各的 compileSdk = "34" minSdk = "24" targetSdk = "34" coreKtx = "1.12.0" appcompat = "1.6.1" material = "1.11.0" retrofit = "2.9.0" okhttp = "4.12.0" coroutines = "1.7.3" junit = "4.13.2" androidxTestExt = "1.1.5" espresso = "3.5.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } # 同一版本号被多个坐标复用,version.ref 指到同一个 version 键 retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } [bundles] networking = ["retrofit", "retrofit-converter-gson", "okhttp-logging"] test-unit = ["junit"] test-android = ["androidx-test-ext-junit", "androidx-espresso-core"] [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }逻辑说明:[versions]里每个键都是一个独立版本槽位,retrofit和retrofit-converter-gson共用同一个槽位,这是保证同一家族库版本一致的最省事写法。[bundles]里的值必须是[libraries]中已定义的别名,写错名字 Gradle 会在配置阶段直接报错,不会静默放过。[plugins]的id必须是插件在 Maven 上的真实 id,比如 AGP 的是com.android.application,而目录里的别名android-application只是你叫它什么。
参数说明:compileSdk/minSdk/targetSdk放[versions]是我个人的习惯,好处是升级 SDK 只在 toml 里改一处;但要注意它们被当成字符串存,读取时要.toInt()。kotlin-kapt是为还在用注解处理器的老项目留的,如果全量上了 KSP,这一条可以直接删掉,换成ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }。
2.3 三种引用写法与别名到 Groovy/Kotlin 的映射规则
toml 里的别名不是随便起的,Gradle 会按固定规则把它转成属性路径:别名中的-、_、.全部被视为层级分隔符,转换成嵌套属性。所以androidx-core-ktx会变成libs.androidx.core.ktx,retrofit-converter-gson变成libs.retrofit.converter.gson。这条规则不搞明白,后面的报错会让人抓狂。
// app/build.gradle.kts plugins { // 插件引用用 alias(),不是直接用 libs.plugins.xxx alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } dependencies { // 单个库:按别名映射出来的属性路径引用 implementation(libs.androidx.core.ktx) implementation(libs.material) // bundle 用 libs.bundles.xxx,一次展开成多个依赖 implementation(libs.bundles.networking) // 测试依赖,用 bundle 一次性带进测试层 testImplementation(libs.bundles.test.unit) androidTestImplementation(libs.bundles.test.android) }这是 Kotlin DSL 的写法,也是我在新项目里默认用的。如果工程还停留在 Groovy DSL,引用方式略有不同:implementation libs.androidx.core.ktx不需要括号,bundle 是implementation libs.bundles.networking,插件则是alias(libs.plugins.android.application)保持不变。
逻辑说明:alias(...)是专门为插件设计的语法,它会把 catalog 里的插件条目转成PluginDependency交给plugins {}块处理,写错会直接在 settings 阶段报错、定位很快。libs.bundles.xxx返回的是一个List<MinimalExternalModuleDependency>,Gradle 在依赖解析时会把它展开成多条依赖,所以 bundle 只是书写便利,不会改变实际依赖图。
参数说明:属性路径的命名映射有个坑——如果一个别名同时是另一个别名的前缀,Gradle 会生成嵌套对象而不是两个平级属性。比如同时存在okhttp和okhttp-logging,libs.okhttp会指向okhttp这个库,而libs.okhttp.logging才指向 logging-interceptor,两者不冲突,这是 Gradle 支持的链式映射。但如果存在retrofit和retrofit2,前者没有子属性、后者是独立库,两个都能用;真正会出问题的是foo和foo-bar这种——libs.foo就变成了一个既有坐标又有子属性的节点。我的规避办法是别名里永远带完整层级,比如retrofit和retrofit-converter-gson,而不是retrofit和retrofitconverter。
3. 存量项目迁移:用脚本把散落的 implementation 批量搬进 catalog
3.1 先摸清家底,别急着改
迁移最忌讳上来就动手。第一步永远是导出全量依赖树,看清楚哪些坐标、哪些版本真实存在。Gradle 自带的dependencies任务够用,关键是要选对 configuration——releaseRuntimeClasspath通常是最有参考价值的,因为它是最终打进包里的那批。
# 导出 app 模块在 release 运行时配置下的完整依赖树 ./gradlew :app:dependencies --configuration releaseRuntimeClasspath > deps-app.txt # 多模块工程,对每个模块都导出一次,方便后续做差集 for m in app core network feature-home feature-profile; do ./gradlew ":$m:dependencies" --configuration releaseRuntimeClasspath > "deps-$m.txt" done # 只看直接依赖(加--depth 1 减少噪音) ./gradlew :app:dependencies --configuration releaseRuntimeClasspath --depth 1逻辑说明:dependencies任务输出的是人读的树状结构,每行以+---或\---开头,缩进表示层级。--configuration必须显式指定,不指定的话它会打印所有 configuration,输出量能到几十兆,根本没法看。多模块逐模块导出,是为了后面判断某个库是“全工程都用”还是“只有某个 feature 用”,前者应该进主 catalog,后者值得斟酌。
参数说明:--depth 1只展开直接声明的依赖,适合快速盘点;但要查版本冲突必须去掉这个参数,因为冲突信息只出现在传递依赖被提升(->箭头)的那些行里。另外dependencies输出的版本号带(*)标记的表示该依赖被其他路径重复引用且版本已对齐,带->的表示发生了版本提升,这两种标记是排查冲突的入口。
导出之后我会做一遍人工归并:把 14 个文件里的坐标抽出来,按 group 分组,统计每个 group 出现了几个不同版本。这一步不需要工具,grep -oE '[a-zA-Z0-9._-]+:[a-zA-Z0-9._-]+:[0-9][^ ]*' deps-*.txt | sort -u一把梭就够。归并结果直接决定 toml 里[versions]怎么写——同一个 group 出现多版本的,一律取最高版本作为统一目标,理由后面讲冲突时会说。
3.2 批量改写脚本:从正则到 AST 的取舍
这一步就是很多人嘴里说的“GradleBatchfile”思路:写一个批处理脚本,把各个 module 的build.gradle/build.gradle.kts里硬编码的坐标替换成 catalog 引用。我一开始想上 Kotlin AST,后来发现成本太高,实际项目里 90% 以上的依赖声明都是规整的单行字符串,正则完全够用,剩下 10% 手工处理更省心。
# tools/migrate_to_catalog.py import re import sys from pathlib import Path # 匹配形如 implementation("group:artifact:version") 的声明 # 兼容 Kotlin DSL 的圆括号和 Groovy DSL 的裸写法 PATTERN = re.compile( r'(?P<indent>\s*)' r'(?P<conf>implementation|api|compileOnly|runtimeOnly|testImplementation|' r'androidTestImplementation|kapt|ksp)\s*\(?\s*' r'["\'](?P<group>[\w.\-]+):(?P<artifact>[\w.\-]+):(?P<ver>[^"\']+)["\']\s*\)?' ) def to_alias(group: str, artifact: str) -> str: """把 group:artifact 转成 catalog 别名,规则与团队约定保持一致""" raw = f"{group}-{artifact}" # 去掉常见前缀,避免别名过长 raw = raw.replace("com.", "").replace("org.", "").replace("androidx.", "androidx-") alias = re.sub(r"[^a-zA-Z0-9]+", "-", raw).strip("-").lower() return alias def convert(path: Path, dry_run: bool = True): src = path.read_text(encoding="utf-8") hits = [] def repl(m: re.Match) -> str: alias = to_alias(m.group("group"), m.group("artifact")) hits.append((m.group("group"), m.group("artifact"), m.group("ver"), alias)) return f'{m.group("indent")}{m.group("conf")}(libs.{alias.replace("-", ".")})' out = PATTERN.sub(repl, src) if not dry_run and hits: path.write_text(out, encoding="utf-8") return hits if __name__ == "__main__": dry = "--apply" not in sys.argv total = 0 for f in Path(".").rglob("build.gradle*"): if "build-logic" in str(f) or "buildSrc" in str(f): continue # convention plugin 自身不参与替换 hits = convert(f, dry_run=dry) for g, a, v, alias in hits: print(f"{f}: {g}:{a}:{v} -> libs.{alias.replace('-', '.')}") total += len(hits) print(f"{'[DRY-RUN] ' if dry else ''}共处理 {total} 处")逻辑说明:脚本的核心是那条正则,conf分支覆盖了implementation、api、kapt、ksp等常用配置名,\(?和\)?让它同时兼容 Kotlin DSL 的implementation("...")和 Groovy DSL 的implementation '...'。--apply参数控制是否真实写盘,默认 dry-run,这是血泪经验——第一次跑就写盘,结果把注释里的示例代码也替换了,事后 diff 看得人头皮发麻。rglob会递归扫全工程,但跳过build-logic和buildSrc,因为这两个目录里用的是compileOnly声明插件依赖,不属于业务依赖,混进来会把 catalog 撑爆。
参数说明:to_alias里的前缀剥离规则要按团队实际情况改,com.、org.这些通用前缀剥掉能让别名短一些,但要注意别把com.google.android.material剥成google-android-material之后和别的东西撞名。写盘前建议先git add -A && git commit,跑完一次git diff逐行看,我一般会把 diff 贴在群里让另一个人过一眼再合。
脚本跑完之后还有两件事是脚本干不了的:一是把生成的别名去重合并(同一坐标在不同模块可能被转成不同别名,因为正则匹配到的 group 写法有大小写差异),二是把出现过的版本号回填到 toml。这两步我是拿脚本输出的 CSV 手工过一遍,20 分钟的事,比写一个更复杂的脚本划算。
3.3 BOM 与 platform:版本号到底该交给谁
有些库家族天生就适合用 BOM(Bill of Materials)管版本,比如 Firebase、Compose、OkHttp 都有官方 BOM。这时候 catalog 里的写法要变——library 条目不再带version.ref,版本由platform()统一提供。
// app/build.gradle.kts dependencies { // BOM 本身进 catalog,但不带版本 implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) // 也可以对非 BOM 的模块用 platform 做版本对齐 implementation(platform(libs.okhttp.bom)) implementation(libs.okhttp) implementation(libs.okhttp.logging) }对应 toml:
[libraries] firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor" }逻辑说明:BOM 表中的 library 条目只写 group 和 name,不写version或version.ref,这是 Gradle 允许的写法,表示“版本由外部约束提供”。如果硬给它们加版本,platform()的约束会被覆盖,BOM 就白加了。注意platform()必须写在依赖块里,不能写在plugins {}里;它返回的是一个PlatformDependencyModifier包装,Gradle 会把它当成版本约束来源而不是实际依赖。
参数说明:BOM 的版本选择有个取舍——跟官方推荐走最稳,但如果项目里某个库需要固定到旧版本,就得在dependencies { constraints { ... } }里单独压,不要在 BOM 上动刀。另外platform()对implementation生效,对kapt或ksp配置不生效,注解处理器的版本还得单独在 catalog 里声明。
4. 构建逻辑也统一:buildSrc 与 convention plugin 的取舍
4.1 目录结构、settings 接入与选择理由
依赖统一管理的下一层是构建逻辑统一。当工程里十几个模块的android {}块几乎一模一样时,复制粘贴带来的不是效率,是升级compileSdk时改十三处的痛苦。方案有两个:buildSrc和build-logic(included build)。我的判断标准很简单——buildSrc的缺点是对任何改动都触发全量重编,一个大工程改一行插件代码要等三分钟;build-logic作为 included build 有缓存隔离,日常开发体验好得多。所以新工程默认build-logic,老工程如果已经在用buildSrc且模块数少于 5,维持现状也没问题。
project-root/ ├── settings.gradle.kts ├── build.gradle.kts ├── gradle/ │ └── libs.versions.toml # 全工程唯一的版本真源 ├── build-logic/ # included build │ ├── settings.gradle.kts │ └── convention/ │ ├── build.gradle.kts │ └── src/main/kotlin/ │ ├── AndroidLibraryConventionPlugin.kt │ └── AndroidAppConventionPlugin.kt ├── app/ │ └── build.gradle.kts └── core/network/ └── build.gradle.kts// settings.gradle.kts pluginManagement { // 把 build-logic 作为一个插件来源纳入,必须写在 repositories 之前 includeBuild("build-logic") repositories { gradlePluginPortal() mavenCentral() google() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "sample-app" include(":app", ":core:network", ":core:data")逻辑说明:includeBuild("build-logic")把子构建纳入插件解析路径,之后在任意模块的plugins {}里就能用build-logic中注册的插件 id 了。RepositoriesMode.FAIL_ON_PROJECT_REPOS这行值得单独说——它强制所有仓库声明只能出现在 settings 里,模块级的repositories {}会直接报错。这是把“仓库统一”也一起管住的手段,配合 catalog 才算是完整的依赖治理。
参数说明:build-logic/settings.gradle.kts里要单独声明dependencyResolutionManagement的仓库,因为 included build 有自己独立的构建环境,不会继承根工程的仓库配置。这一点经常被漏掉,表现是 convention 插件编译时报找不到 AGP。
4.2 写一个可复用的 Android convention plugin
convention plugin 的本质是一个Plugin<Project>实现,把重复的配置块搬进去。下面这个类负责所有 library 模块的通用配置,包括应用 AGP 和 Kotlin 插件、设置 SDK 版本、配置 Java 兼容级别。
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt import com.android.build.gradle.LibraryExtension import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.getByType class AndroidLibraryConventionPlugin : Plugin<Project> { override fun apply(target: Project) = with(target) { // 1. 先在目标工程上应用基础插件,后续配置才有效 pluginManager.apply("com.android.library") pluginManager.apply("org.jetbrains.kotlin.android") // 2. 从 version catalog 读 SDK 版本,避免在插件里硬编码 val catalogs = extensions.getByType<VersionCatalogsExtension>() val libs = catalogs.named("libs") val compileSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt() val minSdk = libs.findVersion("minSdk").get().requiredVersion.toInt() // 3. 统一配置 android 扩展块 extensions.configure<LibraryExtension> { this.compileSdk = compileSdk defaultConfig.minSdk = minSdk compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } } } }逻辑说明:apply里第一步永远是pluginManager.apply,因为LibraryExtension这个类型只有应用了 AGP 之后才存在,顺序错了会抛Extension not found。第二步通过VersionCatalogsExtension拿到libs,这是 Gradle 7.4 之后在插件里读 catalog 的标准姿势,findVersion("compileSdk").get().requiredVersion拿到的是字符串,要转 int 得自己.toInt()。第三步的extensions.configure<LibraryExtension>是 Kotlin DSL 的扩展配置语法,等价于 Groovy 里的android { ... }。
参数说明:JavaVersion.VERSION_17要跟你的 JDK 和 AGP 版本匹配——AGP 8.x 要求 JDK 17,AGP 7.4 还能用 11。如果要给不同模块用不同兼容级别,就别在这里写死,改成从 catalog 的[versions]里读一个javaVersion槽位。另外findVersion(...).get()在键不存在时会抛NoSuchElementException,如果希望缺键时 fail fast,这样写没问题;如果希望兜底,用.orNull?.requiredVersion?.toIntOrNull() ?: 34。
注册插件的 id 在build-logic/convention/build.gradle.kts里:
plugins { `kotlin-dsl` } dependencies { // 注意:这里用 compileOnly,避免把 AGP 打包进插件 compileOnly("com.android.tools.build:gradle:8.2.2") compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22") } gradlePlugin { plugins { register("androidLibrary") { id = "sample.android.library" // 这个 id 就是模块里引用的名字 implementationClass = "AndroidLibraryConventionPlugin" } } }然后 app 模块的build.gradle.kts就瘦成几行:
plugins { id("sample.android.library") } dependencies { implementation(libs.bundles.networking) testImplementation(libs.bundles.test.unit) }逻辑说明:compileOnly是关键——AGP 和 Kotlin 插件在运行期由宿主构建提供,如果这里用implementation会把整个 AGP 打进插件 jar,构建时间暴涨且版本可能冲突。id用倒置域名风格但不要用真实公司域名,随便起一个不冲突的前缀即可。
参数说明:gradlePlugin { plugins { register(...) } }里注册的每个条目会生成对应的插件描述符文件,apply时按id查。如果后面要给 app 模块单独写一个插件,register("androidApp") { id = "sample.android.app" }再加一个类就行,不用新建子工程。
4.3 在 convention plugin 里正确读 catalog
上一节已经展示了通过VersionCatalogsExtension读版本的写法,这里说两个细节。第一个是extensions.getByType<VersionCatalogsExtension>()写在with(target)作用域里,target是apply 传入的 Project,这样拿的是目标工程的扩展,不是插件自身的。第二个是libs.findLibrary("okhttp").get().get().toString()能拿到坐标字符串(两个get()一个是 Optional、一个是Provider),但这个 API 返回的是MinimalExternalModuleDependency的字符串形式,直接拼进依赖声明里能用但不优雅,我一般只拿它做日志输出。
如果插件只是给模块加一批通用依赖(比如所有 library 模块都要有 coroutines),写法是:
target.dependencies.add("implementation", libs.findLibrary("kotlinx.coroutines.android").get())target.dependencies.add(configurationName, dependency)这个 API 接受Provider<MinimalExternalModuleDependency>,Gradle 在解析期才会求值,不会提前触发配置。这是 convention plugin 里加默认依赖的标准姿势,比implementation(libs.xxx)这种 DSL 写法更适合写在插件代码里。
5. 避坑与排查:catalog 不生效、版本冲突、IDE 标红的 5 个现场
5.1 现象:改完 toml 后 IDE 满屏 “Unresolved reference: libs”
原因:Gradle 侧其实已经生效了,是 IDE 的模型缓存没刷新。Android Studio 对 catalog 文件的监听不是实时的,尤其是从 Git 拉取别人改过的 toml 之后,IDE 还在用旧的类型模型。极少数情况是 toml 真的写错了,比如某个 library 引用了不存在的[versions]键。
解决:先执行./gradlew --stop停掉所有 daemon,再./gradlew :app:dependencies --configuration debugRuntimeClasspath --dry-run触发一次模型重算,然后在 IDE 里点 Sync Project with Gradle Files。如果还不行,检查 toml 里每个version.ref指向的键是否真实存在于[versions]中——这个错误在命令行构建时会报得很明确,搜Could not find version就能定位。我踩过一次更隐蔽的:toml 文件本身没问题,但我不小心在gradle/目录下放了一个libs.versions.toml.bak,Gradle 把两个文件都读进去了,报了个模棱两可的重复定义错误。
5.2 现象:同一份依赖树里 okhttp 出现 4.9 和 4.12 两个版本,打了包才发现在跑旧的
原因:catalog 只统一了“声明”,统一不了“传递依赖”。某个三方 SDK 内部硬编码依赖了 okhttp 4.9,Gradle 默认的冲突策略是“取最高版本”,正常情况下应该提升到 4.12,但如果某个模块用了force或者strictly,提升会被打断。
解决:先跑./gradlew :app:dependencyInsight --dependency okhttp --configuration releaseRuntimeClasspath,它会打印出完整的引用路径和冲突解决结果,谁在拖后腿一目了然。确认是旧版本被优先选中后,在 catalog 里把 okhttp 版本统一,并在dependencies { constraints { implementation(...) { because(...) } } }里加一条约束。不要用force,force会覆盖所有路径且不留解释,半年后没人看得懂为什么这么写。
5.3 现象:别名里带点号,IDE 补全出奇怪的层级
原因:前文说过,别名里的.、-、_都是层级分隔符。如果起了一个androidx.core-ktx这样的别名,生成的属性路径是libs.androidx.core.ktx,属性名和预期可能不一致;更麻烦的是大小写,别名全小写才能转成合法的属性名,写成Retrofit会直接构建失败。
解决:别名统一用小写加中划线,转换时在脑子里把每个中划线换成点。团队里约定一条规则:group 段用中划线连接、artifact 段跟在后面,比如androidx-core-ktx、com-squareup-okhttp-logging,这样映射出来的路径可预测。对于有子依赖家族(retrofit 和它的 converter),别用点号,用中划线retrofit-converter-gson,路径是libs.retrofit.converter.gson,语义清晰。
5.4 现象:根工程能读 catalog,子模块报libs未定义
原因:libs这个扩展是由 settings 的dependencyResolutionManagement { versionCatalogs }自动注册的,如果子模块的build.gradle.kts里手动写了dependencies { implementation("...") }倒不受影响,但只要用了libs.xxx就必须确保 catalog 被识别。常见触发场景是:子模块的构建脚本被buildSrc或其他 included build 纳入编译,而那个子构建没有自己的libs扩展。
解决:如果是 convention plugin 里读 catalog,用 4.3 说的VersionCatalogsExtension显式取,不要直接写libs(插件源码里根本没有这个访问器)。如果是普通子模块报这个错,检查它是不是被 include 到了build-logic的 settings 里——included build 是独立构建,看不到主工程的 catalog。
5.5 现象:toml 里 agp 从 8.1 升到 8.2,但构建行为没变,报错还是旧版本的错
原因:[plugins]里的agp版本只影响“通过 catalog 引用插件”的模块。如果某个模块的build.gradle里还留着手写的classpath 'com.android.tools.build:gradle:8.1.0'(老式 buildscript 写法),或者根工程的buildscript { dependencies { classpath ... } }里还有一份,两套版本会打架,实际生效的往往是 buildscript 那份。
解决:全工程搜com.android.tools.build:gradle和org.jetbrains.kotlin:kotlin-gradle-plugin,确保只存在于build-logic/convention/build.gradle.kts的compileOnly声明和 catalog 的[plugins]中。老式 buildscript 写法一律删掉,迁移到plugins {}块。改完跑./gradlew buildEnvironment看一眼插件类路径,确认没有两份 AGP 并存。
6. 把依赖治理固化成卡口:依赖报告、版本对齐与 CI 校验
迁移做完只是开始,真正防止回退的是 CI 卡口。我一般会在 pipeline 里加一个轻量脚本,专门检查两件事:一是所有build.gradle(.kts)里是否又出现了硬编码的三段式坐标,二是 toml 里的每个[versions]槽位是否真的被引用。第一条防回归,第二条防僵尸版本键堆积。
# tools/check_catalog.py —— 放进 CI,退出码非 0 即阻断 import re import sys import tomllib from pathlib import Path HARDCODED = re.compile(r'["\'][\w.\-]+:[\w.\-]+:\d[\w.\-]*["\']') ROOT = Path(__file__).resolve().parent.parent def load_catalog(): with open(ROOT / "gradle/libs.versions.toml", "rb") as f: return tomllib.load(f) def find_hardcoded(): bad = [] for f in ROOT.rglob("build.gradle*"): if any(p in str(f) for p in ("build-logic", "buildSrc", "/build/")): continue for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1): if line.strip().startswith("//"): continue if HARDCODED.search(line): bad.append(f"{f.relative_to(ROOT)}:{i}: {line.strip()}") return bad def find_unused_versions(catalog): text = (ROOT / "gradle/libs.versions.toml").read_text(encoding="utf-8") defined = list(catalog.get("versions", {}).keys()) return [v for v in defined if f'version.ref = "{v}"' not in text and f'version = {{ ref = "{v}" }}' not in text] if __name__ == "__main__": cat = load_catalog() hard = find_hardcoded() unused = find_unused_versions(cat) for h in hard: print(f"[硬编码依赖] {h}") for u in unused: print(f"[未引用的版本槽位] {u}") sys.exit(1 if hard else 0)逻辑说明:find_hardcoded用正则扫所有构建脚本,跳过注释行和构建产物目录,匹配形如"group:artifact:1.2.3"的字面量。find_unused_versions做了个反向检查——某个 version 键在文件里没有被任何version.ref引用,说明它可能是迁移遗留。这个检查只告警不阻断,因为有些版本键是留给 convention plugin 读的,不通过version.ref引用。
参数说明:tomllib是 Python 3.11+ 的内置模块,CI 镜像版本低的话换成tomli。sys.exit(1 if hard else 0)只让硬编码阻断构建,未使用槽位仅输出提示,这样不会因为一条无害的告警把整条 pipeline 卡死。
版本对齐那部分,我不建议在 CI 里做“自动提升”,风险太大。更稳的做法是每周跑一次./gradlew :app:dependencyInsight --dependency <频繁冲突的库> --configuration releaseRuntimeClasspath并把结果存档,趋势性看哪些库的传递依赖在慢慢分叉。真有分叉,回到 catalog 里把版本槽位调高,走一次正常的 PR 流程。
从那以后我每次新建模块,都强制先跑一遍./gradlew :模块名:dependencies --configuration releaseRuntimeClasspath确认libs引用全部解析成功再提交,这个习惯帮我挡掉过至少三次“本地能编、CI 挂掉”的玄学问题。习惯比工具重要,工具只是把习惯固化下来。希望帮到你。
本文还有配套的精品资源,点击获取