【Kotlin 协程修仙录 · 金丹境 · 巅峰】 | 道心圆满:轻量级重试与超时机制的协程实战

0 阅读8分钟

image_11.png

前言

金丹境后阶已过,你已掌握 supervisorScope 的异常隔离之道。推荐列表崩溃,商品信息依然稳如磐石;一个子协程的失败,不再株连九族。你的并发架构,已初具韧性。

然而,道心是否真正圆满,还需经历最后一道考验——无常的网络。后端服务偶尔抽风,第三方接口时不时超时,用户的网络从 5G 切换到弱 Wi-Fi……你的协程代码,能否在这些风浪中优雅生存?

“一个网络请求超时了,我不想让用户干等 30 秒。能不能 3 秒没响应就自动取消,给个提示让用户重试?” “某些接口偶尔失败,但重试一次就好了。能不能让它自动重试 2 次,每次间隔递增,而不是直接弹错误页面?” “多个并发请求中,有的可以重试,有的重试没意义(比如扣款)。如何针对性配置?”

传统方案里,你需要在 Callback 中手写计时器,在 Handler 里管理重试计数,代码膨胀且极易出错。而协程,为你准备了两个轻量级法器:withTimeoutOrNullretryWhen。它们不是重量级的框架,而是内嵌于协程体系的优雅工具——就像剑仙的袖中飞剑,轻巧却致命。

本讲是金丹境的最终章。你将:

  • 掌握 withTimeoutwithTimeoutOrNull 的超时控制,让慢请求不再拖垮体验。
  • 学会 retryretryWhen 的重试策略,实现指数退避与条件重试。
  • 将超时与重试组合,构建弹性的网络请求层。
  • 在 Android 实战中打造一个“永不白屏”的加载框架。

准备好将金丹淬炼至圆满,让协程在风暴中稳如磐石了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


超时控制:withTimeoutwithTimeoutOrNull

为什么需要超时?

用户点击按钮后,如果 3 秒还没反应,就会焦虑。30 秒是系统 ANR 的底线,但好的体验要求秒级响应。协程的 withTimeout 让你能为任意挂起操作设定截止时间——超时则抛异常,干净利落。

import kotlinx.coroutines.*

suspend fun fetchDataWithTimeout(): String {
    return withTimeout(3000) { // 3 秒超时
        // 模拟一个可能很慢的网络请求
        delay(5000)
        "数据"
    }
}

// 如果超过 3 秒未完成,抛出 TimeoutCancellationException

withTimeoutOrNull:超时不崩溃,返回 null

大多数 UI 场景下,你不想因为超时就直接崩溃,而是返回一个空结果或降级数据。withTimeoutOrNull 正是为此而生——超时时返回 null,而非抛出异常。

suspend fun fetchDataSafe(): String? {
    return withTimeoutOrNull(3000) {
        delay(5000) // 模拟慢网络
        "数据"
    } ?: "超时降级数据" // null 时提供默认值
}
flowchart LR
    Start[发起请求] --> Timeout{withTimeoutOrNull}
    Timeout -->|3秒内完成| Success[返回结果]
    Timeout -->|超时| Null[返回 null]
    Null --> Fallback[使用降级数据]
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Timeout fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Success fill:#c8e6c9,stroke:#2e7d32
    style Null fill:#ffcdd2,stroke:#b71c1c
    style Fallback fill:#ffb74d

超时异常的特殊性

withTimeout 抛出的是 TimeoutCancellationException,它是 CancellationException 的子类。这意味着:

  • 不会被普通的 catch (e: Exception) 捕获(如果使用 CoroutineExceptionHandler)。
  • 它被视为正常的协程取消,而非程序错误。
  • 你可以用 catch 操作符在 Flow 中处理它,但通常更推荐用 withTimeoutOrNull 避免异常。
// 注意:如果用 try-catch 包裹 withTimeout
try {
    withTimeout(1000) { delay(5000) }
} catch (e: TimeoutCancellationException) {
    println("捕获到超时异常") // 这是可以的
}

重试机制:retryretryWhen

retry:简单粗暴的重试

retry 操作符可以在 Flow 中直接使用。当上游抛出异常时,它会重新收集上游 Flow,最多重试指定次数。

flow {
    emit(tryFetch()) // 可能失败的网络请求
}.retry(3) // 最多重试 3 次
 .catch { e -> emit(fallbackData) }
 .collect { data -> updateUI(data) }

