【Kotlin 协程修仙录 · 元婴境 · 中阶】 | 操作符真解:Flow 中间操作符与数据流变幻术

前言

元婴初成,你已能创造 Flow,感知冷流的懒加载韵律。你能用 flow {} 发射数据,用 collect 收集结果。但此刻的 Flow,还只是一条笔直的管道------数据从入口流入,原封不动地从出口流出。

真实世界的需求远比这复杂:

  • 后端返回的用户列表里有 100 条数据,我只需要名字包含'张'的前 5 条。
  • 搜索接口返回的 JSON 里嵌套了太多无用字段,我只想提取其中的 idtitle
  • 用户疯狂点击刷新,我只想处理最后一次请求的结果,之前的全扔掉。
  • 网络请求可能失败,我想失败后自动重试 3 次。

如果每遇到一个需求都要去修改 flow {} 块的内部代码,Flow 就失去了它作为响应式框架的优雅。

Flow 的真正威力,藏在一系列中间操作符 里。它们就像流水线上的工位,每个工位对数据做一点加工,最后输出成品。你只需要像搭积木一样,把这些操作符串联起来,就能构建出复杂的数据处理管道。

本讲是元婴境的中阶修炼。你将:

  • 掌握 mapfiltertransform 等核心变换操作符。
  • 彻底搞懂 flatMapConcatflatMapMergeflatMapLatest 三兄弟的区别。
  • 学会用 catch 优雅处理 Flow 中的异常。
  • 理解 onCompletiononStartonEach 等生命周期操作符。

准备好让你的数据流变幻莫测了吗?我们开始。

千曲 而后晓声,观千剑 而后识器。虐它千百遍 方能通晓其真意


操作符全景图:Flow 管道的积木块

Flow 的操作符分为两大类:

  • 中间操作符 :作用于 Flow,返回一个新的 Flow。它们是懒加载 的,仅仅声明了数据处理逻辑,在 collect 时才真正执行。
  • 终端操作符 :触发 Flow 开始执行的挂起函数,如 collecttoListfirst
flowchart LR subgraph Upstream[上游 Flow] S[数据源] end subgraph Intermediate[中间操作符 懒加载] direction LR Map[map] Filter[filter] FlatMap[flatMap] Catch[catch] OnEach[onEach] end subgraph Downstream[下游 Flow] T[终端操作符 collect] end S --> Map --> Filter --> FlatMap --> Catch --> OnEach --> T style Upstream fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px style Intermediate fill:#fff3e0,stroke:#f57c00,stroke-width:2px style Downstream fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style S fill:#c8e6c9 style Map fill:#ffb74d style Filter fill:#ffb74d style FlatMap fill:#ffb74d style Catch fill:#ffb74d style OnEach fill:#ffb74d style T fill:#90caf9

本讲重点讲解中间操作符。它们可以被无限串联,每个操作符只做一件事,符合单一职责原则。


基础变换三兄弟:mapfiltertransform

map:一对一变换

map 是最常用的操作符。它接收上游的每一个值,应用一个变换函数,将结果发射给下游。

kotlin 复制代码
flow {
    emit(1)
    emit(2)
    emit(3)
}.map { value ->
    "Number: $value"
}.collect { string ->
    println(string)
}
// 输出:
// Number: 1
// Number: 2
// Number: 3

map 像流水线上的贴标机------每个经过的产品都被贴上一张标签,数量不变。

flowchart LR classDef upItem fill:#c8e6c9,stroke:#2e7d32 classDef mapNode fill:#fff3e0,stroke:#f57c00 classDef downItem fill:#90caf9,stroke:#1976d2 Note1["🔹上游:原始数据流"]:::upItem U1[1] --> U2[2] --> U3[3] Note2["🔸map变换"]:::mapNode M1["map { it * 2 }"] Note3["🔷下游:变换输出"]:::downItem D1[2] --> D2[4] --> D3[6] U1 --> M1 --> D1 U2 --> M1 --> D2 U3 --> M1 --> D3 class U1,U2,U3 upItem class M1 mapNode class D1,D2,D3 downItem

filter:条件过滤

filter 根据条件决定哪些值可以继续向下游传递。不满足条件的值会被丢弃。

kotlin 复制代码
flow {
    emit(1)
    emit(2)
    emit(3)
    emit(4)
}.filter { value ->
    value % 2 == 0 // 只保留偶数
}.collect { even ->
    println(even)
}
// 输出:
// 2
// 4

