7. 取消与超时
协程是协作式取消:调用 cancel() 只是「打个招呼」,协程会在下一个挂起点(delay / withContext / await 等)检查并真正停止。它不是线程的强制 interrupt,所以写重活时你要主动配合检查。
一、全家福(按使用频率)
| 频率 | API | 是什么 | 典型场景 |
|---|---|---|---|
| 🔥 极高 | job.cancel() | 请求取消某个协程 | 页面销毁、用户离开、重试前停掉旧请求 |
| 🔥 极高 | job.cancelAndJoin() | 取消并等它真正结束 | 切换数据前确保旧任务不再写状态 |
| 🔥 极高 | withTimeout(n) | 超过 n 毫秒抛 TimeoutCancellationException | 接口调用兜底,别让用户一直转圈 |
| 高 | withTimeoutOrNull(n) | 超时返回 null 而非抛异常 | 「能拿到就用,拿不到算了」的非关键请求 |
| 高 | coroutineContext.isActive / ensureActive() | 检查取消标志 | 循环 / 长计算里主动配合取消 |
| 中 | yield() | 让出线程并顺便检查取消 | 紧循环里既让出 CPU 又响应取消 |
| 中 | job.cancelChildren() | 取消所有子协程,保留自身 | 下拉刷新时清掉上一轮子任务 |
| 中 | withContext(NonCancellable) { ... } | 在取消中执行不可打断的收尾 | 取消时也要安全关闭资源 / 写库 |
| 低 | job.join() | 等协程结束(不取消) | 测试 / 合并多个结果 |
二、协作式取消:原理与场景
2.1 循环里必须主动检查
下面代码写在 ViewModel 的方法中。注意 repeat 里有 ensureActive()——纯 CPU 循环没有挂起点,如果不主动检查,cancel() 要等循环结束才生效。
class PollingViewModel(private val repo: UserRepository) : ViewModel() {
private var job: Job? = null
// ① 启动轮询(写在 ViewModel 方法里)
fun startPolling() {
job = viewModelScope.launch {
repeat(1000) { i ->
ensureActive() // 主动检查:被取消就在这里抛出 CancellationException
repo.pushHeartbeat(i) // 挂起函数,本身也会在入口检查取消
delay(1000) // 挂起点,取消会在此刻生效
}
}
}
// 离开页面 / 退到后台时停掉
fun stopPolling() {
job?.cancel() // 只取消,不等待
// job?.cancelAndJoin() // 取消并等它真正结束(在 suspend 函数里用)
}
}
viewModelScope 在 ViewModel onCleared() 时自动取消其下所有协程。所以上面手动 cancel() 只是「提前停」,不是必须写;但轮询 / 定时任务这种,提前停能省流量、省电。
2.2 取消会向上冒泡
结构化并发下,父作用域取消,所有子孙协程一起取消。这正是「页面关了,里面所有请求自动停」的来源。
viewModelScope.launch { // 父
launch { fetchA() } // 子 A
launch { fetchB() } // 子 B
// 本协程取消 → A、B 一并取消
}
三、超时控制
3.1 超时就抛异常 vs 返回 null
下面写在 ViewModel 的方法中,数据请求来自 Repository。
class UserViewModel(private val repo: UserRepository) : ViewModel() {
// ① 超时抛异常:用 try/catch 兜成 UI 错误态
fun loadWithTimeout() = viewModelScope.launch {
try {
val data = withTimeout(2000L) { repo.fetchSlowData() } // >2s 抛 TimeoutCancellationException
_state.value = UiState.Success(data)
} catch (e: TimeoutCancellationException) {
_state.value = UiState.Error("加载超时,请重试")
}
}
// ② 超时返回 null:可选数据,拿不到就走兜底
fun loadOptional() = viewModelScope.launch {
val banner = withTimeoutOrNull(800L) { repo.fetchBanner() } // 超时返回 null
_banner.value = banner ?: fallbackBanner // 静默兜底
}
}
结果是「主流程必须有」的(如列表数据)→ withTimeout + try/catch;结果「有也行没有也行」的(如运营 banner、埋点)→ withTimeoutOrNull 静默兜底,不让用户看到错误。
3.2 取消时做不可打断的收尾
有时取消也要把一半的进度安全落库,不能半途而废。用 NonCancellable 包住收尾逻辑:
val job = scope.launch {
try {
doWork()
} finally {
// 取消也会进 finally;用 NonCancellable 保证收尾不被再打断
withContext(NonCancellable) {
saveProgress() // 安全落盘
closeResources()
}
}
}
四、真实开发中会踩的坑
踩坑 1:纯 CPU 计算不取消:循环里没有挂起点(delay/await/withContext),cancel() 要等循环跑完才生效。解决:循环里加 ensureActive() 或 yield()。这是 ANR / 关不掉后台任务最常见原因。
踩坑 2:把 CancellationException 当业务异常吞掉:try { ... } catch (e: Exception) 会连 CancellationException 一起捕获,导致协程无法被正常取消(父作用域以为它还活着)。只兜你关心的业务异常,或显式 rethrow:
try {
repo.fetch()
} catch (e: CancellationException) {
throw e // 必须原样抛出,保留取消信号
} catch (e: IOException) {
_state.value = UiState.Error(e.message)
}
踩坑 3:withTimeout 的超时异常被 catch 成「错误」:用 withTimeout 时记得单独 catch TimeoutCancellationException,否则它会落到通用 catch (e: Exception) 里被当成业务失败,UI 出现诡异错误态。
踩坑 4:cancel 之后还去读已失效的状态:取消是异步的,cancel() 返回时协程可能还没真正停。需要「确认停了再干下一件事」时,用 cancelAndJoin(),否则下一个请求可能和旧请求竞争写同一个 StateFlow。