21、Flow与错误处理:catch操作符的深度使用、retry与retryWhen、自定义错误处理策略

说实话,Flow 的错误处理这块,我当年刚接触时也踩过不少坑。你想想看,响应式编程里数据像水流一样源源不断,但水流里万一混进了脏东西怎么办?直接断流?还是过滤掉?或者重启水泵?

嗯,这就是我们今天要聊的核心——Flow 的错误处理。我个人习惯把错误处理分成三个层次:捕获、重试、策略化。咱们一个一个来。

一、catch 操作符:错误捕获的正确姿势

先看最基础的。Flow 里如果发生异常,不处理的话整个流就挂了。但用 catch 操作符,你可以在异常发生时做补救。

flow {
    emit(1)
    emit(2)
    throw RuntimeException("出错了")
    emit(3) // 这行不会执行
}.catch { e ->
    emit(-1) // 用默认值替代
    println("捕获到异常: ${e.message}")
}.collect { value ->
    println(value)
}
// 输出: 1, 2, -1, 捕获到异常: 出错了

这里有个关键点:catch 只能捕获它上游的异常。什么意思?就是 catch 之前的操作符和 flow builder 里的异常它能抓到,但 catch 之后的就不归它管了。

重要原则:catch 一定要放在错误源的下游,但放在 collect 的上游。如果你把 catch 放在最前面,那它什么都抓不到。

我在项目中遇到过这样的场景:用户登录时,网络请求失败,我不想直接让整个页面白屏。用 catch 发射一个默认的 UI 状态,用户体验就好多了。

二、retry 与 retryWhen:自动重试机制

有些错误是暂时的,比如网络抖动。直接放弃太可惜了。这时候 retryretryWhen 就派上用场了。

2.1 retry:简单粗暴的重试

flow {
    emit("请求数据...")
    throw IOException("网络连接失败")
}.retry(3) // 最多重试3次
  .catch { e -> emit("最终失败: ${e.message}") }
  .collect { println(it) }

retry(3) 的意思是:如果上游抛出异常,就重新订阅整个流,最多重试 3 次。如果 3 次都失败,异常会继续往下传,被 catch 捕获。

小技巧:retry 默认会立即重试。如果网络不好,建议配合延迟使用。后面会讲。

2.2 retryWhen:精细化控制重试

retryWhen 给了你更大的控制权。你可以根据异常类型、重试次数、甚至当前时间来决定是否重试。

flow {
    emit("开始请求")
    throw IOException("超时")
}.retryWhen { cause, attempt ->
    // cause: 当前异常
    // attempt: 已经重试的次数(从0开始)
    if (cause is IOException && attempt < 3) {
        delay(1000L * (attempt + 1)) // 递增延迟:1s, 2s, 3s
        println("第${attempt + 1}次重试...")
        true // 返回true表示继续重试
    } else {
        println("不满足重试条件,放弃")
        false // 返回false表示停止重试
    }
}.catch { e ->
    println("最终失败: ${e.message}")
}.collect { println(it) }

我曾经用 retryWhen 做过一个指数退避的重试策略。第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒……这样既不会把服务器打爆,又能提高成功率。

注意:retryWhen 的 lambda 里如果抛出异常,会立即终止重试,并把新异常往下传。所以记得在 lambda 内部做好异常处理。

三、自定义错误处理策略

实际项目中,错误处理往往不是简单的 catch 或 retry 能搞定的。你需要一套完整的策略。我一般会封装一个通用的错误处理函数。

// 自定义错误处理策略
sealed class ErrorStrategy {
    data class Retry(val maxRetries: Int, val delayMs: Long) : ErrorStrategy()
    data class Fallback(val defaultValue: Any) : ErrorStrategy()
    object Propagate : ErrorStrategy() // 直接抛给上层
}

// 扩展函数
fun <T> Flow<T>.handleErrors(
    strategy: ErrorStrategy
): Flow<T> = when (strategy) {
    is ErrorStrategy.Retry -> this.retryWhen { cause, attempt ->
        if (attempt < strategy.maxRetries) {
            delay(strategy.delayMs)
            true
        } else false
    }
    is ErrorStrategy.Fallback -> this.catch {
        emit(strategy.defaultValue as T)
    }
    is ErrorStrategy.Propagate -> this // 不做处理
}

// 使用示例
fun loadData(): Flow<String> = flow {
    emit("数据")
    throw RuntimeException("加载失败")
}

fun main() {
    loadData()
        .handleErrors(ErrorStrategy.Retry(maxRetries = 2, delayMs = 500))
        .catch { println("最终还是失败了") }
        .collect { println(it) }
}

这样做的好处是,业务代码里只需要调用 handleErrors,传入不同的策略即可。代码干净,逻辑清晰。

四、知识体系总览

下面这张图是我自己总结的 Flow 错误处理全景图。你可以看到,从异常发生到最终处理,有三条路径:捕获、重试、策略化。

Flow 错误处理知识体系 异常发生 catch 捕获 retry / retryWhen 自定义策略 发射默认值 / 转换异常 重试次数 / 延迟策略 Retry / Fallback / Propagate 稳定的 UI 状态 / 用户友好提示

五、避坑指南与最佳实践

最后,分享几个我实战中总结的经验:

  • catch 不要滥用:不是所有异常都要吞掉。对于不可恢复的错误(比如空指针、参数错误),直接让应用崩溃反而更容易定位问题。
  • 重试要有上限:我曾经见过一个同事没设重试上限,结果用户手机在弱网环境下无限重试,电量哗哗地掉。后来加了最大重试次数和延迟,问题就解决了。
  • 区分业务异常和技术异常:业务异常(比如用户名不存在)用 catch 发射错误状态;技术异常(比如网络超时)用 retry 重试。混在一起处理,代码会变得很难维护。
  • 测试你的错误处理:写单元测试时,专门构造异常场景,看看 catch 和 retry 是否按预期工作。我习惯用 flowOf 配合 flow { throw ... } 来模拟。

核心总结:catch 负责「出错了怎么办」,retry 负责「出错了再来一次」,自定义策略负责「不同错误不同处理」。三者结合,你的 Flow 就能在各种异常场景下稳如泰山。

好了,关于 Flow 的错误处理就聊到这里。记住,好的错误处理不是让程序不出错,而是出错后还能优雅地继续运行。这才是响应式编程的魅力所在。

公众号:蓝海资料掘金营,微信deep3321