第3章:协程取消基础 — 协作式取消、isActive、ensureActive 与 CancellationException

3.1 为何必须协作?

与 Java 原生线程的 stop() 不同,Kotlin 协程的取消是 协作式 的。这意味着运行时不会强行终止正在运行的协程,而是设置取消标志,由协程代码自身在合适的时机配合停止。

ViewModelScope 场景: 当 viewModelScope.onCleared() 被调用时,Job 层级中的取消标志被置为 false,并抛出 CancellationException。如果协程代码不主动检查取消,它将“免疫”取消,继续执行,造成资源浪费甚至对已销毁的 View 进行操作。

3.2 基础检查:isActive

isActive 是 CoroutineScope 的一个扩展属性。只要当前协程的 Job 处于活跃状态,它就返回 true。通常用于计算密集型的循环中。

// 在 ViewModelScope 中使用 isActive 跳出循环
viewModelScope.launch {
    var i = 0
    while (isActive) { // 每次迭代前检查取消标志
        processData(i++)
    }
}
特点: isActive 只是读取一个 volatile 变量,开销极低。你需要手动处理退出逻辑(如 return 或 break)。

3.3 简洁检查:ensureActive()

ensureActive() 是 kotlinx.coroutines 提供的便捷扩展函数。如果 Job 不再活跃,它会立即抛出 CancellationException,比手动检查 isActive 更简洁。

// 使用 ensureActive 减少样板代码
viewModelScope.launch {
    repeat(10_000) { index ->
        ensureActive()  // 如果已取消,这里直接抛出 CancellationException
        compute(index)
    }
}

对比差异:

特性isActiveensureActive()
类型属性(读布尔值)扩展函数
取消后行为返回 false,需手动 return/break自动抛出 CancellationException
性能损耗几乎为零极低(一次条件跳转)
推荐场景需要保存进度或分段退出的复杂循环挂起点或循环中快速失败检查

3.4 CancellationException 的本质与处理

当协程被取消时,内部的 Job 会在 ensureActive() 或挂起点(如 delay()、withContext)抛出 JobCancellationException(CancellationException 的子类)。

核心原则: CancellationException 不是业务异常,它代表协程的“正常停止信号”。不应捕获后吞掉。

// 反模式:吞噬 CancellationException
viewModelScope.launch {
    try {
        delay(1000)
    } catch (e: CancellationException) {
        // 什么都不做 —— 取消被吞掉,协程状态不一致!
    }
}

// 正确模式1:清理后重新抛出
viewModelScope.launch {
    try {
        delay(1000)
    } catch (e: CancellationException) {
        releaseLock()
        throw e // 必须重新抛出以向上传播取消
    }
}

// 正确模式2:使用 finally(官方推荐)
viewModelScope.launch {
    try {
        delay(1000)
    } finally {
        releaseLock() // 无论正常结束还是取消,都会执行
    }
}

3.5 在 ViewModelScope 中的实际运行流程

当 ViewModel 被清除时,viewModelScope 的 Job 立即变成 取消中 状态。这个信号会沿着 Job 树向下传播到每个子协程。

下面的图展示了这一协作式取消的完整决策链路:

协作式取消决策流程 启动协程 执行任务 / 挂起 取消信号? 抛出 CancellationException 执行清理 / 结束 继续执行 是 否 协作式取消保证了 ViewModel 被清除后,协程能安全释放资源,不会继续占用 CPU 或内存。

3.6 常见陷阱与调试方法

陷阱1:runCatching 吞噬取消
runCatching 会捕获所有异常,包括 CancellationException。这会导致协程无法正常结束,父协程认为任务已完成。
// 错误:取消被 runCatching 吞掉
viewModelScope.launch {
    runCatching {
        delay(1000)
    }
    // 协程继续运行,但 Job 已被取消
    dangerousOperation() // 可能引发 IllegalStateException
}
陷阱2:CPU 密集循环中不检查取消
如果一个协程在做大量计算且没有挂起点(如 delay、withContext),它必须手动调用 ensureActive() 或检查 isActive。否则即使 ViewModel 已销毁,它仍然会阻塞线程。

调试工具: 在 Android Studio 中启用 -Dkotlinx.coroutines.debug 系统属性,可以在日志中看到协程的名称、状态和取消信息。结合 Job.cancel(cause: CancellationException) 自定义原因,可以更轻松地追踪取消来源。

3.7 性能与稳定性要点

  • 开销对比: isActive ≈ 读 volatile 变量(<0.1ns);ensureActive() ≈ volatile 读 + 一次条件分支(<0.5ns)。两者对性能影响极小。
  • 稳定性收益: 不检查取消的协程可能导致 IllegalStateException(操作已销毁的视图)、内存泄漏(匿名内部类持有 Activity 引用)甚至 ANR(线程持续被占用)。
  • 权威建议: 在 ViewModelScope 中,如果循环包含挂起点,优先使用 ensureActive();如果是纯 CPU 密集型且无挂起调用,使用 while (isActive) 结构。