Flow操作符进阶:flatMapConcat、flatMapMerge、flatMapLatest

这三个操作符,说实话,是处理嵌套 Flow 的核心武器。我个人刚接触时也被绕晕过——明明都是把内层 Flow 打平,为什么跑起来效果天差地别?

别急,我们先画一张图,把它们的逻辑铺开,然后再手敲代码验证。

flatMapConcat / flatMapMerge / flatMapLatest 流程对比 上游 Flow (A, B, C) flatMapConcat flatMapMerge flatMapLatest A Flow → A1, A2 B Flow → B1, B2 C Flow → C1, C2 输出顺序:A1,A2,B1,B2,C1,C2 A Flow → A1, A2 B Flow → B1, B2 C Flow → C1, C2 输出顺序:交错(并发) A Flow → A1, A2 (取消) B Flow → B1, B2 (取消) C Flow → C1, C2 ✅ 输出:仅 C1, C2 💡 每个上游元素会生成一个内层 Flow(A1,A2...),三个操作符决定这些内层 Flow 如何发射到下游。

1. flatMapConcat——老老实实排队

这个操作符的做法很简单:等前一个内层 Flow 全部发射完,再启动下一个。你想想看,就像银行柜台只有一个窗口,前面的人没办完,后面的人就得一直等着。

核心行为:串行执行,严格有序。内层 Flow 的发射顺序与上游元素顺序完全一致,且不会出现并发。

我在项目中用过一次:从数据库分批读取用户列表,每批返回一个 Flow,然后把这些 Flow 依次 merge 成一个。当时用了 flatMapConcat,结果发现用户体验很糟糕——第二批的用户必须等第一批全部加载完才能显示。嗯,这就是串行的代价。

代码示例:

flowOf("A", "B", "C")
    .flatMapConcat { value ->
        flow {
            for (i in 1..2) {
                emit("${value}$i")
                delay(100) // 模拟耗时
            }
        }
    }
    .collect { println(it) }
// 输出:A1 A2 B1 B2 C1 C2(严格顺序,间隔100ms)

使用场景:数据依赖性强,下游必须按上游顺序处理,例如:顺序写入日志文件、按序处理事务。

2. flatMapMerge——大家一起上

flatMapMerge 正好相反:上游每来一个元素,立即启动内层 Flow,所有内层 Flow 并发执行。输出顺序完全看谁跑得快,也就是“先完成先输出”。

我记得有一次做实时数据聚合,从三个不同 API 拉取行情数据。如果用 flatMapConcat,用户得等一分钟才看到所有报价,改用 flatMapMerge 后,哪个接口先返回就先显示,体验直接起飞。

并发控制:默认并发数是 DEFAULT_CONCURRENCY(等于 CPU 核数),也可以显式指定,比如 flatMapMerge(concurrency = 2) 最多同时跑 2 个内层 Flow。

代码示例:

flowOf("X", "Y", "Z")
    .flatMapMerge(concurrency = 2) { value ->
        flow {
            emit("${value} start")
            delay(200)
            emit("${value} done")
        }
    }
    .collect { println(it) }
// 可能输出(并发):X start, Y start, X done, Z start, Y done, Z done
// 注意:因为 concurrency=2,Z 必须等 X 或 Y 有空位才能启动

避坑指南:我曾经在无限制并发时(不设 concurrency)遇到过 OOM。上游元素多且每个内层 Flow 都吃内存,一定要设置合理的 concurrency。

3. flatMapLatest——只要最新的

这个操作符很有意思:当前一个内层 Flow 还没跑完、新元素又来了,直接取消旧的,只处理最新的那个。说白了就是“喜新厌旧”。

我刚学的时候觉得它有点暴力,但后来做搜索联想输入就发现它太香了:用户每敲一个字就发请求,如果前面请求还没回来,直接取消,只取最新的结果。这不正是我们需要的吗?

行为本质:当新元素到达时,立即 cancel 当前正在运行的内层 Flow(协程取消),然后启动新 Flow。如果旧 Flow 已经结束,则正常启动新 Flow。

代码示例:

flowOf("query1", "query2", "query3")
    .flatMapLatest { query ->
        flow {
            emit("start $query")
            delay(300)  // 模拟网络请求
            emit("result $query")
        }
    }
    .collect { println(it) }
// 假设上游发射间隔 < 300ms,则输出可能为:
// start query1
// (收到 query2,取消 query1 的 Flow)
// start query2
// (收到 query3,取消 query2 的 Flow)
// start query3
// result query3  (只有最后一个完整执行)

注意事项:flatMapLatest 依赖协程的取消协作机制。如果内层 Flow 中的代码没有检查取消状态(比如纯 CPU 计算),可能无法及时取消。建议在耗时循环中加入 ensureActive() 或 yield()。

4. 三兄弟对比一览

光看文字可能还差点意思,我整理了一个表格,把关键指标列出来,方便你面试或做技术选型时直接参考。

特性 flatMapConcat flatMapMerge flatMapLatest
执行方式 串行 并发(可控制度) 串行(丢弃旧流)
输出顺序 严格按上游顺序 无序(按完成时间) 只输出最后一个Flow的结果
并发数 1(无并发) 可配置(默认CPU核数) 1(但会取消旧任务)
内存压力 低(一次一个Flow) 较高(多个Flow共存) 低(只保留最新)
典型场景 事务、日志、依赖运算 并行网络请求、批量处理 搜索联想、实时传感器、UI刷新

5. 经验总结与思考

说了这么多,我再用几句话总结一下个人心得:

  • 需要严格顺序时,无脑选 flatMapConcat。但要注意性能瓶颈——如果内层 Flow 都很慢,下游会被阻塞。
  • 想提速且不关心顺序时,用 flatMapMerge。记得设置合理的 concurrency,否则容易打满线程池或撑爆内存。
  • 场景天然适合“只取最新”时,优先考虑 flatMapLatest。它比手动 cancel 再启动要优雅得多。

我曾经在一个图片加载库中犯过错误:用了 flatMapConcat 去并发加载,结果用户快速滑动列表时,图片加载顺序完全错位。后来换成 flatMapLatest 才解决——每次只加载当前可见项的最新请求,旧请求自动取消,内存和 CPU 都省了。

一句话口诀:排队的用 concat,并发的用 merge,喜新厌旧的用 latest。实战中多搭配 catch 和 retry 使用,效果更稳。

好了,这三个操作符的底层原理其实都一样——都是将 Flow<Flow<T>> 展平成 Flow<T>,区别只在于内部协程的调度策略和取消时机。理解这个本质,你就能自己推导出它们的行为了。