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 后,你可以像搭水管一样接上各种中间操作符。这些操作符是 冷流 的一部分——它们只定义“怎么变”,不会立刻执行。我按用途分了四大类:
- 变换类:
map一对一映射,transform可以发射任意次,flatMapConcat/Merge/Latest做流内展平 - 过滤类:
filter按条件保留,drop/take控制个数,distinctUntilChanged去重 - 组合类:
combine合并多个流,zip配对 - 异常处理类:
catch拦截上游异常,retry重试
说说我曾经犯过的错。有一次我在 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 {}:手动 emit 最灵活,模拟 Room 返回时机绝配
- 中间操作符只管定义,不执行:放心大胆写,性能靠终端控制
- 每次 collect 都是新世界:冷流特性让回归测试用例互相独立,这是 Room + Flow 协同测试的基石