第四讲:依赖解析基础
这一讲我们聊聊模块依赖。说白了,就是搞清楚你的代码到底在“靠”谁活着。我在重构SystemUI的时候,光是梳理依赖关系就花了两周——你想想看,一个状态栏模块,背后牵扯了几十个库,稍不留神就循环依赖,编译直接挂掉。所以这一讲的内容,是后面所有案例的根基。
1. 什么是模块依赖
模块依赖,就是一个模块(比如 StatusBarModule)在代码或资源上引用了另一个模块(比如 CommonUIUtils)。没有它,你就没办法调用那个模块里的类、方法或者资源。
在 Android 项目里,依赖通常写在 build.gradle 里。举个例子:
// SystemUI 主模块的 build.gradle
dependencies {
implementation project(':statusbar-module')
implementation project(':navigation-module')
compileOnly project(':plugin-api')
}
每条声明都告诉 Gradle:「我需要这个模块,帮我把它拉进来。」我个人习惯把依赖分三类:模块内部依赖(同一个项目里的子模块)、外部库依赖(Maven 上的开源库)、系统框架依赖(Android Framework 提供的 API)。
核心要点:模块依赖的本质是 代码与资源的引用关系。在 SystemUI 这种大型模块中,依赖关系往往呈现网状,而不是线性的。
2. 编译时依赖 vs 运行时依赖
很多新手搞不清这两个概念。我刚开始做 Android 开发时也栽过跟头——明明编译通过了,一跑起来就 ClassNotFoundException,排查了半天才发现是依赖声明用错了。
编译时依赖:编译器在生成 .class 文件时需要它。但打包成 APK 时,这个依赖 不一定 会打进去。典型场景是用 compileOnly 声明的依赖,比如 SystemUI 里的 plugin-api:编译期需要接口定义,但运行时会由插件框架动态提供。
运行时依赖:APK 跑起来之后,JVM 或 ART 需要加载这个类的字节码。如果用 implementation 或 api 声明,依赖会被打包进 APK。还有一种情况是动态加载——依赖不在 APK 里,而是运行期从插件包或网络下加载。
看个表格,对比更清楚:
| 对比维度 | 编译时依赖 | 运行时依赖 |
|---|---|---|
| 声明方式 | compileOnly |
implementation / api / runtimeOnly |
| 是否打入APK | ❌ 不打入 | ✅ 打入(或动态加载) |
| 典型场景 | 插件接口、注解处理器 | 业务逻辑库、UI组件库 |
| 缺失后果 | 编译报错 | 运行时崩溃(如ClassNotFoundException) |
在 SystemUI 里,我记得有一次排查一个线上崩溃,最后发现是某个功能模块用了 compileOnly 声明了一个网络库,但插件框架没在运行时提供这个库,导致调用时直接崩溃。这个坑很隐蔽,因为编译和打包都正常,只有跑到那行代码才会炸。
避坑指南:千万不要把「编译能过」当成「没问题」。检查依赖声明时,多问自己一句:这个类在运行时真的存在吗?
再补充一个我自己的经验:对于插件化架构的项目,编译时依赖和运行时依赖的模块往往是不同的。比如 SystemUI 的插件机制:编译期依赖插件接口模块,运行时依赖的是插件实现模块(由插件加载器动态安装)。这两者要严格区分,否则就会出现上面说的那种崩溃。
3. 依赖传递性
依赖传递性,翻译成人话就是:A 依赖 B,B 依赖 C,那么 A 也间接依赖了 C。你想想看,如果你在 SystemUI 里依赖了一个工具库,那个工具库又依赖了 OkHttp,那你项目里就有了 OkHttp——哪怕你没直接声明它。
传递性在 Gradle 里默认是开启的。看这个例子:
// 模块A 的 build.gradle
dependencies {
implementation project(':moduleB')
}
// 模块B 的 build.gradle
dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
}
// 最终模块A 的依赖树里会出现 okhttp
// 这就是传递依赖
传递依赖的好处是省事——你不需要手动声明所有间接依赖。但坏处也很明显:版本冲突。我曾经踩过一个坑:SystemUI 里两个子模块各自依赖了不同版本的 Guava,结果运行时因为方法签名不一致导致崩溃。排查了整整一天,最后发现是传递依赖带来的版本冲突。
怎么控制传递性?Gradle 提供了两种方式:
- 关闭传递性:
implementation('com.example:lib:1.0') { transitive = false } - 强制指定版本:
implementation('com.example:lib:1.0') { force = true }
我个人建议,在大型项目中要定期使用 gradle dependencies 命令查看依赖树,手动清理那些「意外引入」的传递依赖。尤其是在 SystemUI 这种多模块项目中,传递依赖的复杂度会随着模块数量指数级增长。
小技巧:在 Terminal 执行 ./gradlew :app:dependencies --configuration releaseRuntimeClasspath,可以输出完整的运行时依赖树。看到那些 -> 符号了吗?那就是版本冲突被自动解决的地方——但自动解决不一定是你想要的。
知识体系总览
下面这张图把三个概念串在了一起。你可以看到 SystemUI 主模块如何依赖不同层次的模块,以及编译时、运行时、传递依赖分别落在哪些环节。
三个核心认知
讲到这里,我想总结三个我经过多次踩坑才沉淀下来的认知:
- 依赖不是越多越好。 每加一个依赖,就多一分版本冲突和包体积膨胀的风险。在 SystemUI 里,我们曾经因为一个冗余依赖导致 APK 增大 2MB —— 对于系统应用来说,这是不可接受的。
- 编译时依赖和运行时依赖要分清楚。 用错了轻则编译警告,重则线上崩溃。尤其是插件化项目,这个区分直接决定了架构的稳定性。
- 传递依赖需要主动管理。 不要相信 Gradle 的自动版本解决策略,它只是「选了一个版本」,不一定是你想要的那个。定期 review 依赖树,把不确定的传递依赖显式声明出来。
我的习惯做法:在每个模块的 build.gradle 顶部写一段注释,标明「本模块对外暴露了哪些传递依赖」以及「已知的版本冲突风险」。这样团队其他成员接手时,不会一脸懵。
好,这一讲就是这些。依赖解析是后续所有案例的前提——你只有把「谁依赖谁、什么时候依赖、依赖怎么传递」搞清楚了,才能理解 SystemUI 里那些复杂的模块化设计到底在解决什么问题。