【Kotlin 协程修仙录 · 化神境 · 后阶】 | 异常天劫:Flow 异常处理的终极奥义与重试之道

0 阅读7分钟

image_11.png

前言

化神境中阶已过,你掌握了 SharedFlow 的多播奥义,事件分发如臂使指。你配置了 replay,调优了 extraBufferCapacity,消息总线稳如磐石。

但一个幽灵,一个名为 “异常” 的幽灵,始终在你的 Flow 管道中徘徊。

  • 1、我在 flow {} 里用 try-catch 捕获了异常,为什么 collect 时还是崩溃了?
  • 2、catch 操作符到底能捕获哪些异常?为什么放在不同位置效果完全不同?
  • 3、网络请求失败了,我想自动重试 3 次,每次间隔递增。用 retry 还是 retryWhen
  • 4、collect 块里抛出的异常,能用 catch 捕获吗?

Flow 的异常处理,是其设计中最精妙也最容易被误解的部分。它不是简单的 try-catch 包装,而是一套异常透明性 原则指导下的传播体系。不理解这套原则,你的 Flow 管道就永远埋着崩溃的隐患。

本讲是化神境的最终章。你将彻底驯服 Flow 的异常天劫:

  • 搞懂 catch 操作符的精确作用域——它能捕获什么,不能捕获什么。
  • 掌握 retryretryWhen 的重试策略,实现指数退避。
  • 理解“异常透明性”原则,知道为什么 catch 不能捕获下游异常。
  • 学会在 collect 块中安全处理异常的最佳实践。
  • 看清 Flow 异常与结构化并发的协同关系。

准备好渡劫了吗?我们开始。

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


Flow 异常的传播铁律:异常透明性

Kotlin Flow 的设计中,有一条不可动摇的原则:

