Kotlin 协程设计思想(九):Flow 到底是什么?为什么 suspend 函数还需要 Flow?

------ 从 suspend、Sequence 到 Cold Flow,彻底讲透 Kotlin Flow 的设计哲学

前面几篇,我们已经讲了:

CoroutineContext

Job

Dispatcher

launch / async

Exception

Structured Concurrency

Supervisor

suspend

到第八篇,我们已经知道:

suspend 不是开启协程。

它真正解决的是:

复制代码
协程如何暂停
协程如何恢复

suspend 的底层核心是:

复制代码
Continuation + 编译器状态机

到这里,很多人会觉得:

既然已经有了 suspend,为什么还要有 Flow

这就是第九篇真正要讲的问题。

不是:

复制代码
Flow 怎么用?

而是:

复制代码
为什么 Kotlin 已经有 suspend,还要设计 Flow?

一、前面其实埋了一个坑

前面我们一直在讲:

复制代码
suspend fun login() {

}

它的特点是什么?

一句话:

复制代码
一次调用,一个结果

例如:

复制代码
suspend fun getUser(): User

执行流程是:

复制代码
请求用户信息
↓
返回 User
↓
结束

这非常适合接口请求。

例如:

复制代码
val user = getUser()

调用一次,返回一个结果,然后结束。

但是问题来了。

如果是定位呢?

复制代码
1 秒返回一次经纬度

如果是 WebSocket 呢?

复制代码
不断收到服务端消息

如果是蓝牙通知呢?

复制代码
设备一直上报数据

如果是下载进度呢?

复制代码
1%、2%、3%、4%...

这些场景都有一个共同特点:

复制代码
不是一次结果
而是连续结果

这时候,单纯的 suspend fun 就不够用了。


二、suspend 最大的限制

suspend 最大的特点是:

复制代码
一次调用,一个结果

例如:

复制代码
val user = getUser()

这很好理解。

但是现实世界里,很多数据不是一次性的。

而是源源不断的。

例如:

复制代码
定位
WebSocket
蓝牙通知
聊天消息
股票价格
传感器数据
下载进度
数据库变化

这些数据不是:

复制代码
请求一次
返回一次
结束

而是:

复制代码
不断产生
不断更新
不断消费

如果只靠 suspend fun,就会很尴尬。

因为 suspend fun 只能这样:

复制代码
suspend fun getLocation(): Location

它只能返回一个 Location

但是定位真正需要的是:

复制代码
Location
Location
Location
Location
...

所以:

suspend 解决的是一次结果。

而:

Flow 解决的是连续结果。


三、其实 Java 早就遇到过这个问题

在 Java 里,如果我们想一个个拿数据,会用什么?

复制代码
Iterator

例如:

复制代码
while (iterator.hasNext()) {
    Object item = iterator.next();
}

它的特点是:

复制代码
一次拿一个
不断往后拿

后来 Kotlin 里有了:

复制代码
Sequence

例如:

复制代码
val sequence = sequence {
    yield(1)
    yield(2)
    yield(3)
}

它也是一个个产生数据。

使用时:

复制代码
sequence.forEach {
    println(it)
}

输出:

复制代码
1
2
3

Sequence 的特点是:

复制代码
懒加载
按需产生
一个个消费

这和 Flow 很像。

但是 Sequence 有一个问题:

它不支持挂起。


四、Sequence 为什么不够?

例如:

复制代码
val sequence = sequence {
    yield(1)
    delay(1000)
    yield(2)
}

这段代码是不成立的。

因为 sequence {} 里面不能直接调用挂起函数。

也就是说,Sequence 可以做到:

复制代码
一个个产生数据

但是它做不到:

复制代码
一边等待
一边挂起
一边继续产生数据

而现实中的连续数据,往往都需要等待。

例如:

复制代码
1 秒后产生一次定位
网络消息来了才产生一次数据
蓝牙通知来了才产生一次数据
数据库变化了才产生一次数据

这些都不是普通的同步迭代能解决的。

所以可以这样理解:

复制代码
Sequence = 同步数据序列
Flow = 支持挂起的异步数据序列

五、突然理解 Flow

很多教程会说:

