第3章 默认Job与SupervisorJob选择

3.1 核心原理:失败传播机制

在 Kotlin 协程中,Job 和 SupervisorJob 是两种最常用的 Job 实现。它们最本质的区别在于子协程异常是否传播到父级。

  • Job(默认):子协程抛出未捕获异常时,父 Job 立即取消,并导致所有活跃的子协程(包括兄弟协程)统一失败。这是“连带失败”模型,适用于整个任务组必须原子化成功/失败的场景。
  • SupervisorJob:子协程的异常被隔离,父 Job 和兄弟子协程不受影响。这是“独立失败”模型,适合单个子任务失败不应影响其他任务的场景。
关键区别:Job 的异常传播是自底向上再自顶向下的扩散,而 SupervisorJob 的异常仅在发生异常的子协程内处理。

3.2 传播流程图(Job vs SupervisorJob)

Job 与 SupervisorJob 异常传播对比 Job(默认) 父 Job 子协程 A(正常) 子协程 B(异常) 兄弟协程(被取消) 异常传播 SupervisorJob 父 SupervisorJob 子协程 A(正常) 子协程 B(异常) 兄弟协程(继续运行) 异常隔离 说明 左侧:异常从子 B 传播到父,父取消所有子 右侧:异常仅在子 B 内部处理,不影响其他

3.3 代码实现:创建 Scope 时的选择

在 Service 中,通常在 onCreate() 或初始化阶段创建协程 Scope。下面对比两种方式:

实践建议:使用 SupervisorJob() 时,父 Job 类型必须显式传入,否则默认仍为普通 Job()。

示例1:使用默认 Job(连带失败)

class MyService : Service() {
    private val scope = CoroutineScope(Dispatchers.IO + Job())  // 默认Job

    fun startTasks() {
        scope.launch {
            // 任务1
            val result1 = fetchFromNetwork()
        }
        scope.launch {
            // 任务2:抛出异常时会导致任务1也被取消
            if (someCondition) throw RuntimeException("Task2 failed")
        }
    }
}

示例2:使用 SupervisorJob(独立失败)

class MyService : Service() {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())  // 显式SupervisorJob

    fun startTasks() {
        scope.launch {
            // 任务1
            val result1 = fetchFromNetwork()
        }
        scope.launch {
            // 任务2:异常不会影响任务1
            if (someCondition) throw RuntimeException("Task2 failed")
        }
    }
}

3.4 概念对比

特性 Job(默认) SupervisorJob
异常传播 子异常 → 父取消 → 所有子取消 子异常仅影响自身,父/兄弟不受影响
Scope 类型 CoroutineScope CoroutineScope + SupervisorJob()
适合场景 原子化任务组(如数据库事务) 独立子任务(如日志上报、缓存刷新)
异常处理代价 整个 Scope 重建,开销大 仅重建失败子协程,开销小
默认行为 引发 JobCancellationException 传播 子协程异常被封装为 CompletionHandler 回调

3.5 实际场景选择策略

  • 强一致性任务组(如数据同步时写库 + 上报 + 清理缓存):使用默认 Job(),任一子任务失败则整个任务组回滚或重试。
  • 弱依赖独立任务(如首页加载多个 API 数据,单个失败允许重试而不影响其他):使用 SupervisorJob()。
  • 混合场景:最外层 Scope 使用 SupervisorJob(),内部每个“原子组”再嵌套普通 Job()。
常见错误:在 launch 中直接 catch 异常并忽略,却仍然使用了普通 Job()。这会导致异常虽然被 catch,但 JobCancellationException 仍然会向上传播,导致父 Scope 取消。正确做法:要么使用 SupervisorJob(),要么在祖父辈 catch 并处理。

3.6 调试与日志观察

可通过协程的 CoroutineExceptionHandler 或 invokeOnCompletion 来观察异常传播路径:

val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("ScopeTutorial", "Caught: ${exception.message}")
}

scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + handler)

scope.launch {
    delay(100)
    throw RuntimeException("Test failure")
}
// 日志输出:只有该子协程的异常被 handler 捕获
// 父 Job 和兄弟协程仍然运行

若将 SupervisorJob() 换成 Job(),则日志中除了子协程异常外,还会看到 JobCancellationException 从父 Scope 传播。

3.7 性能与稳定性优化

在 Service 长期后台任务中,选择不当会导致Scope 反复重建,引发内存泄漏或资源浪费。参考下列优化原则:

  • 首选 SupervisorJob() 作为顶层 Scope,除非明确需要原子失败。
  • 每个子任务内使用 try-catch 处理预期异常,防止未捕获异常意外传播。
  • 避免在 onDestroy() 中 cancel() 时强制传播异常,优先使用 scope.cancel() 清理所有子协程。
经验法则:80% 的 Service 场景应使用 SupervisorJob(),只有并发事务型操作才用默认 Job()。选择正确后,异常处理代码量可减少 60% 以上。