9、Flow的异常处理与重试:catch操作符、retry与retryWhen、onCompletion回调、try-catch在Flow中的最佳实践

Flow 在 Android 开发中越来越常见。但很多人只把它当「高级版 LiveData」用,忽略了它的异常处理能力。说实话,这挺可惜的。

我见过不少项目,Flow 一崩整个协程就炸了。原因很简单——没做异常处理。今天我们就来聊聊 Flow 的异常处理与重试机制。我会结合项目里的真实踩坑经历,帮你把这些知识点吃透。

核心要点:Flow 的异常处理不是「加个 try-catch 就完事」。你需要理解 catch 操作符的声明式思维、retry 的幂等性设计、以及 onCompletion 的收尾逻辑。这三者配合好了,你的数据流才能既健壮又优雅。

Flow 异常处理与重试知识体系 Flow 异常处理 catch 操作符 声明式异常捕获 retry / retryWhen 重试策略与条件 onCompletion 收尾与资源释放 最佳实践:catch + retryWhen + onCompletion 组合使用 避免裸 try-catch,优先使用声明式操作符

catch 操作符:声明式的异常捕获

Flow 的 catch 操作符,说白了就是「如果上游出错了,我来兜底」。它和 try-catch 最大的区别在于——catch 是声明式的,它只捕获上游的异常,不影响下游。

flow {
    emit(1)
    emit(2)
    throw RuntimeException("出错了")
    emit(3) // 这行不会执行
}.catch { e ->
    emit(-1) // 发射一个兜底值
    println("捕获异常: ${e.message}")
}.collect { value ->
    println("收到: $value")
}
// 输出: 收到: 1, 收到: 2, 收到: -1, 捕获异常: 出错了

我在项目中遇到过这样的场景:网络请求返回的数据流,偶尔会因为 JSON 解析失败而中断。用 catch 操作符捕获后,发射一个默认的空数据对象,UI 层就能正常展示「暂无数据」而不是直接崩溃。

小技巧:catch 操作符内部可以重新抛出异常,或者发射兜底数据后结束 Flow。但注意——catch 之后不能再 emit 原始数据流中的值,因为上游已经终止了。

retry 与 retryWhen:让 Flow 学会「再来一次」

网络请求失败后重试,这是很常见的需求。Flow 提供了 retry 和 retryWhen 两个操作符。retry 简单粗暴——重试 N 次。retryWhen 则更灵活,你可以根据异常类型、重试次数、甚至当前时间来决定是否重试。

// 简单重试:最多重试3次
flow {
    emit(fetchDataFromNetwork())
}.retry(3)
 .catch { e ->
    emit(cachedData)
 }.collect { data ->
    updateUI(data)
 }

// 条件重试:只在特定异常时重试,且每次间隔递增
flow {
    emit(fetchDataFromNetwork())
}.retryWhen { cause, attempt ->
    // 如果是网络超时,且重试次数小于5次,就重试
    if (cause is SocketTimeoutException && attempt < 5) {
        delay(1000L * (attempt + 1)) // 指数退避
        true // 返回 true 表示重试
    } else {
        false // 返回 false 表示不重试,异常会继续传播
    }
}.collect { data ->
    updateUI(data)
}

我记得有一次做 IM 消息推送的接入,服务端偶尔会返回 503。直接用 retry(3) 重试,结果三次重试间隔太短,全失败了。后来改成 retryWhen,配合指数退避策略,成功率从 60% 提升到了 95% 以上。

注意:retry 和 retryWhen 只会在 Flow 抛出异常时触发。如果 Flow 正常完成,它们不会做任何事。另外,重试会重新执行整个上游 Flow 的代码,包括副作用操作。如果你的 Flow 里有网络请求、数据库写入等操作,要确保它们是幂等的。

onCompletion 回调:收尾工作的正确姿势

onCompletion 在 Flow 完成时触发——不管是正常完成、异常终止、还是被取消。它有点像 Java 里的 finally 块,但更优雅。