异常透明性:下游的异常处理操作符(如 catch不得捕获其下游发生的异常。异常只能从上游向下传播,并在终端操作符(如 collect)处最终暴露。

这条原则保证了 Flow 管道的可预测性——你不能用一个 catch 把整个管道包起来就假装万事大吉。

flowchart LR
    subgraph Upstream[上游]
        direction LR
        U1[flow 生产]
        U2[操作符1]
    end
    
    subgraph CatchOp[catch 操作符]
        C[catch]
    end
    
    subgraph Downstream[下游]
        direction LR
        D1[操作符2]
        D2[collect]
    end
    
    U1 --> U2 --> C --> D1 --> D2
    
    C -.->|可以捕获| U1
    C -.->|可以捕获| U2
    C -.->|不能捕获| D1
    C -.->|不能捕获| D2
    
    style Upstream fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style CatchOp fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Downstream fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style U1 fill:#a5d6a7
    style U2 fill:#a5d6a7
    style C fill:#ffb74d
    style D1 fill:#ef9a9a
    style D2 fill:#ef9a9a

核心结论

  • catch 只能捕获它之前的操作符中抛出的异常。
  • collect 块中的异常,catch 永远无法捕获

catch 操作符深度解析

catch 的基本用法

catch 拦截上游异常,并允许你执行恢复操作——比如发射一个替代值,或者重新抛出异常。

flow {
    emit(1)
    throw RuntimeException("炸了")
    emit(2) // 不会执行
}.catch { e ->
    println("捕获:${e.message}")
    emit(-1) // 发射替代值
}.collect { value ->
    println("收集:$value")
}
// 输出:
// 收集:1
// 捕获:炸了
// 收集:-1

catch 不能捕获下游异常

这是最常见的陷阱。看看这段代码:

flow {
    emit(1)
    emit(2)
}.catch { e ->
    println("这里捕获不到")
}.collect { value ->
    if (value == 2) throw RuntimeException("collect 炸了")
}
// catch 不会执行,程序崩溃!

为什么?因为异常发生在 collect 块中,而 collect 位于 catch下游。根据异常透明性原则,catch 无权干涉。

sequenceDiagram
    participant U as 上游 (flow)
    participant C as catch
    participant D as 下游 (collect)

    U->>C: emit(1)
    C->>D: 传递 1
    D->>D: 处理 1 (正常)
    
    U->>C: emit(2)
    C->>D: 传递 2
    D--xD: 抛出异常!
    
    Note over C: catch 无法感知下游异常
    D-->>U: 异常直接崩溃

正确的做法:在 collect 内部 try-catch

flow {
    emit(1)
    emit(2)
}.collect { value ->
    try {
        if (value == 2) throw RuntimeException("collect 炸了")
    } catch (e: Exception) {
        println("collect 内部捕获:${e.message}")
    }
}

catch 操作符捕获范

deepseek_mermaid_20260825_30cbda.png


retryretryWhen:让 Flow 拥有不死之身

网络波动、服务临时不可用,这些瞬态错误不应让整个流终止。retryretryWhen 让 Flow 具备了重试能力。

retry:简单重试

flow {
    emit(1)
    throw IOException("网络错误")
}.retry(3) // 最多重试 3 次
 .catch { e -> emit(-1) }
 .collect { println(it) }

当上游抛出异常时,retry重新订阅上游 Flow,从头开始执行。如果重试次数用尽仍失败,异常才会向下传播给 catch

retryWhen:条件重试与指数退避

retryWhen 提供了更精细的控制。它的 lambda 接收两个参数:cause(异常)和 attempt(当前重试次数,从 0 开始)。你返回一个 Booleantrue 表示重试,false 表示放弃。

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") }
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,stroke-width:2px
    style Propagate fill:#ef9a9a
    style Catch fill:#ffb74d

注意:retry 会重新执行整个上游

retry 重订阅意味着上游 Flow 的所有代码都会重新执行。如果你的 Flow 中有副作用(如写入数据库),务必小心。

var counter = 0
flow {
    counter++
    println("执行第 $counter 次")
    throw IOException()
}.retry(2)
 .collect()
// 输出:执行第 1 次、执行第 2 次、执行第 3 次

异常处理与结构化并发的协同

Flow 的收集是在协程中进行的。当收集协程被取消时,Flow 的异常处理行为会发生变化。

取消 vs 异常

取消是正常的协程结束方式,抛出 CancellationExceptioncatch 操作符默认不捕获 CancellationException(这是正确的,因为取消不应被视为错误)。

val job = launch {
    flow {
        emit(1)
        delay(1000)
        emit(2)
    }.catch { e ->
        println("捕获不到 CancellationException")
    }.collect { value ->
        println(value)
    }
}
delay(100)
job.cancelAndJoin()
// 不会打印 "捕获不到 CancellationException"

ViewModel 中的安全收集

结合 viewModelScopecatch,可以构建安全的 UI 数据流:

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 永远不会因为异常而终止。

flowchart LR
    subgraph Upstream[上游数据源]
        Repo[Repository Flow]
    end
    
    subgraph Transform[转换与异常处理]
        Map[map]
        Catch[catch 转为 Error 状态]
    end
    
    subgraph Downstream[UI 消费]
        SF[StateFlow]
        UI[Compose UI]
    end
    
    Repo --> Map --> Catch --> SF --> UI
    
    style Upstream fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Transform fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Downstream fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Catch fill:#ffb74d
    style SF fill:#90caf9

常见异常模式与处理策略

场景推荐方案理由
网络请求可能失败,需重试retryWhen + 指数退避避免雪崩,给服务恢复时间
数据解析失败,无法恢复catch 中发射空值或错误状态不让单个数据破坏整个流
collect 块内可能抛异常collect 内部 try-catchcatch 无法捕获下游异常
需要区分不同异常类型catchwhen (e) 分支处理精细化恢复策略
不希望异常中断 StateFlowstateIn 前用 catch 转为错误状态保持 UI 状态持续可用

实战:带重试与缓存的网络请求 Flow

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 期望捕获所有异常

// ❌ catch 捕获不到 collect 里的异常
flow { emit(1) }
    .catch { e -> println("捕获不到") }
    .collect { throw RuntimeException() }

正确collect 内部用 try-catch

错误 2:retry 内不 delay,疯狂重试

.retryWhen { _, attempt -> attempt < 3 } // 无延迟,瞬间重试 3 次

正确:加入 delay,给服务端喘息时间。

错误 3:在 StateFlow 转换中未处理异常导致流终止

val state = flow { ... }.stateIn(...) // 异常会取消整个 StateFlow

正确:在 stateIn 前使用 catch 转换为错误状态。

错误 4:混淆取消与失败

.catch { e -> 
    if (e is CancellationException) { /* 永远走不到这里 */ }
}

正确catch 不捕获取消异常,无需处理。


八、最佳实践

  1. catch 放在可能失败的操作符之后:明确它的捕获范围。
  2. collect 内异常自己处理:不要期望 catch 能帮忙。
  3. 重试必须有延迟和上限:防止资源耗尽。
  4. 将异常转为 UI 状态而非崩溃:保持用户体验。
  5. stateIn 前完成异常处理:确保热流永不终止。
  6. retryWhen 实现业务级重试策略:如仅重试 5xx 错误。

九、总结与下回预告

恭喜,你已渡过 Flow 的异常天劫,化神境大圆满!

本讲核心收获

  • Flow 的异常透明性原则:catch 只能捕获上游异常。
  • collect 块异常需用 try-catch 自行处理。
  • retryretryWhen 实现可配置的重试逻辑。
  • 取消异常(CancellationException)被 catch 忽略,符合预期。
  • 在 ViewModel 中用 catch + stateIn 构建不死的 UI 状态流。

在下一境——炼虚境·初阶——中,我们将踏入 Channel 的领域,学习协程间的通信管道。届时你会明白:

  • ChannelFlow/SharedFlow 有何本质不同?
  • 如何用 Channel 构建生产者-消费者模式?
  • RENDEZVOUSBUFFEREDCONFLATED 等容量策略如何选择?

准备好破境炼虚了吗?


【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
化神境 · 后阶1、catch 精准捕获术
2、retryWhen 重试诀
3、异常透明性总纲
当前进度100%
修为1000/1000
下一突破[炼虚境 · 初阶] (需领悟:Channel 基础、容量策略、生产者-消费者模式)
catch只能捕获上游,collect内的异常需自己handle。异常透明性原则是Flow安全之基。

【本讲思考题】

  1. 表象题:以下代码会打印什么?

    flow { emit(1); throw IOException() }
        .catch { e -> emit(2) }
        .catch { e -> emit(3) }
        .collect { println(it) }
    
  2. 场景题:你有一个网络请求 Flow,希望在遇到 IOException 时重试最多 3 次,每次间隔 2 秒;遇到其他异常时不重试,直接显示错误。请写出核心代码。

  3. 原理题:为什么 catch 操作符无法捕获 collect 块中的异常?请从 Flow 的“冷流”执行模型和操作符的挂起函数调用栈角度简述。


道友,化神四境已全部通关。你的 Flow 已臻化境,数据流在你手中如臂使指。下一境,我们将踏入协程间通信的秘境——Channel。炼虚境·初阶见。

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