复制代码
Flow 是数据流

这句话没错,但是太抽象。

我觉得更适合初学者理解的一句话是:

复制代码
Flow = 支持 suspend 的 Sequence

Sequence 是:

复制代码
一个个给数据

Flow 也是:

复制代码
一个个给数据

但 Flow 比 Sequence 多了一个能力:

复制代码
每次给数据之前,可以挂起

例如:

复制代码
val flow = flow {
    emit(1)

    delay(1000)

    emit(2)

    delay(1000)

    emit(3)
}

这段代码表达的意思是:

复制代码
先发 1
等 1 秒
再发 2
再等 1 秒
再发 3

这个能力,Sequence 做不到。

Flow 能做到,是因为:

复制代码
emit 是 suspend
collect 也是 suspend

六、emit 到底是什么?

例如:

复制代码
flow {
    emit(1)
    emit(2)
    emit(3)
}

很多人觉得:

复制代码
emit 就是发送数据

这句话没错,但还不够。

更准确地说:

复制代码
emit 是一次可能挂起的数据发送

为什么 emit() 也要是 suspend

因为生产者和消费者之间可能存在速度差。

例如:

复制代码
生产者很快
消费者很慢

生产者不停发:

复制代码
1
2
3
4
5

但是消费者处理不过来。

这时候怎么办?

不能无限制乱发。

所以 emit() 必须有能力:

复制代码
等待消费者处理
必要时挂起自己

这就是为什么:

复制代码
emit()

本身也是 suspend


七、collect 为什么也是 suspend?

再看:

复制代码
flow.collect {
    println(it)
}

为什么 collect() 也是 suspend

因为 collect 的本质是:

复制代码
等待数据
处理数据
继续等待
继续处理

尤其是很多 Flow 可能永远不会结束。

例如:

复制代码
WebSocket 消息流
定位数据流
蓝牙通知流
数据库监听流

它们的特点是:

复制代码
只要页面还在
就一直等待数据

所以 collect 必须能够挂起当前协程。

否则它就只能阻塞线程。

所以:

复制代码
collect 是 suspend

本质上是因为:

复制代码
它要等待连续数据

八、为什么 Flow 是 Cold?

这是 Flow 最经典的问题。

例如:

复制代码
val flow = flow {
    println("开始")
    emit(1)
}

这里会不会马上打印:

复制代码
开始

答案是:

不会。

因为只是创建了一个 Flow 对象。

此时:

复制代码
没人 collect
就没人生产

只有真正调用:

复制代码
flow.collect {
    println(it)
}

才会开始执行 flow {} 里面的代码。

所以 Flow 默认是 Cold Flow。

所谓 Cold Flow,就是:

复制代码
没人收集
就不生产数据

九、为什么要设计成 Cold?

这个设计非常重要。

例如在 Android 页面里:

复制代码
页面进入
开始 collect

页面退出
停止 collect

当没人 collect 的时候,Flow 就不再生产数据。

这样有几个好处:

复制代码
节约资源
避免无意义任务
配合生命周期更安全
减少内存泄漏

比如定位。

如果页面已经退出了,还在后台一直生产定位数据,就很浪费。

Cold Flow 的设计正好解决这个问题:

复制代码
需要时才开始
不需要时就停止

这和协程的结构化并发思想是统一的。


十、突然和前面串起来了

现在我们回头看整个体系。

复制代码
suspend:一次调用,一个结果

Flow:一次调用,多个结果

Channel:消息队列

callbackFlow:把回调转成 Flow

StateFlow:状态流

SharedFlow:事件流

这样一看,很多东西就不再是零散 API 了。

它们分别解决不同的问题。

suspend 解决:

复制代码
一次异步结果

Flow 解决:

复制代码
连续异步结果

Channel 解决:

复制代码
协程之间排队传消息

callbackFlow 解决:

复制代码
把传统 callback 接入协程世界

StateFlow 解决:

复制代码
状态保存和状态观察

SharedFlow 解决:

复制代码
事件分发和一次性通知

这才是 Flow 真正的位置。


十一、Google 为什么还要设计 Flow?

因为现实业务里,数据形态不止一种。

有些数据是一次性的。

