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 只做纯函数转换,不做 suspend 调用,不做 I/O。

测试 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 })
}
提示:filter 内部返回 false 则丢弃该元素,不会影响下游发射。协同上游如果抛异常,下游会收到取消信号。这个特性在测试时可以用来模拟“部分数据被过滤”的场景。

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 日志
}
我曾经踩过的坑:在 transform 里错误地使用了 return,导致后面的 emit 被跳过。记住,transform 内应该用 return@transform 来结束当前元素处理,不要用 return 直接退出 lambda。否则后续元素会直接被忽略!

3.4 三个操作符的对比与选型

操作符输入→输出适用场景测试关注点
map1→1类型转换、格式化转换逻辑正确性
filter1→0/1条件过滤、白名单边界条件、空结果
transform1→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 一张图理清这章的核心

下面这张图总结了三个操作符的输入输出关系和测试策略,建议你把它存下来当备忘录。

Flow 操作符核心知识图谱 map filter transform 1 个输入 → 1 个输出 1 个输入 → 0 或 1 个输出 1 个输入 → 0~N 个输出 测试方法 flowOf + toList() 边界值 + 空结果验证 发射顺序 + 分支覆盖 assertEquals 验证 assertTrue(none {}) 列表对比法 常见陷阱 map 里做 I/O → UI 卡死 filter 边界值没测 → 漏数据 transform 误用 return 丢元素 选哪个?看你要 1→1 还是 1→0/1 还是 1→N

3.8 实战测试模板

最后分享一个我经常用的测试模板。不管测什么操作符,基本套路都一样:

  1. 准备输入数据 → 用 flowOf 或 flow { }
  2. 应用操作符 → 链式调用 map/filter/transform 等
  3. 收集结果 → .toList() 或 .toSet()
  4. 断言 → 用 JUnit 的 assert 家族

上面这些操作符,说白了就是数据管道的阀门。多用几次你就会发现,80% 的业务逻辑都能用 map、filter、transform 的组合搞定。测试时多想想边界情况(空值、异常、边界数据),基本不会出大问题。