3. Flow操作符基础:map、filter、transform 的使用与测试
说实话,刚接触 Flow 的时候,我总会拿它和 RxJava 做对比。后来发现一条铁律:操作符是 Flow 的灵魂。今天咱们就聊聊最基础的三个——map、filter、transform。别看它们简单,用不对照样掉坑。
3.1 map:一对一转换,别在里面搞副作用
map 的职责很单纯:输入一个值,输出一个值。比如把网络返回的 User 对象转成 UI 层需要的 UserUiModel:
fun getUserFlow(): Flow<User> = flow {
emit(User("Alice", 28))
emit(User("Bob", 35))
}
fun Flow<User>.toUiModel(): Flow<UserUiModel> =
map { user -> UserUiModel(name = user.name, ageLabel = "${user.age}岁") }
但要注意,map 是 同步执行的。我在项目里见过有人在 map 里写网络请求——结果 UI 线程直接被卡死。你说犯这种低级错误是不是很冤?
测试 map 就更容易了。用 flowOf 造数据,然后用 toList() 验证:
@Test
fun `map 应该正确转换 User 为 UserUiModel`() = runTest {
val userFlow = flowOf(User("Alice", 28), User("Bob", 35))
val result = userFlow.map { UserUiModel(it.name, "${it.age}岁") }.toList()
assertEquals(2, result.size)
assertEquals("Alice", result[0].name)
assertEquals("28岁", result[0].ageLabel)
}
3.2 filter:保留你需要的数据
filter 的作用就是一句大白话:要还是不要? 比如我只想看成年人:
val adultFlow = userFlow.filter { it.age >= 18 }
测试 filter 也没什么玄学,关键要覆盖边界值。我记得有一次生产环境出了问题,就是因为没测试 18 岁这条线:
@Test
fun `filter 应该只保留年龄≥18 的用户`() = runTest {
val flow = flowOf(
User("Tom", 17),
User("Jerry", 18),
User("Spike", 25)
)
val adults = flow.filter { it.age >= 18 }.toList()
assertEquals(2, adults.size)
assertTrue(adults.none { it.age < 18 })
}
3.3 transform:自由地发射 0 到 N 个数据
transform 是我个人最喜欢的操作符。和 map 不同,它让你自己控制发射的次数。比如一个事件可能产生多条日志:
data class AppEvent(val type: String, val message: String)
fun Flow<AppEvent>.expandLogs(): Flow<String> = transform { event ->
emit("[EVENT] ${event.type}")
if (event.message.length > 50) {
emit("[WARN] 消息过长,已截断")
}
emit("[RAW] ${event.message.take(50)}")
}
测试 transform 时,我会用列表对比法:把预期发射的完整列表写出来,然后和实际结果一一对应。曾经有一次测试没通过,我发现是因为 emit 顺序搞反了——先发射了日志摘要再发射事件头。
@Test
fun `transform 应该按顺序发射多条日志`() = runTest {
val event = AppEvent("LOGIN", "用户登录成功")
val result = flowOf(event)
.expandLogs()
.toList()
assertEquals(3, result.size)
assertEquals("[EVENT] LOGIN", result[0])
assertEquals("[RAW] 用户登录成功", result[1]) // 注意顺序
// 这里假设 message 长度小于 50,所以没有 WARN 日志
}
3.4 三个操作符的对比与选型
| 操作符 | 输入→输出 | 适用场景 | 测试关注点 |
|---|---|---|---|
| map | 1→1 | 类型转换、格式化 | 转换逻辑正确性 |
| filter | 1→0/1 | 条件过滤、白名单 | 边界条件、空结果 |
| transform | 1→0~N | 拆分数据、附加日志、条件发射 | 发射顺序、分支覆盖 |
怎么选?其实很简单:
- 只要一对一的纯转换,用 map。
- 只要按条件扔掉一些元素,用 filter。
- 需要自由控制发射次数,或者做更复杂的逻辑(比如判断后才决定是否发射)——欢迎来到 transform 的世界。
3.5 串联使用:操作符不是孤岛
实际项目中,你不可能只用一个操作符。最常见的是先 filter 掉脏数据,再 map 转换格式。比如如下处理用户输入:
flowOf("abc", "", "def", " ", "ghi")
.filter { it.isNotBlank() }
.map { it.trim() }
.collect { println(it) } // 只输出 "abc", "def", "ghi"
测试这种组合链时,我建议分阶段验证:先单独测试 filter 步骤,再测试 map 步骤。如果发现结果不对,用 toList() 打印中间值——这是最简单粗暴的调试法。
3.6 避坑:操作符的异常处理
操作符内部如果抛异常,整个 Flow 会立即取消。我早期项目里就在 map 里写了除零逻辑,结果测试死活过不了。解决方案:用 catch 操作符包裹,或者用 transform 加上 try-catch:
flowOf(1, 2, 0, 3)
.map { 10 / it } // 会在 0 处崩溃
.catch { e -> emit(-1) }
.collect { println(it) } // 输出 10, 5, -1(0 被拦截了)
测试异常路径时,用 flowOf 故意制造一个会失败的数据:
@Test
fun `异常应该被 catch 处理并发射默认值`() = runTest {
val result = flowOf(1, 2, 0, 3)
.map { 10 / it }
.catch { emit(-1) }
.toList()
assertEquals(listOf(10, 5, -1), result)
}
flowOf 构造简单的输入数据,比 mock 一个复杂的 Flow 源要快得多也清楚得多。我日常写测试,80% 都用 flowOf 搞定。3.7 一张图理清这章的核心
下面这张图总结了三个操作符的输入输出关系和测试策略,建议你把它存下来当备忘录。
3.8 实战测试模板
最后分享一个我经常用的测试模板。不管测什么操作符,基本套路都一样:
- 准备输入数据 → 用
flowOf或flow { } - 应用操作符 → 链式调用 map/filter/transform 等
- 收集结果 →
.toList()或.toSet() - 断言 → 用 JUnit 的 assert 家族
上面这些操作符,说白了就是数据管道的阀门。多用几次你就会发现,80% 的业务逻辑都能用 map、filter、transform 的组合搞定。测试时多想想边界情况(空值、异常、边界数据),基本不会出大问题。