例如:

复制代码
登录
获取用户信息
提交表单
上传结果

这些适合:

复制代码
suspend fun

有些数据是连续的。

例如:

复制代码
定位
下载进度
WebSocket
蓝牙通知
数据库监听
页面状态变化

这些适合:

复制代码
Flow

所以 Kotlin 协程体系不是简单地"多造了几个 API"。

它是在补完整个异步编程模型:

复制代码
一次结果
连续结果
状态
事件
消息
回调

十二、整个协程体系的设计主线

现在再看这些概念:

复制代码
Thread
Coroutine
suspend
Flow
StateFlow
SharedFlow
Channel
callbackFlow

它们其实一直在回答不同层次的问题。

复制代码
Thread:任务怎么执行?

Coroutine:任务怎么暂停和恢复?

suspend:一次异步结果怎么表达?

Flow:连续异步结果怎么表达?

StateFlow:状态怎么保存和观察?

SharedFlow:事件怎么分发?

Channel:消息怎么排队?

callbackFlow:回调怎么接入协程体系?

你会发现:

Kotlin 协程体系,并不是一堆零散 API。

它真正解决的是:

程序里的任务和数据,应该如何流动。


十三、最终总结

如果让我一句话解释 suspend,我会说:

复制代码
suspend 解决一次异步结果。

如果让我一句话解释 Flow,我会说:

复制代码
Flow 是支持 suspend 的 Sequence。

如果让我解释为什么已经有了 suspend,还要有 Flow,我会说:

复制代码
suspend 只能表达一次结果。
Flow 可以表达连续结果。

所以:

复制代码
suspend fun getUser(): User

适合一次请求。

而:

复制代码
fun observeLocation(): Flow<Location>

适合连续数据。

真正理解 Flow,不是记住:

复制代码
emit
collect
flowOn
StateFlow
SharedFlow

而是理解:

复制代码
现实世界里有大量连续产生的数据。
suspend 只能解决一次结果。
Flow 才能解决连续结果。

这就是 Flow 的设计哲学。


下篇预告

到这里,整个协程设计思想系列已经走到了一个非常有意思的位置。

我们已经讲完了:

复制代码
CoroutineContext
Job
Dispatcher
launch / async
Exception
Structured Concurrency
Supervisor
suspend
Flow

那么最后一个问题来了:

复制代码
Kotlin 协程到底解决了什么问题?

下一篇继续:

Kotlin 协程设计思想(十):Kotlin 协程到底解决了什么问题?

------ 从 Thread、Future、Callback、RxJava 到 Coroutine、Flow,彻底讲透 Kotlin 协程的发展脉络,以及 Google 为什么最终选择了这套设计。

最后再补一句。

整个《Kotlin 协程设计思想》系列,其实一直在回答同一个问题:

复制代码
程序里的任务和数据,到底应该如何流动?

Thread 解决怎么执行。

Coroutine 解决怎么暂停。

suspend 解决一次异步结果。

Flow 解决连续异步结果。

StateFlow 解决状态。

SharedFlow 解决事件。

Channel 解决消息队列。

callbackFlow 解决回调接入。

这才是这个系列真正的主线。

也正是它比单纯讲 API 更有价值的地方。

相关推荐
消失的旧时光-19431 小时前
Kotlin 协程设计思想(八):suspend 到底是什么?为什么 suspend 不是开启协程?
android·kotlin·suspend·continuation
weiggle1 小时前
第六篇:状态管理——从 mutableStateOf 到 StateFlow
android
plainGeekDev2 小时前
SharedPreferences → DataStore
android·java·kotlin
plainGeekDev2 小时前
Cursor 操作 → Room DAO
android·java·kotlin
pyz6662 小时前
Retrofit 源码分析
android·retrofit
xiaoduzi19912 小时前
Android 线程池总结
android
YIN_尹2 小时前
【Linux系统编程】基础IO第二讲——文件描述符
android·linux·服务器
朝星2 小时前
Android开发[10]:性能优化之内存
android·kotlin
像风一样自由20203 小时前
量化压缩实战:INT8 / INT4 / AWQ / GPTQ 全面对比
android·人工智能·语言模型·大模型