
前言
化神境中阶已过,你掌握了 SharedFlow 的多播奥义,事件分发如臂使指。你配置了 replay,调优了 extraBufferCapacity,消息总线稳如磐石。
但一个幽灵,一个名为 "异常" 的幽灵,始终在你的 Flow 管道中徘徊。
- 1、我在
flow {}里用try-catch捕获了异常,为什么collect时还是崩溃了?- 2、
catch操作符到底能捕获哪些异常?为什么放在不同位置效果完全不同?- 3、网络请求失败了,我想自动重试 3 次,每次间隔递增。用
retry还是retryWhen?- 4、
collect块里抛出的异常,能用catch捕获吗?
Flow 的异常处理,是其设计中最精妙也最容易被误解的部分。它不是简单的 try-catch 包装,而是一套异常透明性 原则指导下的传播体系。不理解这套原则,你的 Flow 管道就永远埋着崩溃的隐患。
本讲是化神境的最终章。你将彻底驯服 Flow 的异常天劫:
- 搞懂
catch操作符的精确作用域------它能捕获什么,不能捕获什么。 - 掌握
retry与retryWhen的重试策略,实现指数退避。 - 理解"异常透明性"原则,知道为什么
catch不能捕获下游异常。 - 学会在
collect块中安全处理异常的最佳实践。 - 看清 Flow 异常与结构化并发的协同关系。
准备好渡劫了吗?我们开始。
操千曲 而后晓声,观千剑 而后识器。虐它千百遍 方能通晓其真意。
Flow 异常的传播铁律:异常透明性
在 Kotlin Flow 的设计中,有一条不可动摇的原则:
异常透明性 :下游的异常处理操作符(如
catch)不得 捕获其下游 发生的异常。异常只能从上游 向下传播,并在终端操作符 (如collect)处最终暴露。
这条原则保证了 Flow 管道的可预测性 ------你不能用一个 catch 把整个管道包起来就假装万事大吉。
核心结论:
catch只能捕获它之前的操作符中抛出的异常。collect块中的异常,catch永远无法捕获。
catch 操作符深度解析
catch 的基本用法
catch 拦截上游异常,并允许你执行恢复操作------比如发射一个替代值,或者重新抛出异常。
kotlin
flow {
emit(1)
throw RuntimeException("炸了")
emit(2) // 不会执行
}.catch { e ->
println("捕获:${e.message}")
emit(-1) // 发射替代值
}.collect { value ->
println("收集:$value")
}
// 输出:
// 收集:1
// 捕获:炸了
// 收集:-1
catch 不能捕获下游异常
这是最常见的陷阱。看看这段代码:
kotlin
flow {
emit(1)
emit(2)
}.catch { e ->
println("这里捕获不到")
}.collect { value ->
if (value == 2) throw RuntimeException("collect 炸了")
}
// catch 不会执行,程序崩溃!
为什么?因为异常发生在 collect 块中,而 collect 位于 catch 的下游 。根据异常透明性原则,catch 无权干涉。
正确的做法:在 collect 内部 try-catch
kotlin
flow {
emit(1)
emit(2)
}.collect { value ->
try {
if (value == 2) throw RuntimeException("collect 炸了")
} catch (e: Exception) {
println("collect 内部捕获:${e.message}")
}
}
catch 操作符捕获范

