Flow 基础:从创建到消费,我踩过的坑

说到 Flow,很多人第一反应是“LiveData 的替代品”。这话对,也不全对。我个人觉得,Flow 最核心的价值是它那套完整的冷流体系——数据按需生产、中间可以任意变换、终端消费时才真正执行。今天我就把这套东西拆开揉碎了讲清楚。

一、创建 Flow:三种方式,各有场景

Flow 的创建不像 Observable 那么折腾。Kotlin 协程库直接给了三种常用手段,我用表格给你对比一下:

创建方式 典型代码 适用场景
flow { … } flow { emit(1) } 最灵活,可以自行控制 emit 时机
flowOf(…) flowOf(1, 2, 3) 少量固定数据,简单直接
.asFlow() listOf(1,2).asFlow() 集合/数组/Sequence 转成流
我个人的习惯:写回归测试验证时,flow { } 用的最多。因为需要模拟 Room DAO 返回数据的时机和延迟,手动 emit 最可控。

举个实际例子。我在项目中曾经需要模拟 Room 查询返回一个列表,用 flow { } 这样写:

fun fakeUserDao(): Flow<List<User>> = flow {
    emit(listOf(User("Alice"), User("Bob")))
    delay(100)   // 模拟数据库延迟
    emit(listOf(User("Alice"), User("Charlie")))
}

嗯,这里要注意:emit 只能在 flow { } 的 lambda 里直接调用,不能在嵌套的协程里乱用,否则会报 FlowCollector 的异常。

二、中间操作符:数据流的“管道工”

创建完 Flow 后,你可以像搭水管一样接上各种中间操作符。这些操作符是 冷流 的一部分——它们只定义“怎么变”,不会立刻执行。我按用途分了四大类:

  1. 变换类:map 一对一映射,transform 可以发射任意次,flatMapConcat/Merge/Latest 做流内展平
  2. 过滤类:filter 按条件保留,drop / take 控制个数,distinctUntilChanged 去重
  3. 组合类:combine 合并多个流,zip 配对
  4. 异常处理类:catch 拦截上游异常,retry 重试
核心记忆点:中间操作符都是 懒 的。你写一万个 map + filter,只要不调用终端操作符,数据就不会产生。这件事帮我避免过好几次性能浪费。

说说我曾经犯过的错。有一次我在 flatMapMerge 里忘记设置 concurrency 参数,结果并发量飙升,直接把测试设备的内存打爆了。后来我乖乖写了 flatMapMerge(concurrency = 4),世界安静了。

代码示例:

flow {
    emit("a"); emit("b"); emit("c")
}
.filter { it != "b" }          // 保留 a, c
.map { it.uppercase() }        // A, C
.take(2)                       // 只取前两个(A, C)
.catch { e -> emit("ERROR") } // 出错了发个默认值
.toList() // 终端操作,触发执行

三、终端操作符:触发执行的“开关”

Flow 最朴实的地方在于:没有终端操作符,一切都不会发生。这也是冷流的本质。常用的终端操作符我整理了一个速查表:

操作符 返回类型 说明
collect { } Unit 最常用,逐个消费
toList() List<T> 收集所有值到列表
first() T 拿到第一个值就停止
firstOrNull() T? 流为空时不抛异常
single() T 只允许一个值,多了抛异常
reduce { acc, v -> … } T 累加/归约
fold(initial) { acc, v -> … } R 带初始值的归约
count() Int 统计元素个数
我曾经踩过的坑:在回归测试里用 first() 拿到第一个快照就退出,结果 Room 的 Flow 在后面还有更新,导致测试只验证了初始状态。后来我改用 toList() 或者 collect 配合 take(2) 才搞定。

终端操作符通常要在协程里调用,比如 lifecycleScope.launch { myFlow.collect { … } }。但注意:不要在 flow { } 的 emit 前面使用 collect,那是死循环——还记得我说过冷流是“生产者等消费者”吗?

四、冷流特性:每次消费都是全新的开始

Flow 是个冷流,这句话是什么意思?说白了就是:数据流只在被收集的时候才生产,而且每次收集都是一次独立的从头到尾的执行。

我做个小实验:

val coldFlow = flow {
    println("producing...")
    emit(1)
    emit(2)
}

// 第一次收集
runBlocking { coldFlow.collect { println("Collect1: $it") } }
// 第二次收集
runBlocking { coldFlow.collect { println("Collect2: $it") } }

输出结果是:

producing...
Collect1: 1
Collect1: 2
producing...
Collect2: 1
Collect2: 2

看到没有?producing... 打印了两次。这就是冷流的特征:每个收集者都会触发一个独立的数据生产过程。与 LiveData 的热流(一直发,不管有没有人收)完全不同。

为什么这对回归测试很重要?
因为你可以随意创建多个测试用例分别收集同一个 Flow,每次都是干净的数据,不会互相污染。如果你用过 Observable 的 publish / connect 模式,就会明白这种“干净”有多舒服。

不过冷流也有代价——如果上游有耗时操作(比如网络请求),每次 collect 都会重新执行。所以在正式项目中,我们经常用 .stateIn() 把它转为热流。但测试时,我建议保留冷流,便于精确控制。

五、知识体系一览

下面是我画的一张简图,把 Flow 创建的三种方式、中间操作符的四个分类、终端操作符的触发、以及冷流特性串在一起。你一眼就能看清整个脉络:

Flow 基础核心知识图 flow { emit() } flowOf(…) .asFlow() 创建方式 变换 过滤 组合 异常处理 中间操作符(懒执行) 终端操作符(触发执行) collect / toList / first / reduce ... 冷流特性 每次 collect 都是独立从头执行的数据生产 核心要点 创建方式 (3种) → 中间操作符 (4类) → 终端操作符 (触发) → 冷流特性 (独立执行) 测试中利用冷流特性,每次收集都是干净的快照,方便回归验证

六、总结:测试时记住三句话

  • 创建用 flow {}:手动 emit 最灵活,模拟 Room 返回时机绝配
  • 中间操作符只管定义,不执行:放心大胆写,性能靠终端控制
  • 每次 collect 都是新世界:冷流特性让回归测试用例互相独立,这是 Room + Flow 协同测试的基石