第四讲:依赖解析基础

这一讲我们聊聊模块依赖。说白了,就是搞清楚你的代码到底在“靠”谁活着。我在重构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 主模块 编译时依赖模块 (plugin-api / 接口模块) 运行时依赖模块 (implementation / 业务模块) 传递依赖·公共库 (CommonUIUtils / 工具类) 传递依赖·Framework (Android Framework API) compileOnly implementation transitive transitive 📌 三种依赖的本质区别 • 编译时依赖:编译期必须存在,打包时可选 —— 典型:compileOnly • 运行时依赖:运行期必须存在,会打入 APK 或动态加载 —— 典型:implementation / api • 传递依赖:通过直接依赖间接引入,默认开启 transitive,需关注版本冲突

三个核心认知

讲到这里,我想总结三个我经过多次踩坑才沉淀下来的认知:

  1. 依赖不是越多越好。 每加一个依赖,就多一分版本冲突和包体积膨胀的风险。在 SystemUI 里,我们曾经因为一个冗余依赖导致 APK 增大 2MB —— 对于系统应用来说,这是不可接受的。
  2. 编译时依赖和运行时依赖要分清楚。 用错了轻则编译警告,重则线上崩溃。尤其是插件化项目,这个区分直接决定了架构的稳定性。
  3. 传递依赖需要主动管理。 不要相信 Gradle 的自动版本解决策略,它只是「选了一个版本」,不一定是你想要的那个。定期 review 依赖树,把不确定的传递依赖显式声明出来。

我的习惯做法:在每个模块的 build.gradle 顶部写一段注释,标明「本模块对外暴露了哪些传递依赖」以及「已知的版本冲突风险」。这样团队其他成员接手时,不会一脸懵。

好,这一讲就是这些。依赖解析是后续所有案例的前提——你只有把「谁依赖谁、什么时候依赖、依赖怎么传递」搞清楚了,才能理解 SystemUI 里那些复杂的模块化设计到底在解决什么问题。