三、组件依赖图与循环依赖的检测机制
组件化项目做到中期,依赖关系就会像蜘蛛网一样缠在一起。我记得有一次,项目编译越来越慢,最后直接卡死在Gradle的依赖解析阶段。查了半天,发现是个循环依赖——A依赖B,B依赖C,C又依赖A。嗯,这个坑我踩过好几次。
说白了,组件依赖图就是一张有向图。每个组件是一个节点,每个依赖关系是一条有向边。你想想看,如果这张图里出现了环路,依赖注入容器会在运行时反复尝试创建实例,最终抛出一个UnsatisfiedDependencyException,或者干脆栈溢出。我项目里就有一次线上崩溃,就是因为两个仓库模块互相引用,跑起来直接OOM。
为什么循环依赖这么危险?
- 编译期卡死:Gradle解析依赖时进入死循环,构建时间爆炸。
- 运行时崩溃:Dagger/Hilt的注入图检测到环,直接抛异常。
- 代码逻辑混乱:两个组件互相依赖,改一个就可能影响另一个,改起来像解毛线球。
我曾经…… 接手过一个老项目,里面有个模块叫base,它居然依赖了network模块,而network又依赖base。当时我一运行测试用例,整个IDE卡了五分钟。后来我硬着头皮画出了完整的依赖图,才发现环不止一个,有三个环套在一起。整整重构了一周才拆干净。
检测循环依赖的两种核心方法
我个人习惯在CI阶段就做依赖图检查,而不是等到运行时才报错。常用的检测算法有两种,说白了都是图论里的东西:
1. 拓扑排序
对有向无环图(DAG)进行排序,如果排不出所有节点,就说明有环。这是最直观的方法。你可以用Kahn算法或者DFS深度优先搜索。
这里给一个Kahn算法的伪代码实现,我在自己写的依赖检查工具里就用它:
fun detectCycle(graph: Map<String, List<String>>): List<String>? {
val inDegree = mutableMapOf<String, Int>()
graph.keys.forEach { inDegree[it] = 0 }
graph.forEach { (_, neighbors) ->
neighbors.forEach { inDegree[it] = inDegree.getOrDefault(it, 0) + 1 }
}
val queue = ArrayDeque<String>()
inDegree.filter { it.value == 0 }.keys.forEach { queue.add(it) }
val sorted = mutableListOf<String>()
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
sorted.add(node)
graph[node]?.forEach { neighbor ->
inDegree[neighbor] = inDegree[neighbor]!! - 1
if (inDegree[neighbor] == 0) queue.add(neighbor)
}
}
return if (sorted.size != graph.size) {
// 有环,剩下的节点就是环的一部分
graph.keys.filter { it !in sorted }
} else null
}
小技巧:在Gradle的
buildSrc里写一个插件,每次提交前自动执行这个检测,能省很多事。我一般还会输出一个SVG图形给开发者看,哪几个节点构成了环。
2. 基于依赖注入框架的编译期检查
Dagger和Hilt在编译时会生成代码,如果检测到循环依赖,会直接给出编译错误。比如Dagger会打印类似DependencyCycleException的信息。不过这个检测只限于同一个组件树内部,跨组件的循环依赖它不一定能发现。我建议还是配合图分析工具更稳妥。
依赖图检测方案对比
| 方案 | 检测时机 | 优点 | 缺点 |
|---|---|---|---|
| Kahn拓扑排序(自定义脚本) | 构建前/CI | 能覆盖所有组件间依赖,可输出环路径 | 需要维护依赖关系的配置文件 |
| Dagger/Hilt编译期 | 编译时 | 零配置,框架原生支持 | 只检测单个Component内部的环,跨模块可能漏掉 |
| Gradle依赖分析插件(如gradle-dependencies-check) | 构建时 | 与构建系统集成,自动分析所有project依赖 | 对自定义实现(如service locator)不敏感 |
实战建议:我通常会用第一种方法作为第一道防线,写一个Gradle任务,读取每个module的
build.gradle里的依赖声明,生成有向图然后做拓扑排序。如果发现环,就中断构建并打印环中的组件列表。这样可以在开发早期就把问题扼杀。
组件依赖图与环检测流程图
下面这张图展示了从组件依赖列表到环检测的整体流程。你可以用它来理解我们刚才讲的内容。
避坑指南
- 不要依赖框架的运行时检测作为唯一防线。Dagger虽然能在编译期发现环,但如果你用了
Lazy或Provider来绕开环,实际上环还在,只是被推迟到运行时了,那时候崩溃更难排查。 - 我个人习惯在每个模块的
build.gradle里用dependencies块显式声明所有依赖,不要偷懒用api project(':some-module')滥传依赖——这其实是在给你的依赖图埋雷。 - 如果发现环,不要试图用
@Inject或@Provides的懒加载来糊弄过去。环的存在说明架构有问题,应该拆解组件或者引入中间接口层来打破环。
一个实用的好习惯:在项目根目录放一个
dep-graph.txt,用人类可读的格式记录每个模块的依赖关系。然后写个简单的脚本,每天自动运行一次拓扑排序,把结果邮件发给团队。这样谁不小心引入了环,第二天一早就能发现。