【Kotlin 协程修仙录 · 元婴境 · 初阶】 | 冷流初啼:Flow 的基础与响应式编程的入门

前言

金丹已凝,你对协程的并发、调度、异常处理已驾轻就熟。你能用 async 并发请求多个接口,能用 supervisorScope 隔离局部失败,能用 withContext 自如切换线程。你写的代码,既安全又优雅。

但现在,你遇到了一个新的挑战:

产品经理要求你实现一个"实时搜索"功能------用户在输入框中每输入一个字符,就发起一次网络请求,展示搜索建议。你还得处理各种边界:去抖动(避免过快请求)、取消上一个未完成的请求、错误重试、结果去重......

你尝试用 launch + Channel 实现,发现代码迅速膨胀。你想起 RxJava 里有 debounce、switchMap、distinctUntilChanged 等操作符,但引入整个 RxJava 又显得太重。

Kotlin 协程给出的答案,是一个叫做 Flow 的响应式流框架。

Flow 是 Kotlin 协程官方提供的冷流 实现。它允许你以声明式的方式处理连续发射的多个值,并天然集成了协程的挂起、取消、异常处理机制。

如果说 suspend 函数是"单次异步调用"的终极方案,那么 Flow 就是"连续数据流"的终极方案。它填补了协程生态中响应式编程的最后一块拼图。

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

  • 理解 Flow 是什么,以及它与 LiveData、RxJava、Sequence 的区别。
  • 掌握冷流的本质:为什么 Flow 是"懒加载"的?
  • 学会用 flow {} 构建器创建自己的数据流。
  • 了解 collect 终端操作符和流的基本生命周期。

准备好元婴初啼,感受数据流动了吗?我们开始。

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


什么是 Flow?

在 Kotlin 协程的官方定义中:

Flow<T> 是一个冷流 接口,代表一个可以异步、按顺序发射多个值 的数据流。它使用挂起函数来生产与消费数据 ,天然支持背压 和结构化并发。

Flow 并不是 Kotlin 协程的独创概念。如果你熟悉 RxJava,可以把 Flow 理解为协程版的 Observable,但更轻量、与协程生命周期深度绑定、API 更简洁。

flowchart LR subgraph Producer[生产者] P1[发射值1] P2[发射值2] P3[发射值3] end subgraph Flow[Flow 管道] direction LR Op1[操作符1] Op2[操作符2] end subgraph Consumer[消费者] C[collect 收集] end Producer --> Flow --> Consumer style Producer fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px style Flow fill:#fff3e0,stroke:#f57c00,stroke-width:2px style Consumer fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style P1 fill:#c8e6c9 style P2 fill:#c8e6c9 style P3 fill:#c8e6c9 style C fill:#90caf9

Flow 的核心特征:

  1. 冷流 :Flow 仅在被收集(collect)时才开始执行。每次收集都会触发一次全新的数据生产。
  2. 挂起:生产者和消费者都可以是挂起函数,天然支持异步操作而不阻塞线程。
  3. 可取消 :Flow 的收集过程响应协程的取消信号,无需额外处理。
  4. 结构化并发 :Flow 的生命周期受收集它的协程作用域约束。

为什么需要 Flow?Flow vs 其他方案

在 Flow 出现之前,Android 开发者处理连续数据流的方案主要有三种。它们各有痛点。

方案 优点 痛点 与协程集成
回调接口 简单直接 回调地狱,生命周期需手动管理 需手动适配
LiveData 生命周期感知,自动清理 操作符少,不支持挂起,强制主线程处理数据 需 liveData 构建器
RxJava 操作符极丰富,功能强大 学习曲线陡,依赖重,响应式类型繁多 需 rxjava-coroutines 适配
Flow 协程原生,操作符丰富,轻量 相对较新,生态仍在完善 无缝集成

Flow 的核心价值 :它让你用一套统一的协程心智模型 处理所有异步场景------单次异步用 suspend fun,连续数据流用 Flow。不再需要在不同框架之间来回切换。


冷流的本质:为什么 Flow 是"懒"的?

Flow 最区别于传统 LiveData 或 Channel 的特性是:它是冷的。

冷流 意味着:Flow 的代码块在被收集(collect)之前不会执行。每一次收集都会触发一次全新的数据生产,互不干扰。

