不要在 Data 层随意把 Cold Flow 转换成 Hot Flow
在 Kotlin Flow 的使用中,我经常看到这样一种代码:
kotlin
class LoginRepository(
private val dataSource: LoginDataSource,
private val scope: CoroutineScope
) {
val loginState: StateFlow<LoginState> =
dataSource.observeLoginState()
.stateIn(scope)
}
乍一看,这段代码非常合理。
登录状态,不就是一种 State 吗?
既然是状态,那么用 StateFlow 表示似乎天经地义。
于是我们很自然地写出了:
scss
Flow<LoginState>
↓
stateIn(...)
↓
StateFlow<LoginState>
而且现实项目中确实很容易这么设计。
但我认为这里有一个问题值得认真思考:
为什么这个 Cold Flow 一定要在 Data 层转换成 Hot Flow?
注意,我并不是说 StateFlow 不好,也不是说 Data 层永远不能把 Cold Flow 转换成 Hot Flow。
我想讨论的是另外一件事:
不要无理由地把 Cold Flow 转换成 Hot Flow,更不要让 Data 层在没有明确需求的情况下替上层决定这个 Flow 的生命周期。
一、首先,不是所有 Flow 都需要转换成 Hot Flow
先假设我们有这样一个 DataSource:
kotlin
class LoginDataSource {
fun observeLoginState(): Flow<LoginState> {
// ...
}
}
Repository 完全可以保持这个 Flow:
kotlin
class LoginRepository(
private val dataSource: LoginDataSource
) {
fun observeLoginState(): Flow<LoginState> =
dataSource.observeLoginState()
}
上层需要的时候再进行收集:
perl
viewModelScope.launch {
repository.observeLoginState().collect { state ->
// 更新 UI
}
}
如果这里没有什么特殊需求,那么这个设计已经足够了。
我们并不需要因为:
"登录状态是一个状态。"
就一定把它变成:
swift
StateFlow<LoginState>
也不需要为了:
"以后可能会有多个地方使用。"
提前:
scss
stateIn(...)
更不需要因为:
"StateFlow 比 Flow 更方便。"
就给它绑定一个长期存在的 CoroutineScope。
Cold Flow 本身就是一个完整的执行模型,并不是等待被升级成 Hot Flow 的半成品。
二、业务上的"状态",不等于 Kotlin 的 StateFlow
我觉得这是这里最容易产生的误解。
例如:
kotlin
sealed interface LoginState {
data object LoggedOut : LoginState
data class LoggedIn(val user: User) : LoginState
}
从业务语义上看:
LoginState显然是一种状态。
但是:
markdown
业务上的状态
≠
StateFlow
这是两个完全不同的概念。
前者描述的是:
这个数据在业务上表达什么。
后者描述的是:
这个数据通过什么样的响应式执行模型被产生、共享和保存。
所以完全可以存在:
css
Flow<LoginState>
而不是:
swift
StateFlow<LoginState>
这两个类型表达的是不同层面的事情。
我们不能因为:
LoginState 是状态
就直接推导出:
所以必须使用 StateFlow
更不能继续推导出:
scss
所以 Data 层必须 stateIn()
中间其实缺了非常重要的一步:
我们到底有没有让这个 Flow 变成 Hot Flow 的需求?
三、什么时候才需要把 Cold Flow 转换成 Hot Flow?
当然,Hot Flow 有非常重要的使用场景。
例如,我们有一个上游:
kotlin
fun observeLoginState(): Flow<LoginState>
现在有三个消费者:
markdown
┌── UI
│
Login DataSource ─┼── Widget
│
└── Notification
如果每个消费者都独立 collect Cold Flow,那么上游可能被执行多次。
如果建立上游订阅本身比较昂贵,我们可能希望:
css
┌── UI
│
DataSource ── Hot Flow ── Widget
│
└── Notification
三个消费者共享同一个上游。
这就是一个很合理的转换理由。
除此之外,还有一些情况也可能需要 Hot Flow:
- 多个消费者需要共享同一个上游执行;
- 上游资源昂贵,不希望每个 collector 都建立一份;
- 确实需要缓存最近一次的数据;
- 数据流需要独立于某个具体 collector 存在;
- 业务明确要求某个数据流拥有独立的生命周期。
这些情况下:
scss
shareIn(...)
或者:
scss
stateIn(...)
都可能是合理选择。
所以问题从来不是:
Hot Flow 好不好?
而应该是:
我们为什么需要 Hot Flow?
四、转换成 Hot Flow,实际上是在做生命周期决策
这一点非常容易被忽略。
我们来看:
scss
flow.stateIn(scope)
表面上只是:
r
Flow<T>
↓
StateFlow<T>
但它实际上做的事情远不只是"换一个类型"。
Cold Flow 的一个重要特征是:
Collector 决定什么时候开始消费这个 Flow。
例如:
scss
val flow = flow {
println("start")
emit(loadData())
}
没有 collector 时,上游通常不会开始执行。
当:
scss
flow.collect()
发生以后,上游才开始工作。
因此可以把 Cold Flow 想象成一条管道:
css
Data Source
↓
Cold Flow
↓
Consumer
消费者需要数据:
打开
消费者不需要数据:
关闭
生命周期天然和消费者联系在一起。
但是,当我们写:
scss
flow.stateIn(scope)
之后,情况就不一样了。
这个 Flow 开始拥有一个独立的运行环境,而这个运行环境由:
sql
scope
参与决定。
也就是说:
css
Flow
↓
Hot Flow
↓
Scope
这个 scope 不只是一个"让代码能够运行起来"的参数。
它实际上决定了:
这个 Hot Flow 可以活多久。
所以,把 Cold Flow 转换成 Hot Flow,本质上也是一次生命周期决策。
五、这就像自来水
我们可以用一个非常简单的比喻理解这件事情。
假设:
自来水厂
↓
供水管道
↓
你家的水龙头
你需要水的时候:
打开水龙头
不需要的时候:
关闭水龙头
水厂负责生产和输送水。
但:
什么时候打开你家的水龙头,由你决定。
这其实很像 Cold Flow:
css
Data Source
↓
Cold Flow
↓
Consumer
消费者需要数据:
scss
collect()
消费者不需要:
sql
停止 collect
这个关系非常自然。
六、但是如果 Data 层提前转换成 Hot Flow 呢?
假设我们这样写:
kotlin
class LoginRepository(
private val dataSource: LoginDataSource,
private val scope: CoroutineScope
) {
val loginState: StateFlow<LoginState> =
dataSource.observeLoginState()
.stateIn(scope)
}
这时候,相当于在供水系统里增加了一个"总阀门":
自来水厂
↓
总阀门
↓
你家的水龙头
问题就变成:
这个总阀门由谁控制?
如果总阀门由自来水厂控制,那么:
自来水厂决定你家的水什么时候开始供、什么时候停止供。
这听起来就有些奇怪。
对应到我们的代码:
css
Data Layer
↓
Hot Flow
↓
Consumer
Data 层实际上开始决定:
- Flow 什么时候运行;
- Flow 什么时候停止;
- Flow 应该活多久;
- 上游订阅应该持续多久。
于是我们就应该问:
为什么这些事情应该由 Data 层决定?
七、生命周期本身也是一种职责
我们经常讨论架构中的职责:
- 谁负责访问数据库?
- 谁负责网络请求?
- 谁负责数据转换?
- 谁负责业务逻辑?
但还有一个经常被忽略的问题:
谁负责决定一个东西应该活多久?
生命周期本身也是一种职责。
当我们写:
scss
flow.stateIn(repositoryScope)
实际上就在做一个架构决策:
这个 Flow 的生命周期跟 Repository Scope 绑定。
如果这个 Repository Scope 是一个长期存在的 Scope,那么这个 Flow 也可能长期存在。
这意味着 Data 层不只是:
"提供一个登录状态。"
而是在说:
"我来决定这个登录状态数据流的生命周期。"
这就是问题所在。
八、并不是说 Data 层永远不能转换 Hot Flow
这里必须强调一个非常重要的限定。
Data 层不是绝对不能转换 Cold Flow。
如果 Data 层本身确实拥有这个数据流的生命周期,而且确实存在共享、缓存或者独立运行的需求,那么:
scss
dataSource.observeLoginState()
.stateIn(repositoryScope)
完全可能是合理的。
真正值得警惕的是:
kotlin
class LoginRepository(
private val dataSource: LoginDataSource,
private val scope: CoroutineScope
) {
val loginState =
dataSource.observeLoginState()
.stateIn(scope)
}
仅仅因为:
"登录状态应该是 StateFlow。"
就这么做。
或者:
"以后可能会有多个消费者。"
所以提前做。
或者:
"上层用 StateFlow 更方便。"
所以 Data 层顺手做掉。
这些理由都不足以自动证明:
这个 Flow 应该由 Data 层拥有生命周期。
九、即使数据应该存在整个 App 生命周期,也应该明确是谁做这个决定
还有一种常见的情况:
有人可能会说:
"登录状态本来就是整个 App 的状态啊,当然应该一直存在。"
如果业务确实有这样的要求,那么让登录状态拥有 App 生命周期当然可能是合理的。
但我们仍然需要区分两个问题:
业务决定
登录状态应该贯穿整个 App 生命周期。
架构决定
谁负责创建这个长期存在的 Hot Flow?
这两个问题并不是一回事。
如果确实需要 App 生命周期,那么可以由真正拥有 App 生命周期的组件明确做这个决定。
而不是 Repository 自己创建一个长期存在的 Scope:
kotlin
class LoginRepository {
private val scope = CoroutineScope(...)
val loginState =
dataSource.observeLoginState()
.stateIn(scope)
}
因为这样一来:
css
App 生命周期
↓
Repository
↓
Hot Flow
这个关系就被隐藏起来了。
调用者甚至很难知道:
这个 Flow 为什么一直活着?
它什么时候才会结束?
谁负责取消它?
为什么 Repository 要拥有这个生命周期?
如果生命周期确实重要,那么它就应该成为一个明确的架构决策,而不是 Data 层内部的一个实现细节。
十、过早转换成 Hot Flow,还可能产生真实的工程问题
生命周期问题并不只是理论上的架构讨论。
假设 Flow 的数据来源是一个外部 Service:
css
External Service
↓
DataSource
↓
Cold Flow
↓
Repository
↓
Hot Flow
DataSource 可能通过 Binder、Socket 或其他机制建立一个外部订阅。
例如:
kotlin
fun observeData(): Flow<Data> = callbackFlow {
// 建立外部订阅
awaitClose {
// 解除订阅
}
}
对于 Cold Flow 来说,消费者开始 collect 时:
建立订阅
消费者停止 collect 时:
解除订阅
整个过程比较自然。
但是,如果 Data 层把它提前转换成 Hot Flow:
scss
observeData()
.stateIn(repositoryScope)
那么外部订阅可能不再直接跟某个具体消费者绑定。
十一、外部 Service 重启之后,问题就出现了
假设外部 Service 突然重启:
markdown
External Service
↓
重启
↓
旧订阅关系失效
但是:
css
repositoryScope
↓
Hot Flow
仍然活着。
于是可能出现:
sql
External Service 重启
↓
旧订阅关系失效
↓
Hot Flow 仍然存在
↓
RepositoryScope 仍然存在
↓
新的消费者开始 collect
↓
上游却没有重新建立有效订阅
最后出现一个非常诡异的问题:
Flow 还活着,但是数据已经死了。
这里需要特别说明:
这并不是说 Cold Flow 天然能够解决 Service 重启。
外部 Service 本身存在重启问题,我们依然需要设计重连机制。
真正的问题是:
Data 层过早把一个本来可以随着消费者生命周期建立和释放的外部订阅,变成了一个长期存在的 Hot Flow。
这样一来,一个本来应该被重新建立的连接关系,可能被错误地长期持有。
这就是生命周期设计在实际项目中的意义。
十二、所以真正的决策顺序应该是这样的
当我们拿到一个 Flow 时,不应该直接思考:
css
"这个 Flow 应该在哪一层 stateIn?"
而应该先问:
css
我真的需要 Hot Flow 吗?
如果不需要:
css
Flow
↓
保持 Cold Flow
如果确实需要:
css
Flow
↓
为什么需要?
├── 共享上游?
├── 缓存当前值?
├── 独立生命周期?
└── 其他明确需求?
↓
转换成 Hot Flow
↓
谁拥有这个生命周期?
↓
选择合适的 Scope
也就是说:
先决定"要不要转换",再决定"在哪里转换"。
而不是:
scss
Data Layer
↓
拿到 Flow
↓
先 stateIn()
↓
再交给上层
十三、Cold Flow 不是 Hot Flow 的低级版本
我觉得这是整个问题中最重要的认知转变。
我们很容易形成这样的心理模型:
css
Cold Flow
↓
升级
↓
Hot Flow
好像 Cold Flow 是比较原始的东西,而 Hot Flow 才是最终形态。
实际上并不是。
它们只是不同的执行模型。
Cold Flow 更接近:
谁需要,谁启动;谁不需要,谁停止。
Hot Flow 更接近:
数据流可以脱离某一个具体消费者独立存在,并由一个明确的生命周期控制。
所以:
css
Cold Flow
并不意味着:
"这个 Flow 还没设计好。"
而是:
这个 Flow 就应该按照消费者的生命周期运行。
同样:
css
Hot Flow
也不意味着:
"这个 Flow 设计得更高级。"
它只是意味着:
我们确实需要让这个数据流拥有独立的运行生命周期。
十四、回到自来水厂
现在再回头看这个比喻,就很清楚了。
Cold Flow:
自来水厂
↓
管道
↓
你家的水龙头
你需要水:
打开水龙头
你不需要:
关闭水龙头
这非常自然。
如果我们确实需要一个总供水系统:
自来水厂
↓
总阀门
↓
你家的水龙头
也没有问题。
问题只是:
谁应该控制这个总阀门?
如果确实需要整个小区共享一个总供水系统,那么当然应该有人负责管理它。
但我们不能因为:
"以后可能会用水。"
就让自来水厂提前把总阀门打开。
同样,我们也不应该因为:
"以后可能有多个消费者。"
"这个数据业务上是一个状态。"
"StateFlow 用起来比较方便。"
就让 Data 层提前把 Cold Flow 转换成 Hot Flow。
十五、最后
我并不反对 StateFlow、SharedFlow、stateIn 或 shareIn。
这些都是非常有用的工具。
我真正想避免的是一种下意识的设计方式:
看到 Flow,就想着把它转换成 Hot Flow。
在转换之前,不妨先问三个问题:
1. 我真的需要 Hot Flow 吗?
如果没有共享、缓存、独立生命周期等明确需求,那么 Cold Flow 可能已经足够。
2. 我为什么需要 Hot Flow?
明确需求之后,我们才能决定应该使用 stateIn 还是 shareIn,以及采用什么样的 Sharing 策略。
3. 谁应该拥有它的生命周期?
因为:
scss
flow.stateIn(scope)
真正重要的并不只是:
css
Flow → StateFlow
而是:
css
Flow
↓
拥有一个独立的运行生命周期
↓
这个生命周期由 Scope 决定
所以:
Cold Flow 不是等待被转换成 Hot Flow 的半成品。
没有明确需求,就保持 Cold Flow。
确实需要 Hot Flow,再讨论在哪里转换。
而一旦转换,就应该明确:这个生命周期究竟应该由谁拥有。
毕竟:
你家的水龙头,应该由你自己决定什么时候打开。
不要让自来水厂替你决定。