不要再把 Flow 当作一个容器
在实际开发中,我们经常会遇到这样的需求:
"这里有一个
Flow<T>,但是我现在这个函数不是suspend的,我只想拿到它当前的值怎么办?"
于是,一种非常自然的写法就出现了:
csharp
private var currentUser: User? = null
init {
scope.launch {
userFlow.collect {
currentUser = it
}
}
}
从代码表面来看,这件事情似乎非常简单:
sql
Flow<User>
↓
collect
↓
User
↓
保存到变量
于是我们可能会觉得:
"Flow 里面不是有 User 吗?我把它拿出来保存到变量里不就好了?"
但这种思维方式很容易给我们带来一些实际的问题。
而这些问题的根源,往往不是 collect 用错了,也不是协程写错了。
而是:
我们一开始就把 Flow 理解成了一个容器。
一、如果 Flow 真的是一个容器,这段代码确实很合理
先来看一个普通的集合:
less
val users = listOf(
User("Alice"),
User("Bob"),
User("Charlie")
)
我们可以很自然地理解:
users里面已经有三个 User 了。
所以:
ini
val firstUser = users[0]
没有任何问题。
因为 List 的职责就是保存数据。
数据已经存在:
List
├── Alice
├── Bob
└── Charlie
我们只是把它读取出来。
于是当我们第一次接触:
kotlin
val userFlow: Flow<User>
很容易产生类似的想法:
sql
Flow<User>
│
└── User
仿佛 Flow 只是一个"装着 User 的容器"。
那么自然就会继续想:
"那我要怎么把这个 User 取出来?"
于是:
ini
userFlow.collect {
currentUser = it
}
看起来就非常合理。
问题在于:
Flow 并不是这样工作的。
二、Flow 不是"已经存在的数据",而是"产生数据的过程"
来看一个最简单的 Flow:
scss
val flow = flow {
println("开始执行")
emit(1)
emit(2)
emit(3)
}
如果我们只是:
scss
val flow = flow {
println("开始执行")
emit(1)
emit(2)
emit(3)
}
什么都不会发生。
你也不会看到:
开始执行
因为这里并没有一个已经存在的:
1
2
3
Flow 描述的是:
当有人消费它的时候,应该如何产生这些数据。
只有:
scss
flow.collect {
println(it)
}
之后,整个过程才真正开始。
于是它更像这样:
scss
collect
↓
开始执行 Flow
↓
emit(1)
↓
emit(2)
↓
emit(3)
所以:
Flow<T>并不是一个保存着T的容器。
更准确地说,它描述的是:
一个能够产生
T的数据流。
这两个理解看起来差别不大,但到了真实项目里,区别会非常明显。
三、第一个实际问题:你以为自己只是"读取数据",实际上启动了一个数据处理过程
假设 Repository 中有这样一个 Flow:
kotlin
fun observeUser(): Flow<User> {
return flow {
while (true) {
emit(loadUser())
delay(1000)
}
}
}
我们暂时不讨论这种实现本身是否合理,只看它表达的意思:
每隔一秒产生一次 User。
如果我们这样写:
csharp
private var currentUser: User? = null
init {
scope.launch {
repository.observeUser().collect {
currentUser = it
}
}
}
如果把 Flow 当成容器,我们可能只会看到:
scss
observeUser()
↓
拿到 User
↓
保存起来
但实际上发生的是:
sql
创建 Flow
↓
启动 coroutine
↓
collect
↓
启动上游数据产生过程
↓
每秒产生 User
↓
更新 currentUser
↓
继续等待
↓
继续产生 User
↓
......
这时候我们就会发现:
我真的只是在"读取一个值"吗?
并不是。
我们实际上启动了一个持续运行的数据消费过程。
这也是为什么在真实项目中,经常会出现一些让人困惑的问题:
- 为什么这个 coroutine 一直没有结束?
- 为什么数据库查询还在进行?
- 为什么这个 callback 一直没有解除?
- 为什么某个对象明明不再使用了,相关的任务却还在运行?
- 为什么取消了某个页面之后,这里的数据还在更新?
这些问题的答案很多时候都藏在:
sql
collect { ... }
后面。
因为 collect 并不是:
"从 Flow 里面取一个值。"
而是:
"开始消费这个数据流。"
四、第二个实际问题:成员变量保存的只是"某一次结果"
再看一个更加简单的例子:
kotlin
private var currentValue = 0
fun start() {
scope.launch {
someFlow.collect {
currentValue = it
}
}
}
很多人会把它理解成:
someFlow
↓
currentValue
好像执行完以后:
currentValue
就代表:
someFlow
但实际上两者完全不是一回事。
假设:
ini
val someFlow = flowOf(1, 2, 3)
那么最终:
ini
currentValue == 3
但是 3 并不是 Flow。
它只是:
这个 Flow 到目前为止最后一次产生出来的值。
可以把它画成:
scss
Flow
│
├── emit(1) ──→ currentValue = 1
│
├── emit(2) ──→ currentValue = 2
│
└── emit(3) ──→ currentValue = 3
所以:
currentValue
实际上只是 Flow 的一个快照。
这就产生了一个非常容易被忽略的问题。
如果 Flow 后面继续产生数据:
css
Flow
│
├── 1
├── 2
├── 3
├── 4
├── 5
└── ...
那么:
currentValue
是否一直代表最新状态?
答案是:
只有 collection 还在正常运行,它才可能继续更新。
如果 coroutine 被取消:
css
Flow
↓
collect ──X
那么:
currentValue
就不会再变化。
它只是停留在最后一次收到的值。
所以这个变量并不是:
"Flow 当前的值。"
而更准确地说是:
"某个正在运行的 Flow collection,到目前为止最后一次产生的值。"
这两个说法在工程上有非常大的区别。
五、第三个实际问题:为了"拿到一个值",我们很容易把代码越写越复杂
还有一种非常常见的情况。
假设原来的 API 是:
kotlin
fun getUser(): Flow<User>
但是某个调用者只是想:
"我现在需要 User。"
于是开始想办法把 Flow 变成普通值。
第一步:
ini
scope.launch {
getUser().collect {
user = it
}
}
发现:
普通函数什么时候才能拿到
user?
于是又开始考虑:
scss
getUser().first()
但是 first() 是 suspend 的。
于是:
scss
runBlocking {
getUser().first()
}
又开始担心:
这样会不会阻塞线程?
于是又开始考虑:
scss
scope.launch {
user = getUser().first()
}
然后发现:
我真正的问题好像不是怎么拿值,而是我的 API 为什么给我的是 Flow?
这时候就应该停下来重新思考。
因为我们可能一直在解决:
"如何从 Flow 里取出一个普通值?"
而不是:
"这个地方到底需要 Flow,还是需要一个普通值?"
这两个问题完全不同。
六、Flow 背后可能是一整条流水线
真实项目中的 Flow 往往更加复杂。
比如一个 ViewModel 最终需要一个:
css
Flow<UiState>
它可能来自:
perl
┌── Room
│
├── Network
│
├── User settings
│
└── Permission state
│
▼
combine
│
▼
map
│
▼
filter
│
▼
map
│
▼
UiState
最终 UI 看到的是:
UiState
但这个 UiState 只是浮在海面上的冰山。
真正重要的东西,很大一部分都在水下:
arduino
UiState
────────────────────────────────
▲
│
map / combine
│
┌─────────┼─────────┐
│ │ │
Room Network Settings
│ │ │
└─────────┴─────────┘
如果我们说:
"我把
UiState保存成一个成员变量,这样其他地方就可以直接用了。"
实际上只是把冰山露出水面的那一部分保存了下来。
水下那一整套数据产生和处理逻辑依然存在。
而为了让这个成员变量持续保持最新,我们还必须让:
sql
collect { ... }
持续运行。
所以:
Flow 的真正价值,并不只是它最终产生了什么值,而是这个值是如何产生、如何变化、如何被处理的。
七、这也是为什么 Flow 和普通变量不能简单互换
来看两个 API:
sql
val user: User
和:
kotlin
val userFlow: Flow<User>
它们表达的意思其实完全不同。
前者表达:
这里已经有一个 User。
后者表达:
这里有一个可以产生 User 的数据流。
因此:
sql
user
是一个数据。
而:
userFlow
是一个数据产生过程。
这就像:
一杯水
和:
一条水管
不是一回事。
你可以从水管里得到水,但:
水 ≠ 水管。
同样:
Flow 产生的值 ≠ Flow 本身。
八、那么 Flow 应该怎么使用?
其实答案很简单:
谁需要这个数据流,谁就消费它。
例如:
kotlin
class UserViewModel : ViewModel() {
val user = repository.observeUser()
fun refresh() {
...
}
}
UI 需要观察 User 的变化,那么 UI 对应的消费逻辑就去消费这个 Flow。
如果某个业务逻辑需要一次 User:
ini
val user = repository.getUser()
那么可以考虑 API 本身是不是应该提供一个一次性获取的接口,例如:
kotlin
suspend fun getUser(): User
而不是所有东西都先变成:
css
Flow<User>
然后再想办法:
sql
Flow<User>
↓
想办法变成
↓
User
当然,现实中的 API 设计并不会这么简单。
有些数据源天然就是持续变化的,有些业务确实需要持续观察,有些地方需要共享数据,有些地方又需要保存当前状态。
这些问题后面还会涉及 Flow 的生命周期、Cold Flow、Hot Flow、StateFlow、SharedFlow 等概念。
但在讨论这些问题之前,我们至少应该先建立一个基础认知:
Flow 不是一个装数据的容器。
九、一个简单但非常有用的判断方式
以后再看到:
css
Flow<T>
不要第一反应问:
"怎么把 T 拿出来?"
可以先问:
"我真正需要的是一个 T,还是一个持续产生 T 的过程?"
如果你需要的是一个已经存在的值:
r
需要一个值
↓
T
如果你需要的是一次异步查询:
kotlin
需要查询一次
↓
suspend fun ...(): T
如果你需要持续观察:
css
需要持续变化的数据
↓
Flow<T>
这时候 API 的选择就会自然很多。
十、最后重新看"collect 到成员变量"
现在再回头看这段代码:
csharp
private var currentUser: User? = null
init {
scope.launch {
userFlow.collect {
currentUser = it
}
}
}
它其实不是:
sql
Flow
↓
取出 User
↓
保存 User
而是:
sql
User Flow
│
▼
collect
│
▼
持续消费数据流
│
┌─────────┴─────────┐
▼ ▼
User 1 User 2
│ │
▼ ▼
currentUser = 1 currentUser = 2
所以这段代码真正表达的是:
"启动一个协程,持续消费这个 Flow,并把它最近一次产生的值同步到成员变量。"
这和:
"从 Flow 容器中取出一个值。"
是完全不同的事情。
而一旦我们意识到这一点,就会开始关注一些之前容易忽略的问题:
- 这个 collection 什么时候开始?
- 它什么时候结束?
- 谁负责取消它?
- 上游到底在做什么?
- 这个成员变量真的需要吗?
- 我需要的是最后一次结果,还是整个数据流?
这些问题,才是 Flow 真正值得我们思考的地方。
写在最后
我认为学习 Flow 的一个重要转折点,就是不要再把它理解成:
"一个比较特殊的容器。"
它更像是一条数据产生和处理的流水线。
普通变量告诉我们:
现在是什么?
而 Flow 更关注:
数据从哪里来?
什么时候产生?
如何变化?
如何处理?
因此:
arduino
map
是在构建数据处理过程;
combine
是在组合数据处理过程;
sql
collect
是在消费这个数据处理过程。
而:
ini
collect {
currentValue = it
}
并不是简单地"把 Flow 里的值拿出来"。
它实际上是在:
运行这个 Flow,并把它最近产生的结果保存下来。
所以,当你下次又产生这样的想法:
"我这里有一个 Flow,但是我只想拿到里面的值。"
不妨先停一下。
问自己一个问题:
我是真的需要一个值,还是我其实需要的是这个值背后的数据流?
很多时候,真正需要改变的不是代码,而是我们对 Flow 的理解。
不要再把 Flow 当作一个容器。