26、协程与协程的协程:协程的嵌套与递归、协程的递归遍历、协程的树形结构管理、协程的复杂依赖关系处理
协程嵌套,说白了就是「协程里面开协程」。这听起来简单,但实际用起来坑不少。我最早接触这个场景,是在做一个多级评论列表的加载——每个评论下面还有子评论,子评论下面还有子评论。嗯,这就是典型的树形结构。
你想想看,如果每个节点都开一个协程去加载数据,那这些协程之间是什么关系?是并行还是串行?父协程要不要等子协程?子协程失败了父协程怎么办?这些问题,就是本章要聊的核心。
协程的嵌套:结构化并发的基石
Kotlin 协程的嵌套,不是简单的「在 launch 里再 launch 一个」。它背后有一套完整的结构化并发规则。我习惯把协程嵌套分成两种:父子嵌套和兄弟嵌套。
| 嵌套类型 | 典型写法 | 行为特征 |
|---|---|---|
| 父子嵌套 | coroutineScope { launch { ... } } | 父协程等待所有子协程完成;子协程异常会取消父协程 |
| 兄弟嵌套 | coroutineScope { launch { ... }; launch { ... } } | 兄弟协程并行执行;任一失败,整个 scope 取消 |
这里有个关键点:父子嵌套的取消传播是单向的。父协程取消,子协程一定取消。但子协程取消,父协程不一定取消——除非子协程抛出了未捕获的异常。
核心原则:结构化并发要求每个协程都有明确的「父-子」关系,且生命周期绑定。不要用 GlobalScope 去创建「孤儿协程」。
协程的递归:自己调用自己
递归在协程里怎么玩?说白了就是「协程函数调用自身」。但这里有个陷阱——每次递归调用都会创建一个新的协程上下文。如果递归深度太大,栈会爆掉吗?
我遇到过这样一个场景:需要递归遍历一个深度未知的文件夹结构,每个文件夹都开一个协程去读取子文件。结果递归到第 50 层时,直接 OOM 了。
为什么会这样?因为每次递归都创建了一个新的协程,这些协程的上下文对象堆积在内存里。解决方案其实很简单:用挂起函数代替递归协程。
// ❌ 错误示范:每次递归都创建新协程
suspend fun processFolder(path: String) = withContext(Dispatchers.IO) {
val files = File(path).listFiles() ?: return@withContext
for (file in files) {
if (file.isDirectory) {
launch { processFolder(file.path) } // 递归创建协程
}
}
}
// ✅ 正确做法:用挂起函数递归,不创建新协程
suspend fun processFolderSafe(path: String) = withContext(Dispatchers.IO) {
val files = File(path).listFiles() ?: return@withContext
for (file in files) {
if (file.isDirectory) {
processFolderSafe(file.path) // 直接挂起递归
}
}
}
我的经验:递归协程的深度建议控制在 20 层以内。如果超过这个深度,考虑改用迭代 + 栈结构。我曾经在项目里用 ArrayDeque 模拟递归,效果很好。
协程的递归遍历:树形结构怎么走?
树形结构的遍历,在协程世界里有个经典场景:多级评论的异步加载。每个节点需要从网络或数据库加载数据,而且子节点的加载依赖父节点的结果。
我习惯把这种遍历分成两种模式:
- 深度优先:先加载完一个分支的所有子节点,再加载下一个分支。适合「依赖链」明确的场景。
- 广度优先:先加载所有同层节点,再加载下一层。适合「层级统计」场景。
// 深度优先的递归遍历
suspend fun dfsLoadComments(commentId: String): List<Comment> = withContext(Dispatchers.IO) {
val children = api.loadChildren(commentId) // 挂起加载子评论
val result = mutableListOf<Comment>()
for (child in children) {
result.add(child)
result.addAll(dfsLoadComments(child.id)) // 递归加载孙子评论
}
result
}
// 广度优先的层级遍历
suspend fun bfsLoadComments(rootId: String): List<Comment> = withContext(Dispatchers.IO) {
val queue = ArrayDeque<String>()
queue.add(rootId)
val result = mutableListOf<Comment>()
while (queue.isNotEmpty()) {
val currentId = queue.removeFirst()
val children = api.loadChildren(currentId)
result.addAll(children)
queue.addAll(children.map { it.id })
}
result
}
注意:广度优先遍历如果使用协程并行加载同层节点,要控制并发数。我曾经用 Semaphore 限制同时加载的节点数不超过 5 个,否则网络请求会打满线程池。
协程的树形结构管理
树形结构的管理,核心是「父子协程的生命周期绑定」。每个节点对应一个协程,父节点取消时,所有子节点自动取消。这听起来很美好,但实际项目中往往需要「部分取消」——比如用户只关闭了某个分支,其他分支保持打开。
我常用的方案是:每个节点维护自己的 Job,通过 Job 的 children 属性来管理子协程。
class TreeNode(val id: String, val scope: CoroutineScope) {
private val children = mutableMapOf<String, TreeNode>()
private var nodeJob: Job? = null
fun addChild(childId: String): TreeNode {
val childNode = TreeNode(childId, scope)
children[childId] = childNode
return childNode
}
fun removeChild(childId: String) {
children[childId]?.cancel() // 取消子节点及其所有后代
children.remove(childId)
}
fun cancel() {
nodeJob?.cancel()
children.values.forEach { it.cancel() }
}
fun launchNode(block: suspend CoroutineScope.() -> Unit) {
nodeJob = scope.launch { block() }
}
}
这个结构的好处是:你可以精确控制「取消到哪一层」。比如用户点击「收起评论」,你只需要取消当前节点的子协程,父节点和其他兄弟节点不受影响。
避坑指南:我曾经在树形结构里用 SupervisorJob,结果子协程失败了父协程不取消,导致「僵尸节点」一直占用内存。后来我改成每个节点独立 Job,手动管理取消链,才解决问题。
协程的复杂依赖关系处理
复杂依赖关系,说白了就是「A 等 B,B 等 C,C 又等 A」这种场景。在协程里,这种依赖关系可以用 async/await 或者 Channel 来处理。
我遇到过最复杂的依赖关系,是一个「多级表单校验」场景:
- 字段 A 的校验依赖字段 B 的结果
- 字段 B 的校验依赖字段 C 的结果
- 字段 C 的校验又依赖字段 A 的结果(循环依赖)
这种循环依赖,说白了就是设计有问题。但在遗留系统里,你不得不处理它。我的方案是:用 Channel 做事件驱动,打破循环。
// 用 Channel 处理循环依赖
suspend fun resolveCircularDependency() = coroutineScope {
val channel = Channel<String>(Channel.UNLIMITED)
launch {
val resultA = computeA(channel.receive()) // 等待 B 的结果
channel.send("A done: $resultA")
}
launch {
val resultB = computeB(channel.receive()) // 等待 C 的结果
channel.send("B done: $resultB")
}
launch {
val resultC = computeC(channel.receive()) // 等待 A 的结果
channel.send("C done: $resultC")
}
}
这个方案的核心思路是:把「依赖」变成「事件」。每个节点不直接等待另一个节点,而是通过 Channel 广播自己的结果。谁需要这个结果,谁就去 Channel 里取。
我的建议:复杂依赖关系尽量用「有向无环图」来建模。如果发现循环依赖,优先重构业务逻辑,而不是用技术手段去绕。实在绕不过去,再用 Channel 或 StateFlow 做事件驱动。
知识体系总览
下面这张图,是我对本章知识体系的总结。你可以把它当作一个「思维导图」来用:
这张图把本章的四个核心知识点串起来了。你从中心出发,沿着四条分支往下看,就能理清协程嵌套、递归遍历、树形管理和依赖关系之间的逻辑关系。
最后说一句:协程的嵌套和递归,本质上是对「异步树」的管理。你只要记住「每个协程都有爹,爹管儿子,儿子管孙子」这个朴素道理,大部分问题都能迎刃而解。