retry 的行为是:

  • 当异常发生时,立即重新订阅上游 Flow。
  • 如果重试次数用尽仍失败,异常向下传播给 catch 或终端。
  • 没有延迟,可能瞬间打满重试次数。

retryWhen:条件重试与指数退避

retryWhen 提供了更精细的控制。它的 lambda 接收 cause(异常)和 attempt(当前重试次数,从 0 开始)。你可以:

  • 根据异常类型决定是否重试(如只重试 IOException)。
  • 加入延迟,实现指数退避。
  • 设置最大重试次数。
flow {
    emit(api.fetchData())
}.retryWhen { cause, attempt ->
    if (cause is IOException && attempt < 3) {
        val delayMs = 1000L * (attempt + 1) // 1s, 2s, 3s
        delay(delayMs)
        true // 重试
    } else {
        false // 不重试,异常向下传播
    }
}.catch { e ->
    emit(fallbackData)
}
flowchart TD
    Start[上游抛出异常] --> Retry{retryWhen 判断}
    Retry -->|条件满足| Delay[延迟等待]
    Delay --> Resub[重新订阅上游]
    Resub --> Start
    Retry -->|条件不满足| Propagate[异常向下传播]
    Propagate --> Catch[catch 或终端处理]
    
    style Start fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style Retry fill:#fff9c4,stroke:#f9a825,stroke-width:2px
    style Delay fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Resub fill:#c8e6c9,stroke:#2e7d32
    style Propagate fill:#ef9a9a
    style Catch fill:#ffb74d

重试与超时的组合

现实世界中,超时和重试往往需要配合使用。例如:每个请求最多等待 3 秒,超时则重试,最多重试 2 次。

suspend fun fetchWithRetryAndTimeout(): String {
    var lastException: Throwable? = null
    repeat(3) { attempt ->
        try {
            return withTimeout(3000) {
                api.fetchData()
            }
        } catch (e: TimeoutCancellationException) {
            lastException = e
            // 指数退避
            delay(1000L * (attempt + 1))
        } catch (e: IOException) {
            lastException = e
            delay(500L * (attempt + 1))
        }
    }
    throw lastException ?: IllegalStateException("重试耗尽")
}

更优雅的方式是在 Flow 中串联:

flow {
    val data = withTimeout(3000) { api.fetchData() }
    emit(data)
}.retryWhen { cause, attempt ->
    if (cause is TimeoutCancellationException && attempt < 2) {
        delay(1000L * (attempt + 1))
        true
    } else false
}.catch { e -> emit(fallbackData) }

实战:构建弹性的网络请求层

封装一个支持超时与重试的请求函数

我们将超时与重试逻辑封装为一个通用函数,供 ViewModel 调用。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val message: String, val cause: Throwable? = null) : NetworkResult<Nothing>()
}

suspend fun <T> safeApiCall(
    timeoutMs: Long = 5000,
    maxRetries: Int = 2,
    initialDelayMs: Long = 1000,
    block: suspend () -> T
): NetworkResult<T> {
    var lastException: Throwable? = null
    repeat(maxRetries + 1) { attempt ->
        try {
            val result = withTimeout(timeoutMs) {
                block()
            }
            return NetworkResult.Success(result)
        } catch (e: TimeoutCancellationException) {
            lastException = e
            if (attempt < maxRetries) {
                delay(initialDelayMs * (attempt + 1))
            }
        } catch (e: Exception) {
            lastException = e
            if (attempt < maxRetries && e is IOException) {
                delay(initialDelayMs * (attempt + 1))
            } else {
                break // 非 IO 异常不重试
            }
        }
    }
    return NetworkResult.Error(
        message = lastException?.message ?: "未知错误",
        cause = lastException
    )
}

ViewModel 中使用

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    var uiState by mutableStateOf<UiState>(UiState.Idle)
        private set

    fun loadProduct(id: String) {
        viewModelScope.launch {
            uiState = UiState.Loading
            when (val result = safeApiCall(
                timeoutMs = 5000,
                maxRetries = 2
            ) {
                repository.getProduct(id)
            }) {
                is NetworkResult.Success -> {
                    uiState = UiState.Content(result.data)
                }
                is NetworkResult.Error -> {
                    uiState = UiState.Error(result.message, retry = { loadProduct(id) })
                }
            }
        }
    }
}
deepseek_mermaid_20260716_4da79d.png

超时与结构化并发的协同

超时与协程取消机制天然契合。withTimeout 内部使用了 withTimeoutOrNullCancellationException。当你取消一个包含超时操作的协程时,TimeoutCancellationException 会被正确抛出,不会导致未捕获的异常。