filter 像质检员------合格的产品放行,不合格的剔除。

transform:万能变换

mapfilter 的局限性在于它们都是一对一一对零 的变换。如果你需要一对多 (例如将一个字符串拆分为多个字符发射),就需要 transform

kotlin 复制代码
flow {
    emit("Hello")
    emit("World")
}.transform { word ->
    // 可以多次 emit,甚至不 emit
    word.forEach { char ->
        emit(char.toString())
    }
}.collect { char ->
    println(char)
}
// 输出:H, e, l, l, o, W, o, r, l, d

transform 是最灵活的中间操作符,理论上可以替代任何其他操作符。但它代码量稍多,能用 map/filter 时优先用它们。


flatMap 家族:流的扁平化魔法

这是 Flow 操作符中最容易混淆,也最强大的部分。

当你有一个 Flow,其中的每个值本身又会产生一个新的 Flow 时,你就需要 flatMap 来将"流中流"扁平化为单一流。

flatMapConcat:顺序拼接

flatMapConcat 将内部 Flow 按顺序拼接。它会等待第一个内部 Flow 完全结束后,再开始收集第二个。

kotlin 复制代码
fun getFlow(id: Int): Flow<String> = flow {
    emit("$id: A")
    delay(100)
    emit("$id: B")
}

flow {
    emit(1)
    emit(2)
}.flatMapConcat { id ->
    getFlow(id)
}.collect { value ->
    println(value)
}
// 输出顺序(严格):
// 1: A
// 1: B
// 2: A
// 2: B
sequenceDiagram participant Upstream as 上游 participant FlatMap as flatMapConcat participant Inner1 as 内部流1 participant Inner2 as 内部流2 participant Downstream as 下游 Upstream->>FlatMap: emit(1) FlatMap->>Inner1: 创建内部流1 Inner1->>Downstream: 1:A Inner1->>Downstream: 1:B Inner1-->>FlatMap: 完成 Upstream->>FlatMap: emit(2) FlatMap->>Inner2: 创建内部流2 Inner2->>Downstream: 2:A Inner2->>Downstream: 2:B Inner2-->>FlatMap: 完成

flatMapMerge:并发合并

flatMapMerge同时收集 所有内部 Flow,并将它们发射的值交错 发送给下游。你可以通过 concurrency 参数限制最大并发数。

kotlin 复制代码
flow {
    emit(1)
    emit(2)
}.flatMapMerge { id ->
    getFlow(id)
}.collect { value ->
    println(value)
}
// 可能的输出(交错):
// 1: A
// 2: A
// 1: B
// 2: B

适用场景:需要并发执行多个网络请求,且不关心结果顺序时。

flatMapLatest:只取最新

flatMapLatest 的行为是:当上游发射新值时,如果上一个内部 Flow 还未完成,则取消它,只收集最新的内部 Flow。

这是实现实时搜索的关键操作符。

kotlin 复制代码
flow {
    emit("K")      // 被取消
    delay(50)
    emit("Kot")    // 被取消
    delay(50)
    emit("Kotlin") // 最终只收集这个
}.flatMapLatest { query ->
    searchApi(query) // 返回 Flow
}.collect { result ->
    println("搜索结果:$result")
}

