协程速通 · Android

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 函数里用)
    }
}
为什么 ViewModel 不用手动 cancel

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。

Kotlin 协程速通 · Android 开发 · 内容基于 kotlinx.coroutines 1.7+