3. 依赖管理基础:仓库配置、依赖声明、传递性依赖、版本冲突解决

依赖管理,说白了就是 Gradle 帮你把项目需要的第三方库「拿过来、用起来、管清楚」。很多人一开始觉得这步很简单——配个仓库,写个 implementation 就完事了。但我在实际项目中见过不少因为依赖问题引发的「血案」:编译莫名其妙失败、运行时 ClassNotFoundException、构建时间越来越长……其实根子都在依赖管理上没理清。

这一节我会把依赖管理的四个核心环节拆开讲:仓库去哪配、依赖怎么声明、传递性依赖怎么控制、版本冲突怎么解决。嗯,把这四个点吃透了,你的构建文件就能写得干净又稳当。

依赖管理核心流程 仓库配置 Maven Central · 私有仓库 依赖声明 implementation · api 传递性依赖 自动传递 · 依赖排除 版本冲突解决 最新版本 · 强制约束 依赖解析 依赖树 · 版本锁定 依赖管理的本质:从仓库中获取依赖,经过声明、传递性处理、版本冲突解决, 最终形成一份确定的依赖树,供编译和运行时使用。

3.1 仓库配置——依赖从哪来

Gradle 的依赖仓库,你可以理解成「代码的快递」。常用的有这几个:

  • mavenCentral() —— 官方中央仓库,最稳定,我建议优先用它。
  • google() —— Android 开发必备,存放 AndroidX 和 Google 相关库。
  • mavenLocal() —— 本机构建的产物,适合本地调试。
  • 自定义私有仓库 —— 公司内部库或自研组件,用 maven { url '...' } 指定。

我曾经在一个项目里看到有人把 jcenter() 还留着,结果某天 CI 构建突然报 404。jcenter 已经进入只读状态了,新版本不会同步过去。嗯,早点迁移到 mavenCentral 能省不少麻烦。

小技巧: 仓库声明的顺序就是 Gradle 查找的顺序。如果你有私有仓库,建议放在前面,可以减少网络请求。
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 本身。这差距在大型项目里非常明显。

注意: compileOnly 声明的依赖不会出现在运行时 classpath 中。如果你拿去运行,会直接报 ClassNotFoundException。

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 拉下来的依赖一模一样,不会因为某天中央仓库更新了就突然翻车。

避坑指南: 我曾经在一个 30+ 模块的项目里,直接用 force 压住了所有冲突,结果有一个模块其实需要更高版本的特性,force 之后反而把功能搞炸了。所以 force 虽好用,但务必先分析依赖树,确认没有副作用再用。

为什么我把版本锁定放在最后?因为它是「确定性构建」的终极手段。你想想看,如果团队 10 个人各自 build 出来的依赖树都不一样,那谈何复现问题?锁定之后,所有人、所有环境都用同一套依赖,构建的确定性就稳稳拿捏了。