用日常生活的类比:

  • 热流 像广播电台。无论有没有人收听,电台都在持续发射信号。后来打开收音机的人,只能听到此刻之后的内容。
  • 冷流 像点播视频。只有当用户点击"播放"时,视频才开始加载和播放。每个用户点击播放,都会触发一次独立的视频流。
sequenceDiagram participant Producer as 生产者 (flow { }) participant Flow as Flow 对象 participant Collector1 as 收集者1 participant Collector2 as 收集者2 Note over Flow: Flow 被创建,但未执行 Collector1->>Flow: collect { } Flow->>Producer: 触发生产(第1次) Producer-->>Collector1: 发射 1, 2, 3 Collector2->>Flow: collect { } Flow->>Producer: 触发生产(第2次) Producer-->>Collector2: 发射 1, 2, 3

代码验证:

kotlin 复制代码
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun simpleFlow(): Flow<Int> = flow {
    println("Flow 开始生产")
    emit(1)
    emit(2)
    emit(3)
}

fun main() = runBlocking {
    val flow = simpleFlow()
    println("Flow 已创建,但尚未生产")
    
    println("第一次收集:")
    flow.collect { value -> println("收集到:$value") }
    
    println("第二次收集:")
    flow.collect { value -> println("收集到:$value") }
}

输出:

css 复制代码
Flow 已创建,但尚未生产
第一次收集:
Flow 开始生产
收集到:1
收集到:2
收集到:3
第二次收集:
Flow 开始生产
收集到:1
收集到:2
收集到:3

每次 collect 都会触发 flow {} 块的重新执行。这就是冷流的核心特征。

为什么冷流很重要?

  • 资源节省:不收集就不执行,不会白白消耗 CPU/网络。
  • 可重放:每次收集都是独立的数据流,适合处理"每个订阅者都需要最新数据"的场景(如网络请求)。
  • 线程安全:不同收集者的数据生产彼此隔离,无需担心并发问题。

flow {} 构建器:创造你的第一个 Flow

flow {} 是创建 Flow 最基础的构建器。它的签名如下:

kotlin 复制代码
public fun <T> flow(
    block: suspend FlowCollector<T>.() -> Unit
): Flow<T>

在 block 内部,你可以使用 emit() 函数发射值。emit 是一个挂起函数,这意味着 Flow 的生产过程天然支持异步操作。

kotlin 复制代码
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

/**
 * 创建一个每秒发射一个数字的 Flow
 */
fun countdownFlow(from: Int): Flow<Int> = flow {
    for (i in from downTo 1) {
        delay(1000) // 模拟耗时操作
        emit(i)     // 发射当前值
    }
}

在 ViewModel 中使用 Flow

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

class CountdownViewModel : ViewModel() {

    // 暴露 Flow,而非 LiveData
    val countdownFlow: Flow<Int> = countdownFlow(10)

    fun startCountdown() {
        viewModelScope.launch {
            countdownFlow.collect { value ->
                // 这里的代码会在每个值发射时执行
                println("倒计时:$value")
                // 更新 UI 状态(后续讲 StateFlow 时会优化)
            }
        }
    }
}

Flow 的生命周期与取消

Flow 的收集过程天然响应协程的取消。当收集 Flow 的协程被取消时,Flow 的生产会停止。

kotlin 复制代码
fun main() = runBlocking {
    val job = launch {
        countdownFlow(10).collect { value ->
            println(value)
        }
    }
    
    delay(3500) // 等待 3.5 秒
    job.cancelAndJoin() // 取消收集协程
    println("收集已取消")
}
// 输出只会到 7 左右,因为协程被取消了
sequenceDiagram participant Job as 收集协程 participant Flow as Flow participant Producer as 生产者 (flow { }) Job->>Flow: collect Flow->>Producer: 启动生产 loop 每发射一个值 Producer->>Producer: delay + emit Producer-->>Job: 值 end Job->>Job: 外部调用 cancel() Job->>Producer: 传播取消信号 Producer-->>Producer: 下一个挂起点抛出 CancellationException Producer-->>Flow: 停止生产 Flow-->>Job: 收集结束

collect 终端操作符:点燃冷流的火种

