前言
金丹境后阶已过,你已掌握 supervisorScope 的异常隔离之道。推荐列表崩溃,商品信息依然稳如磐石;一个子协程的失败,不再株连九族。你的并发架构,已初具韧性。
然而,道心是否真正圆满,还需经历最后一道考验——无常的网络。后端服务偶尔抽风,第三方接口时不时超时,用户的网络从 5G 切换到弱 Wi-Fi……你的协程代码,能否在这些风浪中优雅生存?
“一个网络请求超时了,我不想让用户干等 30 秒。能不能 3 秒没响应就自动取消,给个提示让用户重试?” “某些接口偶尔失败,但重试一次就好了。能不能让它自动重试 2 次,每次间隔递增,而不是直接弹错误页面?” “多个并发请求中,有的可以重试,有的重试没意义(比如扣款)。如何针对性配置?”
传统方案里,你需要在 Callback 中手写计时器,在 Handler 里管理重试计数,代码膨胀且极易出错。而协程,为你准备了两个轻量级法器:withTimeoutOrNull 与 retryWhen。它们不是重量级的框架,而是内嵌于协程体系的优雅工具——就像剑仙的袖中飞剑,轻巧却致命。
本讲是金丹境的最终章。你将:
- 掌握
withTimeout与withTimeoutOrNull的超时控制,让慢请求不再拖垮体验。 - 学会
retry与retryWhen的重试策略,实现指数退避与条件重试。 - 将超时与重试组合,构建弹性的网络请求层。
- 在 Android 实战中打造一个“永不白屏”的加载框架。
准备好将金丹淬炼至圆满,让协程在风暴中稳如磐石了吗?我们开始。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
超时控制:withTimeout 与 withTimeoutOrNull
为什么需要超时?
用户点击按钮后,如果 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("捕获到超时异常") // 这是可以的
}
重试机制:retry 与 retryWhen
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) })
}
}
}
}
}
超时与结构化并发的协同
超时与协程取消机制天然契合。withTimeout 内部使用了 withTimeoutOrNull 和 CancellationException。当你取消一个包含超时操作的协程时,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 或可复用的挂起函数中,避免状态污染。
最佳实践
- UI 场景优先用
withTimeoutOrNull:返回null而非抛异常,便于降级展示。 - 仅对临时性错误重试:如
IOException、超时;对业务错误(如 404)不应重试。 - 重试必须有退避延迟和上限:防止雪崩和资源耗尽。
- 将超时与重试封装为通用函数:避免在业务代码中散落
withTimeout和retryWhen。 - 结合
supervisorScope实现局部超时隔离:单个接口超时不影响其他接口。
总结与下回预告
恭喜,你已将金丹淬炼至圆满——轻量级重试与超时机制,让你的协程在风浪中稳如磐石。
本讲核心收获:
withTimeout为挂起操作设定截止时间,超时抛出TimeoutCancellationException。withTimeoutOrNull超时返回null,更适合 UI 降级。retry简单重试,retryWhen支持条件重试与指数退避。- 超时与重试组合,构建弹性的网络请求层。
在下一境——元婴境·初阶——中,我们将踏入响应式编程的领域,学习 Flow 的基础用法。届时你会明白:
Flow是什么?它和我们熟悉的LiveData、RxJava有什么区别?- 冷流的本质是什么?为什么它被称为“懒加载的数据流”?
- 如何用
flow {}构建器创建自己的数据流?
【当前境界修为面板】
| 当前境界 | 修炼技能 | 修炼进度 | 修炼心得 |
|---|---|---|---|
| 金丹境 · 巅峰 | 1、withTimeoutOrNull 超时控制术2、 retryWhen 重试诀3、弹性网络请求模板 | 当前进度:50%修为: 500/1000下一突破: [元婴境 · 初阶] (需领悟:Flow 冷流基础) | 超时是给用户的交代,重试是给系统的机会。协程让优雅容错成为标配。 |
【本讲思考题】
-
表象题:
withTimeout和withTimeoutOrNull的区别是什么?各适用于什么场景? -
场景题:你有一个支付接口,要求超时 5 秒,且不能重试(防止重复扣款)。但商品详情接口超时 3 秒可重试 2 次。如何在同一个
ViewModel中实现这两种策略? -
原理题:
TimeoutCancellationException为什么是CancellationException的子类?这种设计对协程的异常传播有什么好处?
道友,金丹四境已全部通关。你的协程已具备生产级的韧性。下一境,我们将踏入 Flow 的世界,学习如何用响应式流处理连续的数据。元婴境·初阶见。
欢迎一键四连(
关注+点赞+收藏+评论)