retry 与 retryWhen:让 Flow 拥有不死之身
网络波动、服务临时不可用,这些瞬态错误不应让整个流终止。retry 和 retryWhen 让 Flow 具备了重试能力。
retry:简单重试
kotlin
flow {
emit(1)
throw IOException("网络错误")
}.retry(3) // 最多重试 3 次
.catch { e -> emit(-1) }
.collect { println(it) }
当上游抛出异常时,retry 会重新订阅 上游 Flow,从头开始执行。如果重试次数用尽仍失败,异常才会向下传播给 catch。
retryWhen:条件重试与指数退避
retryWhen 提供了更精细的控制。它的 lambda 接收两个参数:cause(异常)和 attempt(当前重试次数,从 0 开始)。你返回一个 Boolean:true 表示重试,false 表示放弃。
kotlin
flow {
println("尝试请求...")
throw IOException("网络错误")
}.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
val delayMillis = 1000L * (attempt + 1) // 1s, 2s, 3s
delay(delayMillis)
true // 重试
} else {
false // 放弃,异常向下传播
}
}.catch { e ->
emit(-1)
}.collect { println("结果:$it") }
注意:retry 会重新执行整个上游
retry 重订阅意味着上游 Flow 的所有代码都会重新执行。如果你的 Flow 中有副作用(如写入数据库),务必小心。
kotlin
var counter = 0
flow {
counter++
println("执行第 $counter 次")
throw IOException()
}.retry(2)
.collect()
// 输出:执行第 1 次、执行第 2 次、执行第 3 次
异常处理与结构化并发的协同
Flow 的收集是在协程中进行的。当收集协程被取消时,Flow 的异常处理行为会发生变化。
取消 vs 异常
取消是正常 的协程结束方式,抛出 CancellationException。catch 操作符默认不捕获 CancellationException(这是正确的,因为取消不应被视为错误)。
kotlin
val job = launch {
flow {
emit(1)
delay(1000)
emit(2)
}.catch { e ->
println("捕获不到 CancellationException")
}.collect { value ->
println(value)
}
}
delay(100)
job.cancelAndJoin()
// 不会打印 "捕获不到 CancellationException"
在 ViewModel 中的安全收集
结合 viewModelScope 和 catch,可以构建安全的 UI 数据流:
kotlin
class UserViewModel : ViewModel() {
val userState: StateFlow<UiState> = userRepo.observeUser()
.map { user -> UiState.Success(user) as UiState }
.catch { e -> emit(UiState.Error(e.message)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = UiState.Loading
)
}
这里 catch 捕获了上游(userRepo)可能的所有异常,并转换为 Error 状态,确保 StateFlow 永远不会因为异常而终止。
常见异常模式与处理策略
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 网络请求可能失败,需重试 | retryWhen + 指数退避 |
避免雪崩,给服务恢复时间 |
| 数据解析失败,无法恢复 | catch 中发射空值或错误状态 |
不让单个数据破坏整个流 |
collect 块内可能抛异常 |
在 collect 内部 try-catch |
catch 无法捕获下游异常 |
| 需要区分不同异常类型 | catch 内 when (e) 分支处理 |
精细化恢复策略 |
| 不希望异常中断 StateFlow | 在 stateIn 前用 catch 转为错误状态 |
保持 UI 状态持续可用 |
实战:带重试与缓存的网络请求 Flow
kotlin
class NewsViewModel(
private val newsRepo: NewsRepository
) : ViewModel() {
sealed class UiState {
object Loading : UiState()
data class Content(val news: List<News>) : UiState()
data class Error(val message: String, val canRetry: Boolean) : UiState()
}
private val refreshTrigger = MutableSharedFlow<Unit>(replay = 1)
val uiState: StateFlow<UiState> = refreshTrigger
.flatMapLatest {
flow {
emit(UiState.Loading)
val news = newsRepo.fetchLatestNews()
emit(UiState.Content(news))
}.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(1000L * (attempt + 1))
true
} else {
false
}
}.catch { e ->
emit(UiState.Error(e.message ?: "未知错误", canRetry = true))
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = UiState.Loading
)
fun refresh() {
viewModelScope.launch {
refreshTrigger.emit(Unit)
}
}
}
常见错误与避坑指南
错误 1:用 catch 包裹整个 Flow 期望捕获所有异常
kotlin
// ❌ catch 捕获不到 collect 里的异常
flow { emit(1) }
.catch { e -> println("捕获不到") }
.collect { throw RuntimeException() }
正确 :collect 内部用 try-catch。
错误 2:retry 内不 delay,疯狂重试
kotlin
.retryWhen { _, attempt -> attempt < 3 } // 无延迟,瞬间重试 3 次
正确 :加入 delay,给服务端喘息时间。
错误 3:在 StateFlow 转换中未处理异常导致流终止
kotlin
val state = flow { ... }.stateIn(...) // 异常会取消整个 StateFlow
正确 :在 stateIn 前使用 catch 转换为错误状态。
错误 4:混淆取消与失败
kotlin
.catch { e ->
if (e is CancellationException) { /* 永远走不到这里 */ }
}
正确 :catch 不捕获取消异常,无需处理。
八、最佳实践
catch放在可能失败的操作符之后:明确它的捕获范围。collect内异常自己处理 :不要期望catch能帮忙。- 重试必须有延迟和上限:防止资源耗尽。
- 将异常转为 UI 状态而非崩溃:保持用户体验。
- 在
stateIn前完成异常处理:确保热流永不终止。 - 用
retryWhen实现业务级重试策略:如仅重试 5xx 错误。
九、总结与下回预告
恭喜,你已渡过 Flow 的异常天劫,化神境大圆满!
本讲核心收获:
- Flow 的异常透明性原则:
catch只能捕获上游异常。 collect块异常需用try-catch自行处理。retry和retryWhen实现可配置的重试逻辑。- 取消异常(
CancellationException)被catch忽略,符合预期。 - 在 ViewModel 中用
catch+stateIn构建不死的 UI 状态流。
在下一境------炼虚境·初阶 ------中,我们将踏入 Channel 的领域,学习协程间的通信管道。届时你会明白:
Channel与Flow/SharedFlow有何本质不同?- 如何用
Channel构建生产者-消费者模式? RENDEZVOUS、BUFFERED、CONFLATED等容量策略如何选择?
准备好破境炼虚了吗?
【当前境界修为面板】
| 当前境界 | 修炼技能 | 修炼进度 | 修炼心得 |
|---|---|---|---|
| 化神境 · 后阶 | 1、catch 精准捕获术 2、retryWhen 重试诀 3、异常透明性总纲 |
当前进度 :100% 修为 :1000/1000 下一突破 :[炼虚境 · 初阶] (需领悟:Channel 基础、容量策略、生产者-消费者模式) |
catch只能捕获上游,collect内的异常需自己handle。异常透明性原则是Flow安全之基。 |
【本讲思考题】
-
表象题:以下代码会打印什么?
kotlinflow { emit(1); throw IOException() } .catch { e -> emit(2) } .catch { e -> emit(3) } .collect { println(it) } -
场景题 :你有一个网络请求 Flow,希望在遇到
IOException时重试最多 3 次,每次间隔 2 秒;遇到其他异常时不重试,直接显示错误。请写出核心代码。 -
原理题 :为什么
catch操作符无法捕获collect块中的异常?请从 Flow 的"冷流"执行模型和操作符的挂起函数调用栈角度简述。
道友,化神四境已全部通关。你的 Flow 已臻化境,数据流在你手中如臂使指。下一境,我们将踏入协程间通信的秘境------Channel。炼虚境·初阶见。
欢迎一键四连 (
关注+点赞+收藏+评论)