collect 是 Flow 最基础的终端操作符 。它的作用是从 Flow 中收集每一个发射的值,并对每个值执行指定的操作。

终端操作符 :触发 Flow 开始执行的操作符。没有终端操作符,Flow 永远不会运行。

collect 是一个挂起函数,它会挂起当前协程 直到 Flow 完成(发射完所有值或抛出异常)。

kotlin 复制代码
public suspend fun Flow<*>.collect(collector: FlowCollector<*>)

除了 collect,Flow 还提供了一些便捷的终端操作符:

操作符 作用 适用场景
collect 收集每一个值 需要处理每一个发射值
collectLatest 只处理最新的值,如果新值到来时上一个值还没处理完,则取消上一个处理 实时搜索、UI 刷新
toList 将 Flow 的所有值收集到一个 List 中 需要一次性获取所有结果
toSet 收集到一个 Set 中 需要去重
first 只收集第一个值,然后取消 Flow 只关心首次结果
reduce 对值进行累加计算 求和、拼接等
fold 带初始值的累加 复杂聚合
kotlin 复制代码
// 使用 toList 一次性收集
suspend fun getAllNumbers(): List<Int> = countdownFlow(5).toList()
// 返回 [5, 4, 3, 2, 1]

// 使用 first 只取第一个
suspend fun getFirstNumber(): Int = countdownFlow(5).first()
// 返回 5

终端操作符分类图:


实战:用 Flow 实现实时搜索

让我们用一个完整的 Android 实战案例来巩固 Flow 的基础用法。场景是:用户在 TextField 中输入关键词,应用实时展示搜索建议。

关键需求:

  1. 每次输入都触发搜索请求。
  2. 避免过快触发(去抖动)。
  3. 取消上一个未完成的请求。
  4. 忽略重复的关键词。

这些在 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() {

    // 输入框的文本变化,使用 MutableStateFlow 作为输入源
    private val queryFlow = MutableStateFlow("")
    
    // 搜索结果状态
    var searchResult by mutableStateOf<List<String>>(emptyList())
        private set
    var isLoading by mutableStateOf(false)
        private set

    init {
        viewModelScope.launch {
            queryFlow
                .debounce(300) // 去抖动:300ms 内无新输入才向下传递
                .filter { it.length >= 2 } // 至少两个字符才搜索
                .distinctUntilChanged() // 忽略连续相同的关键词
                .flatMapLatest { query -> // 切换为新请求,取消旧的
                    flow {
                        isLoading = true
                        emit(searchRepo.search(query))
                        isLoading = false
                    }
                }
                .catch { e -> 
                    // 处理异常
                    searchResult = emptyList()
                    isLoading = false
                }
                .collect { result ->
                    searchResult = result
                }
        }
    }

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

配合 Compose UI:

kotlin 复制代码
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    Column(modifier = Modifier.padding(16.dp)) {
        TextField(
            value = "",
            onValueChange = { viewModel.onQueryChanged(it) },
            placeholder = { Text("输入关键词搜索") },
            modifier = Modifier.fillMaxWidth()
        )
        
        if (viewModel.isLoading) {
            LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
        }
        
        LazyColumn {
            items(viewModel.searchResult) { item ->
                Text(item, modifier = Modifier.padding(8.dp))
            }
        }
    }
}

这个示例展示了 Flow 的强大:用声明式的链式调用,清晰地表达了复杂的异步逻辑。这正是 Flow 相比手动管理协程的压倒性优势。

flowchart LR subgraph Input[输入源] TF[TextField 输入] end subgraph FlowPipeline[Flow 处理管道] Q[queryFlow] D[debounce 300ms] F[filter length>=2] DIST[distinctUntilChanged] FLAT[flatMapLatest] end subgraph Output[结果] UI[更新 UI] end TF --> Q --> D --> F --> DIST --> FLAT --> UI style Input fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style FlowPipeline fill:#fff3e0,stroke:#f57c00,stroke-width:2px style Output fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

常见错误与避坑指南

错误 1:在 flow {} 块内调用挂起函数前未处理取消

kotlin 复制代码
// ❌ 错误:如果在 emit 前执行了不支持取消的阻塞操作,协程无法及时响应取消
fun badFlow(): Flow<Int> = flow {
    Thread.sleep(5000) // 阻塞,不响应取消!
    emit(1)
}

