不要再把 Flow 当作一个容器

不要再把 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、StateFlowSharedFlow 等概念。

但在讨论这些问题之前,我们至少应该先建立一个基础认知:

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 当作一个容器。

相关推荐
我命由我123451 小时前
Android 开发问题:android.permission.CAMERA...duplicated with element declared at
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
parade岁月1 小时前
倒反天罡!押注 React Native 6 年后,Shopify 又回到了原生开发
android·前端·ios
邪修king1 小时前
MySQL 数据库(三):表级操作实战:增删查改全实例演示 + 企业实操红线规范
android·数据库·mysql
七夜zippoe2 小时前
从 Prompt Engineering 到 Agent Engineering:开发范式的代际跃迁
android·ai·prompt·agent·engineering
音视频开发进阶2 小时前
DuoShot:一次拍摄,让精彩多一种表达
android·duoshot
我命由我123452 小时前
Android 控件 - ListAdapter
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
一笑的小酒馆10 小时前
Androidiot开发之猫脸识别
android
三84412 小时前
PHP Session 反序列化(2):漏洞根因 —— 序列化处理器错位与对象注入
android·web安全·php·反序列化·session
天空之城--13 小时前
Android行业一周动态:编码趋势与行业资讯汇总
android·性能优化·架构·kotlin·android jetpack