val job = viewModelScope.launch {
    withTimeout(5000) {
        delay(10000) // 模拟超长操作
    }
}
delay(1000)
job.cancel() // 正常取消,不会崩溃

如果在 supervisorScope 中使用超时,一个子协程的超时不会影响兄弟协程。

supervisorScope {
    launch {
        withTimeout(2000) { delay(5000) } // 超时,只影响自己
    }
    launch {
        delay(3000)
        println("另一个协程正常完成") // 不受上面超时影响
    }
}
sequenceDiagram
    participant Scope as supervisorScope
    participant Job1 as 子协程1 withTimeout(2000)
    participant Job2 as 子协程2

    Scope->>Job1: 启动
    Scope->>Job2: 启动
    Job1->>Job1: delay(5000)
    Job1-->>Job1: 2秒后超时 抛出 TimeoutCancellationException
    Job1-->>Scope: 取消 Job1
    Note over Scope: 不影响 Job2
    Job2->>Job2: delay(3000) 继续执行
    Job2-->>Scope: 正常完成

常见错误与避坑指南

错误 1:在 retry 中不设延迟,疯狂重试

flow { emit(api.fetch()) }
    .retry(5) // 没有延迟,瞬间打满 5 次请求

正确做法:使用 retryWhen 并加入 delay

错误 2:混淆 withTimeout 的超时异常与普通异常

try {
    withTimeout(1000) { delay(5000) }
} catch (e: Exception) {
    // TimeoutCancellationException 不会被捕获(它是 CancellationException 的子类)
}

正确做法:用 withTimeoutOrNull 避免异常,或精准捕获 TimeoutCancellationException

错误 3:在 ViewModel 中手动管理重试计数器

var retryCount = 0
fun load() {
    viewModelScope.launch {
        try {
            api.fetch()
        } catch (e: Exception) {
            if (retryCount < 3) {
                retryCount++
                load()
            }
        }
    }
}

正确做法:将重试逻辑封装在 retryWhen 或可复用的挂起函数中,避免状态污染。


最佳实践

  1. UI 场景优先用 withTimeoutOrNull:返回 null 而非抛异常,便于降级展示。
  2. 仅对临时性错误重试:如 IOException、超时;对业务错误(如 404)不应重试。
  3. 重试必须有退避延迟和上限:防止雪崩和资源耗尽。
  4. 将超时与重试封装为通用函数:避免在业务代码中散落 withTimeoutretryWhen
  5. 结合 supervisorScope 实现局部超时隔离:单个接口超时不影响其他接口。

总结与下回预告

恭喜,你已将金丹淬炼至圆满——轻量级重试与超时机制,让你的协程在风浪中稳如磐石。

本讲核心收获

  • withTimeout 为挂起操作设定截止时间,超时抛出 TimeoutCancellationException
  • withTimeoutOrNull 超时返回 null,更适合 UI 降级。
  • retry 简单重试,retryWhen 支持条件重试与指数退避。
  • 超时与重试组合,构建弹性的网络请求层。

在下一境——元婴境·初阶——中,我们将踏入响应式编程的领域,学习 Flow 的基础用法。届时你会明白:

  • Flow 是什么?它和我们熟悉的 LiveDataRxJava 有什么区别?
  • 冷流的本质是什么?为什么它被称为“懒加载的数据流”?
  • 如何用 flow {} 构建器创建自己的数据流?

【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
金丹境 · 巅峰1、withTimeoutOrNull 超时控制术
2、retryWhen 重试诀
3、弹性网络请求模板
当前进度50%
修为500/1000
下一突破[元婴境 · 初阶] (需领悟:Flow 冷流基础)
超时是给用户的交代,重试是给系统的机会。协程让优雅容错成为标配。

【本讲思考题】

  1. 表象题withTimeoutwithTimeoutOrNull 的区别是什么?各适用于什么场景?

  2. 场景题:你有一个支付接口,要求超时 5 秒,且不能重试(防止重复扣款)。但商品详情接口超时 3 秒可重试 2 次。如何在同一个 ViewModel 中实现这两种策略?

  3. 原理题TimeoutCancellationException 为什么是 CancellationException 的子类?这种设计对协程的异常传播有什么好处?


道友,金丹四境已全部通关。你的协程已具备生产级的韧性。下一境,我们将踏入 Flow 的世界,学习如何用响应式流处理连续的数据。元婴境·初阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论