4. 依赖锁定与版本目录

说到Gradle构建优化,依赖管理是个绕不开的话题。我个人习惯先把版本统一起来,再把依赖锁死——说白了就是先定规矩,再防乱改。

这一章我们聊两个核心工具:版本目录(libs.versions.toml)和依赖锁定(gradle.lockfile)。前者让所有模块共享同一份版本号,后者确保每次构建都用一模一样的依赖。我在项目中遇到过太多次“本地能跑,CI上就挂”的惨案,根源往往是依赖版本飘了。嗯,先别急,咱们一步步来。

4.1 版本目录:统一版本的艺术

以前管理版本号,大家喜欢在根项目build.gradle里写一堆ext或者buildscript。这样做的问题很明显——版本散落在各个文件里,升级时漏一个就炸了。你想想看,一个大型项目几十个模块,每个模块都写着com.squareup.okhttp3:okhttp:4.9.3,哪天想升级到4.10.0,得改多少地方?

版本目录是Gradle 7.0之后官方推荐的方案。它在gradle目录下放一个libs.versions.toml文件,把所有的版本号、库声明、插件声明都集中管理。来看个示例:

# gradle/libs.versions.toml
[versions]
agp = "8.2.0"
kotlin = "1.9.20"
okhttp = "4.12.0"
retrofit = "2.9.0"
coroutines = "1.7.3"

[libraries]
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

每个模块的build.gradle.kts里,直接这样引用:

// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
}

dependencies {
    implementation(libs.okhttp.core)
    implementation(libs.retrofit.core)
    implementation(libs.coroutines.core)
}
技巧:在 [versions] 里定义好的版本号,可以通过 version.ref 引用。如果某个库只需要一次,也可以直接写成 { module = "group:artifact", version = "1.2.3" },不过我个人建议尽量统一 version.ref,方便后续升级。

好处是什么?改版本只需改一个地方,所有模块自动同步。我记得有一次项目需要紧急修复一个OkHttp的CVE漏洞,从4.9.3升到4.10.0,我在 libs.versions.toml 里改了版本号,提交一个PR,CI跑一遍全绿——省掉了挨个模块比对的时间。

但这里要小心一个坑:版本目录的别名不能重复。我之前在一个团队里,看到有人在两个模块里分别声明了 libs.okhttp 和 libs.okhttp3,然后升级时漏掉了一个……你猜后来怎么着?API不兼容,崩溃率飙升。所以别名要语义化、唯一化,比如 okhttp-core、okhttp-logging 这种。

4.2 依赖锁定:让构建可复现

版本目录解决了“统一”的问题,但没解决“锁定”的问题。比如你定义OkHttp是4.12.0,但它自己可能还有传递性依赖呢?比如它依赖了 kotlin-stdlib 的某个版本,而那个版本在第二天又发布了补丁——这样同样的代码,昨天构建和今天构建得到的传递依赖可能不一样。

依赖锁定就是为了解决这个。Gradle内置了 gradle.lockfile 机制,每次构建时把所有直接依赖和传递依赖的精确版本都写进一个锁文件。只要锁文件不更新,构建永远用同一套版本。

开启方式很简单,在根项目的 settings.gradle.kts 里加一行:

// settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
    // 开启依赖锁定
    lockMode.set(LockMode.LENIENT)
}

然后执行一次 ./gradlew dependencies --write-locks,Gradle会在项目根目录生成 gradle.lockfile。这个文件记录了所有配置(比如 compileClasspath、runtimeClasspath)下每一个依赖的精确坐标。内容大概长这样:

# gradle.lockfile (部分)
com.squareup.okhttp3:okhttp:4.12.0=compileClasspath
com.squareup.okhttp3:okhttp-jvm:4.12.0=compileClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.20=compileClasspath
org.jetbrains.kotlin:kotlin-stdlib:1.9.20=compileClasspath
org.jetbrains:annotations:13.0=compileClasspath

锁文件要提交到版本控制。之后每次构建,Gradle都会先比对锁文件,如果发现当前解析出的依赖版本与锁文件不一致,就会报错。这能防止CI和本地环境不一致。

