CoroutineScope 与 SupervisorJob:Job 层次结构、异常传播、SupervisorJob 在 ViewModel 中的使用

在 ViewModel 中使用协程时,绝大多数开发者直接使用 viewModelScope 而忽略了其底层的 Job 类型。实际上 viewModelScope 默认使用 SupervisorJob,这种 Job 的异常传播行为与普通 Job 有本质区别。理解 Job 层次结构与异常传播规则,是安全取消、避免协程泄漏和意外崩溃的前提。

1. Job 层次结构:从根到叶

每一个 CoroutineScope 都关联一个 Job(或 SupervisorJob)。当在 scope 内启动子协程时,子 Job 会以当前 Job 为父节点挂到树中。下图展示了 ViewModel 中典型的 Job 树结构:

ViewModelScope 内 Job 层次结构与异常传播 根 Job (viewModelScope) SupervisorJob 特性 子协程失败不取消父 Job 兄弟协程不受影响 子协程 A (launch) 子协程 B (launch) 子协程 C (async) 异常在子协程 B 内部发生 SupervisorJob 不会取消 A 和 C,兄弟协程继续运行

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 层次结构与异常传播规则,你就能在设计协程任务时做出正确的取消决策。