
前言
炼气四境已过。你已能自如启动协程,也能精准取消它们;你内视过状态机的 label 跳转,也看穿了 CPS 续体的接力真相。你自认为已经掌握了协程。
但一个诡异的现象,可能已经在你心头萦绕多时:
为什么我在
ViewModel里只调用了viewModelScope.launch,当ViewModel被清除时,里面所有还在运行的网络请求、数据库操作、文件读写,全都自动停止了?我明明没有手动取消任何一个!
这不是魔法。这是 Kotlin 协程设计中最核心、最精妙、也最容易被误解的机制------结构化并发。
不理解结构化并发,你就永远无法解释为什么 coroutineScope 和 supervisorScope 行为不同,为什么有时候一个子协程崩溃会导致整个 Scope 完蛋,而有时候又不会。你写的代码可能"看起来能跑",但迟早会在生产环境给你上一课。
今天,我们将正式踏入筑基境。这一讲,你要领悟的不是某一个 API,而是一套设计哲学------它像万有引力一样,无声却不可违抗地约束着每一个协程的生与灭。
操千曲 而后晓声,观千剑 而后识器。虐它千百遍 方能通晓其真意。
什么是结构化并发?
在 Kotlin 协程的官方文档中,结构化并发被定义为:
结构化并发 是一组语言特性与编程范式的组合,它确保:
协程的生命周期被严格限制在启动它的作用域之内。父协程必须等待所有子协程完成后才能完成自身;取消信号从父向子单向传播;异常从子向父单向传播。
如果你觉得这个定义太抽象,我们用一张图来建立直觉。
这张图揭示了结构化并发的第一定律 :取消向下传播。你只需要取消根节点,整棵树上的所有协程都会自动被取消。没有漏网之鱼。
为什么需要结构化并发?
在传统的多线程编程中,"忘记关掉某个线程"是内存泄漏和资源泄漏的头号元凶。
在传统线程模型里,你需要自己维护每一个线程的引用,在合适的时机调用 interrupt() 或检查 volatile boolean 标志。一旦漏掉一个,那个线程就会像幽灵一样在后台游荡,直到应用进程被杀。
结构化并发彻底解决了这个问题:协程的生命周期被作用域严格约束 。Scope 就像一个"括号",所有在其中启动的协程,都必须在 Scope 结束前完成(或被取消)。编译器虽不强制,但运行时会严格保证这一点。
父子关系的建立:Job 树是如何长成的?
每一次你调用 launch 或 async,都不仅仅是"启动一个协程"那么简单。你在做的是:以当前协程的 Job 为父节点,创建一个新的子 Job,并将它挂到 Job 树上。
这个"挂到树上"的动作,是结构化并发所有魔法的起点。一旦父子关系确立,三条铁律即刻生效:
| 铁律 | 方向 | 含义 |
|---|---|---|
| 完成等待 | 子 → 父 | 父协程的 join() 或 coroutineScope 会等待所有子协程完成。 |
| 取消传播 | 父 → 子 | 父 Job 被取消时,所有子 Job 自动被取消。 |
| 异常传播 | 子 → 父 | 子协程未捕获的异常会向上传递,导致父 Job 被取消(除非使用 SupervisorJob)。 |
实战演示:取消传播的威力
让我们用一个可运行的示例,亲眼见证取消传播的"多米诺骨牌"效应。
kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
println("🧘 主协程:启动父协程")
val parentJob = launch {
println("🌳 父协程:启动子协程 A")
launch {
repeat(5) { i ->
delay(500)
println("🚀 子协程 A:心跳 $i")
}
}
println("🌳 父协程:启动子协程 B")
launch {
repeat(5) { i ->
delay(600)
println("🚀 子协程 B:心跳 $i")
}
}
delay(1500)
println("🌳 父协程:自身工作完成,等待子协程...")
}
delay(2000)
println("🧘 主协程:取消父协程!")
parentJob.cancelAndJoin()
println("🧘 主协程:父协程已取消,所有子协程也都停止了。")
}
运行结果中,你会看到子协程 A 和 B 各自打印了几次心跳,然后在 parentJob.cancel() 调用后同时停止。你只取消了一个协程,却终结了三个。
coroutineScope:结构化并发的"围栏"
除了 launch 和 async,Kotlin 协程还提供了一个专门用于体现结构化并发的构建器:coroutineScope。
coroutineScope是一个挂起函数,它创建一个临时的协程作用域,并挂起当前协程 ,直到作用域内启动的所有子协程全部完成。如果任何子协程失败,coroutineScope会抛出异常并取消所有其他子协程。
它就像一道"围栏"------围栏内的所有协程都完成之前,围栏外的代码不会执行。
kotlin
suspend fun loadMultipleData() = coroutineScope {
println("🏁 进入 coroutineScope")
val data1 = async { fetchFromNetwork("url1") }
val data2 = async { fetchFromNetwork("url2") }
val data3 = async { fetchFromNetwork("url3") }
// 等待所有 async 完成,组合结果
val combined = "${data1.await()} + ${data2.await()} + ${data3.await()}"
println("✅ 所有数据加载完成:$combined")
// coroutineScope 结束后,才会返回 combined
combined
}
suspend fun fetchFromNetwork(url: String): String {
delay(Random.nextLong(500, 1500))
return "Data from $url"
}
coroutineScope 与 launch 的关键区别:
launch返回一个Job,不阻塞当前协程,是"发射后不管"。coroutineScope是一个suspend函数,会挂起当前协程直到内部所有子协程完成,是"等待所有人都到齐再走"。
Android 实战:安全的多网络请求并发
假设你有一个商品详情页,需要同时请求商品信息 、用户评价 、推荐列表三个接口。你希望它们并发执行以缩短总耗时,但如果任何一个失败,整个页面应该显示错误状态。
这正是 coroutineScope 的完美应用场景。
kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
class ProductViewModel(
private val productRepo: ProductRepository,
private val reviewRepo: ReviewRepository,
private val recommendRepo: RecommendRepository
) : ViewModel() {
sealed class UiState {
object Loading : UiState()
data class Success(
val product: Product,
val reviews: List<Review>,
val recommends: List<Product>
) : UiState()
data class Error(val message: String) : UiState()
}
var uiState by mutableStateOf<UiState>(UiState.Loading)
private set
fun loadProductDetails(productId: String) {
viewModelScope.launch {
uiState = UiState.Loading
try {
// coroutineScope 保证三个请求并发执行
// 任何一个失败都会抛出异常,进入 catch 块
val result = coroutineScope {
val productDeferred = async { productRepo.getProduct(productId) }
val reviewsDeferred = async { reviewRepo.getReviews(productId) }
val recommendsDeferred = async { recommendRepo.getRecommends(productId) }
// 同时等待三个结果
UiState.Success(
product = productDeferred.await(),
reviews = reviewsDeferred.await(),
recommends = recommendsDeferred.await()
)
}
uiState = result
} catch (e: Exception) {
uiState = UiState.Error("加载失败:${e.message}")
// 注意:coroutineScope 内部任何协程失败,
// 其他正在执行的协程会被自动取消,无需手动清理
}
}
}
}
这个示例展示了结构化并发的优雅之处:
- 不需要手动维护三个请求的
Job引用。 - 任何一个请求失败,其他请求自动被取消,避免浪费资源。
- 代码结构清晰,完全符合直觉。
异常传播的秘密即将揭晓
在本讲的示例中,你可能注意到了一个细节:
我们在
coroutineScope内部用async启动了三个请求。如果其中一个await()抛出异常,coroutineScope会立刻抛出该异常,并且其他两个还在执行的请求会被自动取消。
这种行为是 coroutineScope 的设计使然------它遵循一个失败,全体遭殃的原则。
但在某些场景下,你希望子协程之间相互隔离 :一个子协程的失败不应该影响其他兄弟协程。比如,商品详情的三个接口中,推荐列表失败了,你仍然希望显示商品信息和评价,只是推荐位留空。
这时,你就需要 supervisorScope。
这正是下一讲 【筑基境·中阶】 的核心内容。我们将深入:
Job与SupervisorJob的源码级区别。supervisorScope的使用场景与最佳实践。- 异常处理的三层拦截机制:
try-catch、CoroutineExceptionHandler、supervisorScope。
常见错误与避坑指南
错误 1:在 coroutineScope 外部捕获异常却以为能保护内部
kotlin
// ❌ 错误理解:以为 try-catch 能捕获内部协程的异常
try {
coroutineScope {
launch {
throw IOException("网络错误") // 这个异常会传播到 coroutineScope
}
}
} catch (e: IOException) {
// ✅ 实际上能捕获,因为 coroutineScope 会重新抛出内部异常
println("捕获到了")
}
正确理解 :coroutineScope 会将内部子协程的未捕获异常重新抛出 ,所以外层 try-catch 是有效的。这一点与 launch 不同------launch 不会向外抛出异常,而是交给 CoroutineExceptionHandler。
错误 2:忘记 coroutineScope 会等待所有子协程
kotlin
// ❌ 误解:以为 coroutineScope 只是创建一个作用域,不等待
suspend fun loadData() {
coroutineScope {
launch {
delay(10000) // 这个协程会阻塞 loadData 返回
}
}
println("这行字要等 10 秒才能打印") // 实际上需要等待
}
coroutineScope 的本质就是挂起并等待 内部所有协程完成。如果你不需要等待,应该使用 CoroutineScope(Job()).launch 或直接在当前作用域 launch。
错误 3:在 ViewModel 中用 GlobalScope
kotlin
// ❌ 结构化并发的反面教材
class MyViewModel : ViewModel() {
fun loadData() {
GlobalScope.launch {
// 这个协程不受 ViewModel 生命周期约束
// ViewModel 清除后仍在运行,泄漏!
}
}
}
正确做法 :始终使用 viewModelScope 或 lifecycleScope,它们是结构化并发在 Android 中的具体实现。
最佳实践
-
默认使用
viewModelScope和lifecycleScope:它们已经为你配置好了 Job 树和生命周期绑定。 -
并发任务用
coroutineScope+async:需要同时执行多个任务并等待全部结果时,这是标准模式。 -
在挂起函数内部需要启动新协程时,用
coroutineScope包裹:这样可以保证函数返回前,所有启动的子协程都已经完成。kotlinsuspend fun doWork() = coroutineScope { launch { /* 子任务1 */ } launch { /* 子任务2 */ } // 自动等待所有子任务完成 } -
永远不要直接使用
GlobalScope:除非你在写一个应用级全局任务(如日志上报),并且有明确的清理机制。 -
理解
Job树的取消传播方向:父 → 子。这是你调试"为什么这个协程被取消了"的第一线索。
总结与下回预告
恭喜,你已经领悟了结构化并发的根本大法。
本讲核心收获:
- 结构化并发确保协程的生命周期被严格限制在 Scope 之内,消灭了"野协程"。
- 取消信号从父向子传播,你只需要取消根 Job,整棵树都会被自动清理。
coroutineScope是结构化并发的核心构建器,它挂起当前协程并等待所有子协程完成。Job树的三条铁律:取消向下、完成等待向上、异常向上。
在下一讲 【筑基境·中阶】 中,我们将深入 CoroutineContext 的内部构造,拆解 Job、Dispatcher、CoroutineName 等元素是如何组合成协程的"身份证"的。届时你会明白:
为什么
withContext(Dispatchers.IO)能切换线程却不出 Bug?CoroutineContext的+运算符到底做了什么?
【当前境界修为面板】
- 当前境界 :
[筑基境 · 初阶] - 下一突破 :
[筑基境 · 中阶](需领悟:CoroutineContext的组成结构、Job与Dispatcher的本质、withContext的线程切换原理) - 修炼进度 :
[████████████░░░░░░░░] 55% - 本讲获得法器 :
结构化并发根本大法、Job 树内视术、coroutineScope 围栏结界
【本讲思考题】
1、表象题 :以下代码中,println("Done") 会在什么时候执行?
kotlin
suspend fun test() = coroutineScope {
launch {
delay(5000)
}
}
suspend fun main() {
test()
println("Done")
}
2、场景题:你正在开发一个文件上传功能,需要同时上传三张图片。如果任意一张上传失败,你希望取消其他正在上传的图片。应该使用什么构建器?写出核心代码结构。
3、原理题 :在结构化并发的 Job 树中,如果一个子协程因为 CancellationException 而结束,这个异常会向上传播导致父协程被取消吗?为什么?结合 CancellationException 的特殊性解释。
道友,筑基境的根基已经打下。下一讲,我们将解剖 CoroutineContext,看透协程的"命格"。筑基境·中阶见。
欢迎一键四连 (
关注+点赞+收藏+评论)