注意:我曾经在一个项目中,锁文件没提交到Git,结果新同事拉代码后构建出的APK比我们大5MB——因为传递依赖版本换了,引入了不必要的类库。后来我们把锁文件纳入版本控制,再也没出过这种问题。

更新锁文件呢?当你升级了版本目录里的版本号,或者改了依赖声明,需要重新生成锁文件:

./gradlew dependencies --update-locks

这会根据当前依赖解析结果更新锁文件。建议在CI上专门加一个 check-lockfile 任务,确保锁文件与构建结果一致。

4.3 实战:版本目录 + 依赖锁定双保险

光有版本目录,传递依赖还是会跑偏;光有依赖锁定,版本声明又分散。我一般把它们配合使用:

  • 版本目录声明所有直接依赖的版本
  • 依赖锁定固定所有传递依赖的版本

下面是一个完整的实战配置流程:

  1. 在 gradle/ 下创建 libs.versions.toml,按模块组织依赖
  2. 在 settings.gradle.kts 中开启 lockMode
  3. 执行 ./gradlew dependencies --write-locks 生成锁文件
  4. 将 gradle.lockfile 提交到Git
  5. CI脚本中增加 ./gradlew dependencies --check-lockfile 校验
  6. 升级版本时,先改 toml,再执行 --update-locks,提交两个文件

这样做的好处是:任何人都能复现完全一样的构建环境。我前两年在一个30+模块的项目里推这套方案,原先每周都要花半天处理依赖冲突,后来几乎归零。

关键指标对比:
场景无版本目录/锁定有版本目录+锁定
模块间版本同步手动粘贴,易出错自动同步,改一处
传递依赖版本每次构建可能不同锁文件固定
CI与本地一致性经常不一致强制一致
升级版本复杂度多文件修改+排查改toml+更新锁文件

4.4 避坑指南

讲几个我踩过的坑:

  • 锁文件不能手动编辑:格式严格,手动改容易导致解析失败。用 --update-locks 或 --write-locks 生成。
  • 多项目多配置:如果你有多个子项目,锁文件会包含所有配置。建议只锁定 compileClasspath 和 runtimeClasspath,减少文件尺寸。
  • Android Gradle Plugin 版本:AGP 7.0以上才完整支持依赖锁定,旧版本可能需要自己实现Locking。
  • 插件依赖也可以锁定:在 settings.gradle.kts 中声明的插件版本也会被锁。

我曾经因为没锁插件版本,导致不同开发者拿到不同版本的AGP,构建出的APK行为不一致——连ProGuard规则都变了,排查了一整天才发现。从那以后,只要是协作项目,我一定开锁文件。

进阶:如果你用Gradle 8.x,可以尝试 dependencyLocking 的 lockAllConfigurations(),它会锁定所有配置,连测试和注解处理器也锁定,更彻底。但锁文件会变大,酌情使用。
版本目录 + 依赖锁定 协同流程 libs.versions.toml 声明所有直接依赖版本 settings.gradle.kts 开启 lockMode gradle.lockfile 精确锁定传递依赖 版本目录组成 [versions] 版本号池 [libraries] 库声明 [plugins] 插件声明 别名引用统一 配置细节 lockMode = LENIENT repositories 设置 允许更新锁文件 校验锁文件一致性 锁文件内容 每行:坐标=配置 精确到版本哈希 包含间接依赖 需提交版本控制 协同效果 版本目录统一版本号 → 锁文件固定传递依赖 → 构建可复现 💡 双保险:版本目录解决“声明统一”,依赖锁定解决“传递可靠”

4.5 小结与经验

版本目录和依赖锁定,一个管“主动”,一个管“被动”。主动是说我们明确要哪些版本;被动是说那些我们没声明的传递依赖,锁文件帮我们按住。

我个人的建议是:小项目至少用版本目录,中大型项目必须两者都用。尤其是多人协作的团队,加上CI校验锁文件的一致性,能省掉大量排查依赖问题的时间。