正确做法 :使用挂起函数 delay 或支持取消的 API。

kotlin 复制代码
fun goodFlow(): Flow<Int> = flow {
    delay(5000) // 挂起,响应取消
    emit(1)
}

错误 2:在 ViewModel 中直接 collect 而不使用 launch

kotlin 复制代码
// ❌ 错误:collect 是挂起函数,不能直接在 init 中调用
class MyViewModel : ViewModel() {
    init {
        someFlow.collect { } // 编译错误!
    }
}

正确做法 :在 viewModelScope.launch 中收集。

kotlin 复制代码
class MyViewModel : ViewModel() {
    init {
        viewModelScope.launch {
            someFlow.collect { }
        }
    }
}

错误 3:混淆冷流和热流,期望多次收集共享同一数据源

kotlin 复制代码
val flow = flow { emit(api.getData()) }
// 两次收集会触发两次网络请求!
flow.collect { }
flow.collect { }

如果你需要共享同一数据源,应该使用 StateFlow 或 SharedFlow(热流),这将在化神境讲解。


最佳实践

  1. 暴露 Flow,不暴露可变状态 :在 ViewModel 中,对外暴露 Flow<T> 而非 MutableStateFlow,保持封装性。

  2. 使用 catch 操作符统一处理异常 :不要在每次 collect 处写 try-catch,而是用 flow.catch { } 在管道中处理。

  3. 在 ViewModel 中使用 stateIn 将冷流转换为热流:避免重复的网络请求,确保 UI 重组时不会触发多次收集。

  4. 理解冷流的"每次收集独立执行" :如果你需要缓存结果,使用 stateIn 或 shareIn。

  5. Flow 的构建器内不要做太重的工作 :flow {} 块应该专注于发射值,复杂逻辑封装在单独的挂起函数中。


总结与下回预告

恭喜,你已成功凝结元婴,掌握了 Flow 的基础用法!

本讲核心收获:

  • Flow 是协程原生的冷流实现,用于处理连续发射的异步数据。
  • 冷流是"懒"的:只在被收集时才执行,每次收集独立运行。
  • flow {} 构建器 + emit() 发射值,collect 终端操作符触发执行。
  • Flow 天然集成协程的取消、异常处理和结构化并发。
  • Flow 相比回调、LiveData、RxJava 有显著优势,是协程生态响应式编程的首选。

在下一讲 【元婴境·中阶】 中,我们将深入学习 Flow 的中间操作符:map、filter、transform、take 等。届时你会明白:

  • 如何像搭积木一样用操作符构建数据处理管道?
  • flatMapConcat、flatMapMerge、flatMapLatest 的区别与应用场景?
  • 如何处理 Flow 中的异常和完成事件?

【当前境界修为面板】

当前境界 修炼技能 修炼进度 修炼心得
元婴境 · 初阶 1、Flow 冷流心法 2、flow {} 创造术 3、collect 收集诀 当前进度 :25% 修为 :250/1000 下一突破 :[元婴境 · 中阶] (需领悟:Flow 操作符大全) 冷流如点播视频------不点击播放就不加载,每次播放都是从头开始。

【本讲思考题】

1、表象题:以下代码会打印什么?

kotlin 复制代码
runBlocking {
    val flow = flow {
        println("Start")
        emit(1)
        emit(2)
    }
    println("Before collect")
    flow.collect { println(it) }
    flow.collect { println(it) }
}

2、场景题 :你需要实现一个"每隔 1 秒轮询一次服务器获取最新状态,直到状态变为 COMPLETED"的功能。如何用 flow {} 和 collect 实现?写出核心代码。

3、原理题 :flow {} 构建器内部是如何保证线程安全的?为什么多个收集者同时收集同一个 Flow 不会互相干扰?请结合 SafeFlow 类简述。


道友,元婴初成,你已能感知数据流动的韵律。下一讲,我们将掌握 Flow 的操作符,让数据在管道中自由变幻。元婴境·中阶见。

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

相关推荐
千里马学框架5 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台5 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone5 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc5 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo6 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077006 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work6 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼6 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work6 天前
ch21 签名、校验与发版
kotlin
AFinalStone6 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui