3. 依赖管理基础:仓库配置、依赖声明、传递性依赖、版本冲突解决
依赖管理,说白了就是 Gradle 帮你把项目需要的第三方库「拿过来、用起来、管清楚」。很多人一开始觉得这步很简单——配个仓库,写个 implementation 就完事了。但我在实际项目中见过不少因为依赖问题引发的「血案」:编译莫名其妙失败、运行时 ClassNotFoundException、构建时间越来越长……其实根子都在依赖管理上没理清。
这一节我会把依赖管理的四个核心环节拆开讲:仓库去哪配、依赖怎么声明、传递性依赖怎么控制、版本冲突怎么解决。嗯,把这四个点吃透了,你的构建文件就能写得干净又稳当。
3.1 仓库配置——依赖从哪来
Gradle 的依赖仓库,你可以理解成「代码的快递」。常用的有这几个:
- mavenCentral() —— 官方中央仓库,最稳定,我建议优先用它。
- google() —— Android 开发必备,存放 AndroidX 和 Google 相关库。
- mavenLocal() —— 本机构建的产物,适合本地调试。
- 自定义私有仓库 —— 公司内部库或自研组件,用
maven { url '...' }指定。
我曾经在一个项目里看到有人把 jcenter() 还留着,结果某天 CI 构建突然报 404。jcenter 已经进入只读状态了,新版本不会同步过去。嗯,早点迁移到 mavenCentral 能省不少麻烦。
repositories {
maven { url 'https://nexus.yourcompany.com/repository/maven-public/' }
mavenCentral()
google()
}
3.2 依赖声明——用对 scope 省一半编译时间
依赖声明是日常打交道最多的部分。但你真的用对了吗?
我个人习惯遵循一个原则:能用 implementation 就不用 api。为什么?因为 api 会把依赖暴露给调用方,导致增量编译范围扩大。我在项目中见过有人图省事,所有依赖全用 api,结果改一行代码要重新编译几十个模块……用 implementation 之后,编译时间直接砍了 40%。
| 配置项 | 作用域 | 是否传递 | 推荐场景 |
|---|---|---|---|
| implementation | 编译 + 运行 | 不传递 | 模块内部依赖,对外隐藏实现 |
| api | 编译 + 运行 | 传递 | 接口或抽象类所在依赖 |
| compileOnly | 仅编译期 | 不传递 | 注解处理器、编译期注解 |
| runtimeOnly | 仅运行期 | 不传递 | JDBC 驱动、日志实现 |
你想想看,如果模块 A 用 api 声明了 Guava,模块 B 依赖 A,那 B 也能直接用到 Guava。一旦 Guava 升级,A 和 B 都得重新编译。而用 implementation 的话,A 升级 Guava,B 完全不受影响,增量编译只编译 A 本身。这差距在大型项目里非常明显。
3.3 传递性依赖——看不见的「亲戚关系」
你引入一个库,它自己又带着一堆依赖,这就是传递性依赖。大多数情况下 Gradle 会自动拉取这些「亲戚」,但有时候也会带来麻烦。
什么时候需要干预?
- 传递进来的库和你已有的库版本冲突。
- 传递进来的库你根本不需要,白白增加构建体积。
- 某些传递依赖有已知漏洞,需要排除。
dependencies {
implementation('com.example:library-a:1.0') {
transitive = false // 关闭所有传递依赖
}
implementation('com.example:library-b:2.0') {
exclude group: 'commons-logging', module: 'commons-logging' // 精确排除
}
}
我曾经在微服务项目里遇到过:A 模块依赖了 Log4j 2.x,B 模块通过传递依赖拉进了 Log4j 1.x,运行时报错说类找不到。排查了半天,最后发现是传递依赖串了版本。用 exclude 排除老版本后,问题立刻解决。遇到类似情况,先跑 gradle dependencies 看看依赖树,问题就一目了然。
gradle dependencies --configuration runtimeClasspath 可以打印出完整的运行时依赖树,排查传递性依赖的神器。
3.4 版本冲突解决——谁说了算?
当同一个依赖出现多个版本时,Gradle 默认会选择最新的那个。这听起来合理对吧?但我在实际项目中踩过坑:两个模块分别依赖了不同版本的工具库,新版本破坏了老 API 的兼容性,运行时直接报错。
这时候就需要手动介入了。Gradle 提供了几种手段:
| 策略 | 用法 | 优先级 | 适用场景 |
|---|---|---|---|
| 默认最新 | 自动选择最高版本 | 低 | 无冲突兼容时 |
| force 强制 | 直接在依赖中 force 版本 | 高 | 必须用某版本,覆盖所有传递依赖 |
| 依赖约束 | 使用 constraints 块统一声明 | 中 | 多模块统一管理版本 |
| 版本锁定 | 通过 gradle.lockfile 锁定全量版本 | 最高 | 要求构建完全可复现 |
// force 强制版本
dependencies {
implementation('com.google.guava:guava:30.1-jre') {
force = true
}
}
// 依赖约束(推荐用于多模块)
dependencies {
constraints {
implementation('com.google.guava:guava:30.1-jre') {
because '统一 Guava 版本,避免冲突'
}
}
}
// 版本锁定(启用后生成 gradle.lockfile)
dependencyLocking {
lockAllConfigurations()
}
我个人在大型多模块项目中偏好用 依赖约束 + 版本锁定 的组合。约束层负责声明「我们团队统一用哪些版本」,锁定层确保每次 CI 拉下来的依赖一模一样,不会因为某天中央仓库更新了就突然翻车。
为什么我把版本锁定放在最后?因为它是「确定性构建」的终极手段。你想想看,如果团队 10 个人各自 build 出来的依赖树都不一样,那谈何复现问题?锁定之后,所有人、所有环境都用同一套依赖,构建的确定性就稳稳拿捏了。