CoroutineScope 与 SupervisorJob:Job 层次结构、异常传播、SupervisorJob 在 ViewModel 中的使用
在 ViewModel 中使用协程时,绝大多数开发者直接使用 viewModelScope 而忽略了其底层的 Job 类型。实际上 viewModelScope 默认使用 SupervisorJob,这种 Job 的异常传播行为与普通 Job 有本质区别。理解 Job 层次结构与异常传播规则,是安全取消、避免协程泄漏和意外崩溃的前提。
1. Job 层次结构:从根到叶
每一个 CoroutineScope 都关联一个 Job(或 SupervisorJob)。当在 scope 内启动子协程时,子 Job 会以当前 Job 为父节点挂到树中。下图展示了 ViewModel 中典型的 Job 树结构:
2. SupervisorJob vs 普通 Job:异常传播对比
| 特性 | 普通 Job | SupervisorJob |
|---|---|---|
| 子协程抛出异常 | 异常沿 Job 树向上传播,取消父 Job 及所有兄弟 | 异常仅影响该子协程自身,父 Job 和兄弟不受影响 |
| 取消传播 | 父 Job 取消会导致所有子 Job 递归取消 | 父 Job 取消同样导致所有子 Job 被取消(符合结构化并发) |
| 适用场景 | 需要原子性失败的任务组(如并行下载一个大文件的分片) | 独立任务并行(如多个网络请求,一个失败不应影响其他) |
| 默认 scope 行为 | CoroutineScope(Job()) |
CoroutineScope(SupervisorJob()) |
3. ViewModelScope 为什么使用 SupervisorJob?
Android 官方在 viewModelScope 的实现中强制使用 SupervisorJob,源码如下(基于 lifecycle-viewmodel-ktx 库):
public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(
SupervisorJob() + Dispatchers.Main.immediate
)
)
}
可以看到,Job 类型为 SupervisorJob(),其设计意图:
- 避免单个协程崩溃导致 ViewModel 所有协程被取消——比如用户快速切换页面时后台网络请求超时异常,不应取消正在进行的数据库写入。
- 符合 UI 层独立性原则——每个协程通常负责一个独立用例(API 请求、缓存、消息推送),一个失败不应牵连其他。
- 确保 ViewModel 在清理时(onCleared)能统一取消所有协程——父 SupervisorJob 被取消,所有子 Job 也会被递归取消,保证无泄漏。
4. 异常传播的具体规则
在 SupervisorJob 下,异常传播遵循以下规则:
- launch 块中的异常:直接抛给全局异常处理器(CoroutineExceptionHandler),但不会取消父 Job 或兄弟协程。
- async 块中的异常:不会立即传播,仅在调用
.await()时抛出 CancellationException 包装原始异常。如果异常未被捕获,仍然会向上传播(但因 SupervisorJob,不会取消父 Job)。 - 使用 SupervisorJob + CoroutineExceptionHandler:可以捕获所有 launch 协程的未处理异常。
5. 代码示例:在 ViewModel 中显式控制 Job 层次
class MyViewModel : ViewModel() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun loadData() {
// 子协程 A:网络请求
scope.launch {
val result = repository.fetchData()
// 更新 UI
}
// 子协程 B:本地缓存写入
scope.launch {
repository.cacheData()
}
// 子协程 C:独立的后台任务
scope.launch {
doAnalytics()
}
}
override fun onCleared() {
super.onCleared()
scope.cancel() // 取消所有协程
}
}
6. 调试方法:观察 Job 树状态
利用 Android Studio 的 “Coroutines Debugger” 或运行时打印 Job 树结构:
fun logJobTree(job: Job, indent: String = "") {
Log.d("JobTree", "$indent# ${job.key}")
job.children.forEach { child ->
logJobTree(child, "$indent ")
}
}
// 在 ViewModel 中调用
logJobTree(viewModelScope.coroutineContext[Job]!!)
输出示例(当三个子协程都已启动时):
D/JobTree: # 0xABCD (SupervisorJob)
D/JobTree: # 0x1234 (StandaloneCoroutine{Active})
D/JobTree: # 0x5678 (StandaloneCoroutine{Active})
D/JobTree: # 0x9ABC (DeferredCoroutine{Active})
7. 常见错误
错误1:在 SupervisorJob 中使用 async 且不处理异常
当 async 协程失败后,如果没有人 .await(),异常将被静默吞掉,导致业务逻辑未知。必须为 async 设置 try-catch 或使用 supervisorScope 结合 CoroutineExceptionHandler。
错误2:在 ViewModel 外部的 scope 中错误使用 SupervisorJob
如果业务要求原子性失败(比如多个子任务属于一个事务),应使用普通 Job 而不是 SupervisorJob。
错误3:同时混合 SupervisorJob 和普通 Job
在同一个 scope 中,如果父 Job 是普通 Job,即便子 Job 是 SupervisorJob,异常依然会向上传播到父 Job。层次结构的异常传播由最接近根的 Job 类型决定。
8. 性能与稳定性优化
- 合理拆分 scope:如果某个子协程是核心任务(如支付流程),建议使用独立的
supervisorScope { ... }而不是直接加到 ViewModelScope 内,便于精细控制取消范围。 - 避免在 SupervisorJob 中使用不合理的异常处理:不要全局捕获所有异常,应根据业务类型分别处理(网络失败重试、缓存错误降级)。
- 监控 Job 数量:如果 ViewModel 生命周期内持续启动大量协程而不取消(如轮询),应使用
Job().apply { children.forEach { it.cancel() } }或cancelChildren()控制数量,防止内存泄漏。 - 测试异常传播:编写单元测试时,构造 SupervisorJob scope 并模拟子协程抛出异常,验证其他协程是否正常运行。
小结:SupervisorJob 是 ViewModelScope 的基石,它让 UI 层的协程隔离性大大增强。掌握 Job 层次结构与异常传播规则,你就能在设计协程任务时做出正确的取消决策。