```mermaid

flatMap 三兄弟对比表

操作符 行为 内部流执行顺序 适用场景
flatMapConcat 顺序拼接 严格串行 顺序敏感的批量操作
flatMapMerge 并发合并 并发,交错 并发请求,不关心顺序
flatMapLatest 只取最新 新值到来时取消旧流 实时搜索、实时刷新

异常处理:catch 操作符

在 Flow 的世界里,异常处理有两种方式:

  1. collect 处用 try-catch(终端处理)。
  2. 在管道中用 catch 操作符(中间处理)。

catch 操作符可以捕获上游发生的任何异常,并允许你发射替代值或重新抛出。

kotlin 复制代码
flow {
    emit(1)
    throw RuntimeException("炸了")
    emit(2) // 不会执行
}.catch { e ->
    println("捕获异常:${e.message}")
    emit(-1) // 发射替代值
}.collect { value ->
    println(value)
}
// 输出:
// 1
// 捕获异常:炸了
// -1

关键特性

  • catch 只能捕获上游 的异常。它无法捕获下游(如 collect 块内部)的异常。
  • 你可以在 catch 中通过 emit 发射替代值,让流继续。
flowchart LR subgraph Upstream[上游] A[发射 1] --> B[抛出异常] end subgraph Catch[catch 操作符] C[捕获异常<br>emit -1] end subgraph Downstream[下游] D[collect 收到 1 和 -1] end A --> C --> D B --> C style Upstream fill:#ffcdd2,stroke:#b71c1c style Catch fill:#fff3e0,stroke:#f57c00 style Downstream fill:#c8e6c9,stroke:#2e7d32 style B fill:#ef9a9a style C fill:#ffb74d

生命周期操作符:onStartonEachonCompletion

这些操作符让你能在 Flow 的各个生命周期节点插入副作用,例如显示/隐藏 Loading、打印日志。

操作符 触发时机 典型用途
onStart 在 Flow 开始被收集之前 显示 Loading 动画
onEach 每发射一个值之前 日志记录、埋点
onCompletion Flow 完成或异常结束之后 隐藏 Loading、资源清理
kotlin 复制代码
flow {
    delay(500)
    emit(1)
    emit(2)
}.onStart {
    println("开始加载...")
    // 可以 emit 额外值,例如 emit("Loading")
}.onEach { value ->
    println("即将发射:$value")
}.onCompletion { cause ->
    if (cause == null) {
        println("流正常完成")
    } else {
        println("流异常结束:${cause.message}")
    }
}.collect { value ->
    println("收集到:$value")
}
// 输出:
// 开始加载...
// 即将发射:1
// 收集到:1
// 即将发射:2
// 收集到:2
// 流正常完成
timeline title Flow 生命周期与操作符触发顺序 onStart : 流开始前 flow 块 : 开始生产 onEach : 每个值发射前 collect : 每个值被收集 onCompletion : 流结束后

实战:用 Flow 操作符实现优雅的搜索

让我们将本讲学到的操作符串联起来,实现一个完整的搜索功能。

kotlin 复制代码
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

class SearchViewModel(
    private val searchRepo: SearchRepository
) : ViewModel() {

    private val queryFlow = MutableStateFlow("")
    
    var uiState by mutableStateOf(SearchUiState())
        private set

    init {
        viewModelScope.launch {
            queryFlow
                .debounce(300) // 去抖动
                .filter { query ->
                    query.length >= 2 || query.isEmpty() // 空或至少2字符
                }
                .distinctUntilChanged() // 去重
                .flatMapLatest { query ->
                    if (query.isEmpty()) {
                        flow { emit(emptyList()) } // 空查询直接返回空
                    } else {
                        // 开始搜索,先显示 Loading
                        searchFlow(query)
                            .onStart { uiState = uiState.copy(isLoading = true) }
                            .onCompletion { uiState = uiState.copy(isLoading = false) }
                            .catch { e -> emit(emptyList()) } // 异常时返回空
                    }
                }
                .collect { results ->
                    uiState = uiState.copy(results = results)
                }
        }
    }

    private fun searchFlow(query: String): Flow<List<String>> = flow {
        // 模拟网络请求
        delay(500)
        emit(searchRepo.search(query))
    }

    fun onQueryChanged(newQuery: String) {
        queryFlow.value = newQuery
    }
}

data class SearchUiState(
    val results: List<String> = emptyList(),
    val isLoading: Boolean = false
)

常见错误与避坑指南

错误 1:在 map 中执行耗时操作但不使用挂起函数

kotlin 复制代码
// ❌ 错误:map 内部不能直接调用挂起函数(除非使用特殊操作符)
flow.map { value ->
    delay(100) // 编译错误!map 的 lambda 不是 suspend
    value * 2
}

正确做法map 的 lambda 是普通函数,如果需要挂起操作,应使用 transform.map { ... } 内部调用挂起函数(实际上 Flow 的 map 从 kotlinx.coroutines 1.3.0 开始支持内联挂起,但明确语义更好)。

kotlin 复制代码
// ✅ 使用 transform
flow.transform { value ->
    delay(100)
    emit(value * 2)
}

错误 2:混淆 flatMap 家族的使用场景

kotlin 复制代码
// ❌ 用 flatMapConcat 做实时搜索------用户快速输入时,旧请求不会取消,浪费资源
queryFlow.flatMapConcat { query -> searchFlow(query) }

正确做法 :实时搜索必须用 flatMapLatest 以取消旧请求。

错误 3:在 catch 中试图捕获下游异常

kotlin 复制代码
flow { emit(1) }
    .catch { e -> println("捕获不到下游异常") }
    .collect { value ->
        throw RuntimeException("collect 中的异常")
    }
// catch 不会触发!

正确做法 :下游异常需在 collect 处用 try-catch


最佳实践

  1. 优先使用专用操作符 :能用 map/filter 就不用 transform,代码更简洁、语义更清晰。

  2. flatMapLatest 处理用户交互触发的新请求:自动取消旧请求,避免资源浪费和状态错乱。

  3. catch 统一处理上游异常 :保持 collect 处代码干净。

  4. onStart/onCompletion 管理 UI 状态:将 Loading 逻辑封装在 Flow 管道内,而不是分散在 ViewModel 各处。

  5. 操作符顺序敏感 :例如 catch 只能捕获上游,放在管道开头和结尾效果完全不同。通常将 catch 放在靠近可能出错的 Flow 之后。

  6. 调试时使用 onEach 打印日志:观察数据在管道中的流动。


总结与下回预告

恭喜,你已经掌握了 Flow 操作符的精髓,元婴境中阶修炼完成!

本讲核心收获

  • map/filter/transform 实现基础数据变换。
  • flatMapConcat 顺序拼接、flatMapMerge 并发合并、flatMapLatest 只取最新。
  • catch 捕获上游异常,onStart/onEach/onCompletion 管理生命周期。
  • Flow 操作符的串联构建出声明式的数据处理管道。

在下一讲 【元婴境·后阶】 中,我们将学习 Flow 的背压(Backpressure 处理机制。届时你会明白:

  • 当生产者速度远快于消费者时,Flow 如何避免数据堆积?
  • bufferconflatecollectLatest 三种背压策略的区别与适用场景。
  • 如何在 Android 中处理高频传感器数据?

【当前境界修为面板】

当前境界 修炼技能 修炼进度 修炼心得
元婴境 · 中阶 1、map/filter/transform 变换三法 2、flatMap 三兄弟真解 3、catch/onCompletion 生命周期术 当前进度50% 修为500/1000 下一突破[元婴境 · 后阶] (需领悟:bufferconflatecollectLatest 背压处理三法) 操作符是流水线上的工位。flatMapLatest是搜索的守护神------新值到来,旧请求立刻取消。

【本讲思考题】

1、表象题:以下代码的输出是什么?

kotlin 复制代码
flow {
    emit(1)
    emit(2)
}.flatMapConcat { value ->
    flow {
        emit("$value-A")
        emit("$value-B")
    }
}.collect { println(it) }

2、场景题:你有一个显示股票实时价格的界面,价格每秒更新一次。当用户点击"刷新"按钮时,你希望立即发起一次请求获取最新价格,并取消之前还在运行的定时刷新流。应该用哪个 flatMap 操作符?写出核心代码。

3、原理题flatMapLatest 内部是如何实现"新值到来时取消旧流"的?请结合 transformLatest 或源码简述其实现机制。


道友,元婴境的最后一道关隘已在眼前。掌握了背压处理,你就能让 Flow 在极端数据洪流中稳如磐石。元婴境·后阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论

相关推荐
pengyu2 小时前
【Kotlin 协程修仙录 · 元婴境 · 初阶】 | 冷流初啼:Flow 的基础与响应式编程的入门
android·kotlin
wawo003 小时前
Kuikly实践-从Android compose迁移到kuikly compose
android
Carson带你学Android4 小时前
Android 版 MCP 正式登场:AppFunctions
android·ai编程·jetbrains
古法安卓7 小时前
Android-Direct Boot 阶段与 getFilesDir() 路径变更问题深度分析
android·java·android studio
阿巴斯甜7 小时前
Android Studio Detekt 使用
android
杉氧8 小时前
Flutter 跨平台多端适配与 Android/iOS 一键自动化打包发布
android·前端·flutter
Super 含8 小时前
Android 包体积优化(一):APK 到底大在哪里?从 APK 结构到体积分析
android·ide·vscode
Super 含9 小时前
Android 包体积优化(四):深入 ReDex——StripDebugInfo、InterDex 与 DEX 重排
android
安卓与AI研习社9 小时前
主线程明明是空闲的,为什么仍然 ANR?3 个真实 Trace 的证据链复盘
android