9、Flow的异常处理与重试:catch操作符、retry与retryWhen、onCompletion回调、try-catch在Flow中的最佳实践
Flow 在 Android 开发中越来越常见。但很多人只把它当「高级版 LiveData」用,忽略了它的异常处理能力。说实话,这挺可惜的。
我见过不少项目,Flow 一崩整个协程就炸了。原因很简单——没做异常处理。今天我们就来聊聊 Flow 的异常处理与重试机制。我会结合项目里的真实踩坑经历,帮你把这些知识点吃透。
核心要点:Flow 的异常处理不是「加个 try-catch 就完事」。你需要理解 catch 操作符的声明式思维、retry 的幂等性设计、以及 onCompletion 的收尾逻辑。这三者配合好了,你的数据流才能既健壮又优雅。
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