flow {
    emit("数据1")
    emit("数据2")
}.onCompletion { cause ->
    // cause 为 null 表示正常完成
    // cause 不为 null 表示异常终止或被取消
    if (cause == null) {
        println("Flow 正常完成")
    } else {
        println("Flow 异常终止: ${cause.message}")
    }
    // 在这里释放资源、关闭数据库连接等
}.collect { value ->
    println("收到: $value")
}

我在项目中习惯把 onCompletion 和 catch 搭配使用。catch 负责处理异常并决定是否继续,onCompletion 负责收尾——比如关闭加载动画、释放资源、记录日志等。这样职责清晰,代码也容易维护。

关键区别:catch 可以「拦截」异常并让 Flow 继续发射数据;onCompletion 只是「通知」你 Flow 结束了,它不能阻止异常传播,也不能再发射数据。

try-catch 在 Flow 中的最佳实践

虽然 Flow 提供了 catch 操作符,但有些场景下 try-catch 仍然有用武之地。关键在于——用对地方。

什么时候用 try-catch?

  • 在 flow {} 构建器内部,对某个具体的 emit 操作做局部异常处理
  • 在 collect 终端操作中,捕获下游的异常
  • 在操作符内部(如 map、filter)处理非 Flow 相关的异常

什么时候用 catch 操作符?

  • 需要统一处理上游所有异常时
  • 需要在异常后继续发射数据时
  • 需要将异常转换为另一种数据类型时
// 好的实践:try-catch 处理局部异常
flow {
    try {
        val data = fetchFromNetwork()
        emit(data)
    } catch (e: IOException) {
        // 网络异常,尝试从本地缓存读取
        val cached = readFromCache()
        emit(cached)
    }
}.catch { e ->
    // 其他异常(如缓存读取失败)统一处理
    emit(defaultData)
    println("未预期的异常: ${e.message}")
}.collect { data ->
    updateUI(data)
}

// 不好的实践:用 try-catch 包裹整个 collect
try {
    myFlow.collect { value ->
        process(value)
    }
} catch (e: Exception) {
    // 这样会丢失 Flow 内部的异常上下文
    // 而且无法在异常后继续处理
}

我曾经在一个项目里看到有人用 try-catch 包裹了整个 collect 调用。结果 Flow 内部抛异常后,整个收集过程就终止了,再也收不到后续数据。改成 catch 操作符后,问题迎刃而解。

我的建议:优先使用 catch 操作符处理 Flow 的异常。try-catch 只用在「必须同步处理」的局部代码块中。记住——Flow 是声明式的数据流,用声明式的异常处理方式更符合它的设计哲学。

组合使用:一个完整的例子

最后,我们来看一个综合例子。这个例子模拟了网络请求数据流,包含了异常捕获、条件重试、以及完成回调。

fun fetchUserData(): Flow<User> = flow {
    val response = apiService.getUser()
    emit(response.data)
}.retryWhen { cause, attempt ->
    // 网络错误时重试,最多3次,间隔递增
    if (cause is IOException && attempt < 3) {
        delay(2000L * (attempt + 1))
        true
    } else {
        false
    }
}.catch { e ->
    // 重试失败后,发射缓存数据
    val cached = localCache.getUser()
    if (cached != null) {
        emit(cached)
    } else {
        // 缓存也没有,抛出业务异常
        throw BusinessException("获取用户数据失败", e)
    }
}.onCompletion { cause ->
    // 无论成功失败,都记录日志
    if (cause == null) {
        log("用户数据加载成功")
    } else {
        log("用户数据加载失败: ${cause.message}")
    }
}

// 使用
viewModelScope.launch {
    fetchUserData().collect { user ->
        _uiState.value = UiState.Success(user)
    }
}

这个模式我在多个项目里用过,效果很好。它把异常处理、重试、缓存降级、日志记录都组织在了一起,逻辑清晰,也容易测试。

嗯,关于 Flow 的异常处理与重试,今天就聊到这里。记住三个关键词:catch 兜底、retryWhen 灵活重试、onCompletion 收尾。把它们用好,你的数据流会健壮很多。


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