4、Flow操作符(下):flatMapConcat、flatMapMerge、combine、zip、catch、retry等高级操作符
好,咱们接着聊Flow操作符。上一章我们把基础操作符过了一遍,今天要聊的这几个,才是真正让Flow变得强大的「重型武器」。说白了,它们能帮你处理复杂的异步数据流场景——比如多个网络请求的合并、错误恢复、数据重组等等。
我个人习惯把这类操作符分成两组:「展平型」和「合并型」。展平型解决的是「流中嵌套流」的问题,合并型解决的是「多个流如何协同」的问题。咱们一个一个来看。
4.1 展平操作符:flatMapConcat、flatMapMerge、flatMapLatest
先问一个问题:如果你有一个Flow,它的每个元素又会发射出一个新的Flow,你该怎么办?
举个例子,你有一个用户ID列表,每个ID都要去网络请求用户详情。这时候你就需要「展平」操作符了。Kotlin Flow提供了三种展平策略,我分别说说。
4.1.1 flatMapConcat:串行执行,按顺序来
flatMapConcat 的行为很直观:它会依次处理每个上游元素,等上一个内部Flow完全结束后,再处理下一个。就像排队买奶茶,一个一个来。
flowOf(1, 2, 3)
.flatMapConcat { id ->
flow {
emit("开始请求 $id")
delay(1000) // 模拟网络请求
emit("请求完成 $id")
}
}
.collect { println(it) }
// 输出顺序:
// 开始请求 1
// 请求完成 1
// 开始请求 2
// 请求完成 2
// 开始请求 3
// 请求完成 3
我在项目中遇到过这样一个场景:需要依次上传多张图片到服务器,每张图片上传完成后才能开始下一张。这时候 flatMapConcat 就是最佳选择。为什么?因为顺序很重要,后一张图片的URL可能依赖于前一张的返回结果。
4.1.2 flatMapMerge:并发执行,谁快谁先出
如果你不关心顺序,只想要最快的响应速度,那就用 flatMapMerge。它会同时启动所有内部Flow,谁先发射数据就先处理谁。
flowOf(1, 2, 3)
.flatMapMerge(concurrency = 2) { id ->
flow {
emit("开始请求 $id")
delay(1000 * id) // 模拟不同耗时
emit("请求完成 $id")
}
}
.collect { println(it) }
// 输出顺序(可能):
// 开始请求 1
// 开始请求 2
// 请求完成 1 // id=1 耗时最短
// 开始请求 3
// 请求完成 2
// 请求完成 3
注意那个 concurrency 参数,它控制最大并发数。我曾经踩过一个坑:没有设置并发数,结果同时发起几十个网络请求,直接把服务器打挂了。嗯,这里要注意,一定要根据实际情况设置合理的并发上限。
4.1.3 flatMapLatest:喜新厌旧,只取最新的
这个操作符更有意思。当上游发射新元素时,它会取消当前正在执行的内部Flow,转而处理最新的那个。说白了就是「喜新厌旧」。
flowOf(1, 2, 3)
.flatMapLatest { id ->
flow {
emit("开始请求 $id")
delay(1000)
emit("请求完成 $id")
}
}
.collect { println(it) }
// 输出:
// 开始请求 1
// 开始请求 2 // 1被取消
// 开始请求 3 // 2被取消
// 请求完成 3 // 只有3完成了
这个操作符特别适合搜索框的自动补全场景。用户每输入一个字符,你就发起一次搜索请求。但用户可能输入得很快,你不需要处理中间的结果,只需要最后一次输入的结果就够了。
- flatMapConcat:串行,保序,慢但稳
- flatMapMerge:并发,不保序,快但乱
- flatMapLatest:取消旧任务,只取最新
4.2 合并操作符:combine 和 zip
这两个操作符都是用来合并多个Flow的,但合并策略完全不同。我刚开始学的时候也经常搞混,后来用了一个比喻就记住了:combine 像「合唱」,zip 像「配对」。
4.2.1 combine:只要有新数据,就重新组合
combine 的行为是:当任意一个Flow发射新数据时,它会拿这个新数据和另一个Flow的最新数据,一起执行合并函数。有点像「合唱团」——只要有一个声部变了,整个和声就重新来一遍。
val flowA = flowOf("A1", "A2", "A3").onEach { delay(300) }
val flowB = flowOf("B1", "B2").onEach { delay(500) }
flowA.combine(flowB) { a, b -> "$a + $b" }
.collect { println(it) }
// 输出:
// A1 + B1 (300ms时A1到,B1已到)
// A2 + B1 (600ms时A2到,B1还是最新的)
// A2 + B2 (1000ms时B2到,A2是最新的)
// A3 + B2 (900ms时A3到,B2是最新的)
我在项目中用 combine 做过一个实时监控面板:一个Flow负责轮询CPU使用率,另一个Flow负责轮询内存使用率。每次任何一个指标更新,就重新计算并刷新UI。这样用户看到的数据永远是「当前最新的组合」。
4.2.2 zip:一对一配对,凑齐了才干活
zip 的行为完全不同。它会等待两个Flow都发射了数据,然后配对执行合并函数。就像「配对游戏」——必须凑齐一对,才处理一次。
val flowA = flowOf("A1", "A2", "A3").onEach { delay(300) }
val flowB = flowOf("B1", "B2").onEach { delay(500) }
flowA.zip(flowB) { a, b -> "$a + $b" }
.collect { println(it) }
// 输出:
// A1 + B1 (500ms时,A1和B1都到了)
// A2 + B2 (1000ms时,A2和B2都到了)
// 注意:A3永远不会被处理,因为B没有第三个元素了
你想想看,什么时候用 zip?比如你要同时请求用户信息和用户设置,两个接口都返回后才能展示页面。这时候 zip 就派上用场了。
| 操作符 | 触发条件 | 典型场景 |
|---|---|---|
| combine | 任一Flow有新数据 | 实时监控、搜索过滤 |
| zip | 所有Flow都有新数据 | 并行请求、数据配对 |
4.3 错误处理:catch 和 retry
Flow 里的错误处理,说实话比 RxJava 优雅多了。你不需要到处 try-catch,Flow 提供了专门的操作符来处理异常。
4.3.1 catch:捕获异常,优雅降级
catch 操作符可以捕获上游Flow中发生的异常,然后你可以选择发射一个默认值、重新抛出、或者切换到另一个备用Flow。
flow {
emit("数据1")
throw RuntimeException("网络错误")
emit("数据2") // 这行不会执行
}
.catch { e ->
println("捕获到异常: ${e.message}")
emit("默认数据") // 发射一个兜底值
}
.collect { println(it) }
// 输出:
// 数据1
// 捕获到异常: 网络错误
// 默认数据
我曾经犯过一个错误:把 catch 放在了 collect 之后。结果异常根本捕获不到,直接崩溃了。记住:catch 只能捕获它上游的异常,不能捕获下游的。所以 catch 一定要写在 collect 之前。
4.3.2 retry:失败了?再来一次
网络请求经常会失败,这时候「重试」就是最直接的解决方案。Flow 提供了 retry 操作符,可以指定重试次数和重试条件。
flow {
emit("尝试请求")
if (Math.random() > 0.5) {
throw RuntimeException("请求失败")
}
emit("请求成功")
}
.retry(retries = 3) { cause, attempt ->
println("第 ${attempt + 1} 次重试,原因: ${cause.message}")
// 返回 true 表示继续重试,false 表示放弃
attempt < 3
}
.catch { e ->
println("重试耗尽,最终失败: ${e.message}")
}
.collect { println(it) }
我个人习惯在重试时加上「退避策略」——每次重试前等待一段时间,避免对服务器造成压力。你可以用 retryWhen 操作符实现更精细的控制:
flow { /* 网络请求 */ }
.retryWhen { cause, attempt ->
// 最多重试3次,且每次等待时间递增
if (attempt < 3) {
delay(1000L * (attempt + 1)) // 1s, 2s, 3s
true // 继续重试
} else {
false // 放弃重试
}
}
4.4 知识体系总览
说了这么多,我画了一张图帮你梳理一下今天的内容。这张图展示了 Flow 高级操作符的分类和关系:
好了,这一章的内容就到这里。这些操作符在实际项目中几乎每天都会用到,尤其是网络请求和UI状态管理。你可以在自己的项目里试着用用看,遇到问题再回来翻翻这篇文章。