4. Gradle依赖管理

依赖管理这东西,说白了就是帮你的项目"要啥有啥",但又不能"要啥来啥"乱成一锅粥。我个人觉得,仓库配置和依赖声明是基础,传递依赖和冲突解决才是真正考验人的地方。

我记得几年前接手一个老项目,build.gradle里密密麻麻全是依赖,版本号写得到处都是。一执行build,控制台就飘红,全是Conflict。那会儿我就意识到,版本锁定这步,真不能偷懒。

核心观点:依赖管理的本质是声明 - 解析 - 缓存 - 锁定四个阶段。每个阶段出问题,构建就崩给你看。

4.1 仓库配置

仓库,就是依赖的"货源"。Gradle支持多种仓库类型。我个人习惯在项目开始前,先把repositories块统一配置好。

repositories {
    mavenCentral()     // 中央仓库,最常用
    google()           // Android专用,Google的Maven仓库
    jcenter()          // 已弃用,但老项目里还能见到
    maven { url "https://jitpack.io" }   // 第三方仓库,比如GitHub上的库
    flatDir { dirs 'libs' }              // 本地libs文件夹
}

这里有个细节:仓库顺序会影响依赖解析速度。我一般把mavenCentral()放前面,因为它最稳定。为什么?你想想看,Gradle是按顺序去每个仓库找依赖的,找到了就停。如果有个慢仓库放前面,每次构建都得等半天。

个人经验:我曾在项目里把mavenLocal()放第一位,结果团队里某个人本地缓存了个旧版本的库,整个构建就全歪了。后来我改成只保留mavenCentral()和google(),把本地仓库去掉,才消停。

4.2 依赖声明

依赖声明,就是告诉Gradle:"我需要这个,那个,还有那个"。Gradle里常用的配置项有这么几种:

配置项 作用 传递性 使用场景
implementation 编译时和运行时都可用 不对外暴露 大部分内部依赖,推荐使用
api 编译时和运行时都可用,且对外暴露 对外暴露 Library模块的公开接口依赖
compileOnly 仅编译时可用 不传递 编译期注解处理器、Lombok等
runtimeOnly 仅运行时可用 不传递 JDBC驱动等
testImplementation 仅测试编译和运行时 不传递 JUnit、Mockito等测试框架

这里我重点想聊的是implementation和api的区别。说白了,implementation就是"我用,你别管";api就是"我用,你也用"。我在项目中遇到过有人把所有依赖都写成api,结果模块A改了某个依赖版本,模块B、C全部得重新编译,构建时间直接翻倍。

dependencies {
    // 推荐:用implementation把依赖藏起来
    implementation 'com.squareup.okhttp3:okhttp:4.9.3'

    // 只有对外暴露接口的模块才用api
    api 'com.google.code.gson:gson:2.8.8'

    // 编译期用的,比如Lombok
    compileOnly 'org.projectlombok:lombok:1.18.24'

    // 测试专用
    testImplementation 'junit:junit:4.13.2'
}
避坑指南:我曾经把testImplementation写成了implementation,结果生产环境打出来的jar包里混进了JUnit的类,部署上去直接报错。从那以后,我每次写完dependencies都要跑一遍gradle dependencies确认范围。

4.3 传递依赖与冲突解决

传递依赖,就是你引入的库A,它自己又拉了库B和库C。Gradle默认会把这些"孙子"依赖也带进来。这本来是好事,但问题在于:不同库可能拉了同一个库的不同版本。

举个例子:你引入了com.google.guava:guava:30.0,而引入的com.squareup.okhttp3:okhttp:4.9.3又依赖com.google.guava:guava:28.0。这时候版本冲突就来了。

Gradle处理冲突的规则是:默认采用最高版本。但有时候最高版本不一定兼容。这时候就需要我们手动介入了。

configurations.all {
    resolutionStrategy {
        // 强制使用某个版本
        force 'com.google.guava:guava:30.1-android'

        // 如果出现冲突,优先选择这个模块的依赖
        preferProjectModules()
    }

    // 排除某个传递依赖
    exclude group: 'com.google.code.findbugs', module: 'jsr305'
}

还有一个常用的方法,就是直接在被依赖的声明里排除:

implementation('com.squareup.okhttp3:okhttp:4.9.3') {
    // 排除它内部的某个传递依赖
    exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib'
}

我在项目中遇到过最头疼的一次:A库依赖了B库1.0版,C库依赖了B库2.0版,而B库1.0和2.0的API完全不兼容。最后我只能用force锁死版本,然后手动测试所有功能是否正常。嗯,这里要注意,force虽然好用,但一定要写测试用例,否则可能偷跑出去一个不兼容的API调用。

4.4 版本锁定

版本锁定,就是把你当前构建时实际用到的所有依赖版本,记录到一个文件里。这样下次构建,不管谁执行,都能保证用完全一样的版本。

Gradle官方推荐的做法是配合Gradle构建缓存和依赖锁定插件。不过我这里说个更常用的方法:gradle.lockfile。

// 在settings.gradle里开启依赖锁定
dependencyResolutionManagement {
    repositories.mavenCentral()
    lockMode.set(LockMode.STRICT)  // 严格模式,必须存在lockfile才能构建
}

然后执行:

./gradlew dependencies --write-locks

这会生成一个gradle.lockfile文件,记录了所有依赖的精确版本。这个文件应该提交到版本管理里,团队里每个人都用同一份。

我个人的习惯是:每次添加或更新依赖后,都重新生成一下lockfile。而且,我会在CI流水线里加一个步骤,检查lockfile是否有变更。如果没更新lockfile就提交,直接构建失败。

一句话总结:版本锁定是给依赖管理"上保险",防止"今天能跑,明天不能跑"的尴尬。

4.5 知识结构图

下面这张图,把Gradle依赖管理的核心流程串起来了。从仓库配置开始,到依赖声明、传递解析、冲突处理,最后锁定版本,环环相扣。

Gradle依赖管理核心流程 项目 / build.gradle 仓库配置 依赖声明 传递依赖解析 冲突检测 版本锁定 构建成功 说明:仓库配置决定"去哪找" 依赖声明决定"要什么" 版本锁定决定"用哪个版本"
重要提醒:上面这张图里的每一个环节,在实际项目中都可能出问题。比如仓库配置错了,依赖根本拉不下来;版本锁定了但没提交到git,别人一拉代码就炸。我曾经吃过这个亏,所以才画了这张图,每次做依赖管理时对照着检查一遍。