不要在 Data 层随意把 Cold Flow 转换成 Hot Flow

1 阅读12分钟

不要在 Data 层随意把 Cold Flow 转换成 Hot Flow

在 Kotlin Flow 的使用中,我经常看到这样一种代码:

class LoginRepository(
    private val dataSource: LoginDataSource,
    private val scope: CoroutineScope
) {
    val loginState: StateFlow<LoginState> =
        dataSource.observeLoginState()
            .stateIn(scope)
}

乍一看,这段代码非常合理。

登录状态,不就是一种 State 吗?

既然是状态,那么用 StateFlow 表示似乎天经地义。

于是我们很自然地写出了:

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:

class LoginDataSource {

    fun observeLoginState(): Flow<LoginState> {
        // ...
    }
}

Repository 完全可以保持这个 Flow:

class LoginRepository(
    private val dataSource: LoginDataSource
) {
    fun observeLoginState(): Flow<LoginState> =
        dataSource.observeLoginState()
}

上层需要的时候再进行收集:

viewModelScope.launch {
    repository.observeLoginState().collect { state ->
        // 更新 UI
    }
}

如果这里没有什么特殊需求,那么这个设计已经足够了。

我们并不需要因为:

“登录状态是一个状态。”

就一定把它变成:

StateFlow<LoginState>

也不需要为了:

“以后可能会有多个地方使用。”

提前:

stateIn(...)

更不需要因为:

“StateFlow 比 Flow 更方便。”

就给它绑定一个长期存在的 CoroutineScope

Cold Flow 本身就是一个完整的执行模型,并不是等待被升级成 Hot Flow 的半成品。


二、业务上的“状态”,不等于 Kotlin 的 StateFlow

我觉得这是这里最容易产生的误解。

例如:

sealed interface LoginState {
    data object LoggedOut : LoginState
    data class LoggedIn(val user: User) : LoginState
}

从业务语义上看:

LoginState 显然是一种状态。

但是:

业务上的状态
        ≠
StateFlow

这是两个完全不同的概念。

前者描述的是:

这个数据在业务上表达什么。

后者描述的是:

这个数据通过什么样的响应式执行模型被产生、共享和保存。

所以完全可以存在:

Flow<LoginState>

而不是:

StateFlow<LoginState>

这两个类型表达的是不同层面的事情。

我们不能因为:

LoginState 是状态

就直接推导出:

所以必须使用 StateFlow

更不能继续推导出:

所以 Data 层必须 stateIn()

中间其实缺了非常重要的一步:

我们到底有没有让这个 Flow 变成 Hot Flow 的需求?


三、什么时候才需要把 Cold Flow 转换成 Hot Flow?

当然,Hot Flow 有非常重要的使用场景。

例如,我们有一个上游:

fun observeLoginState(): Flow<LoginState>

现在有三个消费者:

                  ┌── UI
                  │
Login DataSource ─┼── Widget
                  │
                  └── Notification

如果每个消费者都独立 collect Cold Flow,那么上游可能被执行多次。

如果建立上游订阅本身比较昂贵,我们可能希望:

                 ┌── UI
                 │
DataSource ── Hot Flow ── Widget
                 │
                 └── Notification

三个消费者共享同一个上游。

这就是一个很合理的转换理由。

除此之外,还有一些情况也可能需要 Hot Flow:

  • 多个消费者需要共享同一个上游执行;
  • 上游资源昂贵,不希望每个 collector 都建立一份;
  • 确实需要缓存最近一次的数据;
  • 数据流需要独立于某个具体 collector 存在;
  • 业务明确要求某个数据流拥有独立的生命周期。

这些情况下:

shareIn(...)

或者:

stateIn(...)

都可能是合理选择。

所以问题从来不是:

Hot Flow 好不好?

而应该是:

我们为什么需要 Hot Flow?


四、转换成 Hot Flow,实际上是在做生命周期决策

这一点非常容易被忽略。

我们来看:

flow.stateIn(scope)

表面上只是:

Flow<T>
   ↓
StateFlow<T>

但它实际上做的事情远不只是“换一个类型”。

Cold Flow 的一个重要特征是:

Collector 决定什么时候开始消费这个 Flow。

例如:

val flow = flow {
    println("start")
    emit(loadData())
}

没有 collector 时,上游通常不会开始执行。

当:

flow.collect()

发生以后,上游才开始工作。

因此可以把 Cold Flow 想象成一条管道:

Data Source
    ↓
  Cold Flow
    ↓
  Consumer

消费者需要数据:

打开

消费者不需要数据:

关闭

生命周期天然和消费者联系在一起。


但是,当我们写:

flow.stateIn(scope)

之后,情况就不一样了。

这个 Flow 开始拥有一个独立的运行环境,而这个运行环境由:

scope

参与决定。

也就是说:

Flow
 ↓
Hot Flow
 ↓
Scope

这个 scope 不只是一个“让代码能够运行起来”的参数。

它实际上决定了:

这个 Hot Flow 可以活多久。

所以,把 Cold Flow 转换成 Hot Flow,本质上也是一次生命周期决策


五、这就像自来水

我们可以用一个非常简单的比喻理解这件事情。

假设:

自来水厂
   ↓
供水管道
   ↓
你家的水龙头

你需要水的时候:

打开水龙头

不需要的时候:

关闭水龙头

水厂负责生产和输送水。

但:

什么时候打开你家的水龙头,由你决定。

这其实很像 Cold Flow:

Data Source
    ↓
Cold Flow
    ↓
Consumer

消费者需要数据:

collect()

消费者不需要:

停止 collect

这个关系非常自然。


六、但是如果 Data 层提前转换成 Hot Flow 呢?

假设我们这样写:

class LoginRepository(
    private val dataSource: LoginDataSource,
    private val scope: CoroutineScope
) {
    val loginState: StateFlow<LoginState> =
        dataSource.observeLoginState()
            .stateIn(scope)
}

这时候,相当于在供水系统里增加了一个“总阀门”:

自来水厂
   ↓
总阀门
   ↓
你家的水龙头

问题就变成:

这个总阀门由谁控制?

如果总阀门由自来水厂控制,那么:

自来水厂决定你家的水什么时候开始供、什么时候停止供。

这听起来就有些奇怪。

对应到我们的代码:

Data Layer
    ↓
Hot Flow
    ↓
Consumer

Data 层实际上开始决定:

  • Flow 什么时候运行;
  • Flow 什么时候停止;
  • Flow 应该活多久;
  • 上游订阅应该持续多久。

于是我们就应该问:

为什么这些事情应该由 Data 层决定?


七、生命周期本身也是一种职责

我们经常讨论架构中的职责:

  • 谁负责访问数据库?
  • 谁负责网络请求?
  • 谁负责数据转换?
  • 谁负责业务逻辑?

但还有一个经常被忽略的问题:

谁负责决定一个东西应该活多久?

生命周期本身也是一种职责。

当我们写:

flow.stateIn(repositoryScope)

实际上就在做一个架构决策:

这个 Flow 的生命周期跟 Repository Scope 绑定。

如果这个 Repository Scope 是一个长期存在的 Scope,那么这个 Flow 也可能长期存在。

这意味着 Data 层不只是:

“提供一个登录状态。”

而是在说:

“我来决定这个登录状态数据流的生命周期。”

这就是问题所在。


八、并不是说 Data 层永远不能转换 Hot Flow

这里必须强调一个非常重要的限定。

Data 层不是绝对不能转换 Cold Flow。

如果 Data 层本身确实拥有这个数据流的生命周期,而且确实存在共享、缓存或者独立运行的需求,那么:

dataSource.observeLoginState()
    .stateIn(repositoryScope)

完全可能是合理的。

真正值得警惕的是:

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:

class LoginRepository {

    private val scope = CoroutineScope(...)

    val loginState =
        dataSource.observeLoginState()
            .stateIn(scope)
}

因为这样一来:

App 生命周期
      ↓
Repository
      ↓
Hot Flow

这个关系就被隐藏起来了。

调用者甚至很难知道:

这个 Flow 为什么一直活着?

它什么时候才会结束?

谁负责取消它?

为什么 Repository 要拥有这个生命周期?

如果生命周期确实重要,那么它就应该成为一个明确的架构决策,而不是 Data 层内部的一个实现细节。


十、过早转换成 Hot Flow,还可能产生真实的工程问题

生命周期问题并不只是理论上的架构讨论。

假设 Flow 的数据来源是一个外部 Service:

External Service
       ↓
   DataSource
       ↓
    Cold Flow
       ↓
   Repository
       ↓
    Hot Flow

DataSource 可能通过 Binder、Socket 或其他机制建立一个外部订阅。

例如:

fun observeData(): Flow<Data> = callbackFlow {
    // 建立外部订阅

    awaitClose {
        // 解除订阅
    }
}

对于 Cold Flow 来说,消费者开始 collect 时:

建立订阅

消费者停止 collect 时:

解除订阅

整个过程比较自然。

但是,如果 Data 层把它提前转换成 Hot Flow:

observeData()
    .stateIn(repositoryScope)

那么外部订阅可能不再直接跟某个具体消费者绑定。


十一、外部 Service 重启之后,问题就出现了

假设外部 Service 突然重启:

External Service
       ↓
     重启
       ↓
旧订阅关系失效

但是:

repositoryScope
       ↓
    Hot Flow

仍然活着。

于是可能出现:

External Service 重启
        ↓
旧订阅关系失效
        ↓
Hot Flow 仍然存在
        ↓
RepositoryScope 仍然存在
        ↓
新的消费者开始 collect
        ↓
上游却没有重新建立有效订阅

最后出现一个非常诡异的问题:

Flow 还活着,但是数据已经死了。

这里需要特别说明:

这并不是说 Cold Flow 天然能够解决 Service 重启。

外部 Service 本身存在重启问题,我们依然需要设计重连机制。

真正的问题是:

Data 层过早把一个本来可以随着消费者生命周期建立和释放的外部订阅,变成了一个长期存在的 Hot Flow。

这样一来,一个本来应该被重新建立的连接关系,可能被错误地长期持有。

这就是生命周期设计在实际项目中的意义。


十二、所以真正的决策顺序应该是这样的

当我们拿到一个 Flow 时,不应该直接思考:

“这个 Flow 应该在哪一层 stateIn?”

而应该先问:

我真的需要 Hot Flow 吗?

如果不需要:

Flow
 ↓
保持 Cold Flow

如果确实需要:

Flow
 ↓
为什么需要?
 ├── 共享上游?
 ├── 缓存当前值?
 ├── 独立生命周期?
 └── 其他明确需求?
        ↓
   转换成 Hot Flow
        ↓
谁拥有这个生命周期?
        ↓
选择合适的 Scope

也就是说:

先决定“要不要转换”,再决定“在哪里转换”。

而不是:

Data Layer
    ↓
拿到 Flow
    ↓
先 stateIn()
    ↓
再交给上层

十三、Cold Flow 不是 Hot Flow 的低级版本

我觉得这是整个问题中最重要的认知转变。

我们很容易形成这样的心理模型:

Cold Flow
    ↓
   升级
    ↓
Hot Flow

好像 Cold Flow 是比较原始的东西,而 Hot Flow 才是最终形态。

实际上并不是。

它们只是不同的执行模型。

Cold Flow 更接近:

谁需要,谁启动;谁不需要,谁停止。

Hot Flow 更接近:

数据流可以脱离某一个具体消费者独立存在,并由一个明确的生命周期控制。

所以:

Cold Flow

并不意味着:

“这个 Flow 还没设计好。”

而是:

这个 Flow 就应该按照消费者的生命周期运行。

同样:

Hot Flow

也不意味着:

“这个 Flow 设计得更高级。”

它只是意味着:

我们确实需要让这个数据流拥有独立的运行生命周期。


十四、回到自来水厂

现在再回头看这个比喻,就很清楚了。

Cold Flow:

自来水厂
   ↓
管道
   ↓
你家的水龙头

你需要水:

打开水龙头

你不需要:

关闭水龙头

这非常自然。

如果我们确实需要一个总供水系统:

自来水厂
   ↓
总阀门
   ↓
你家的水龙头

也没有问题。

问题只是:

谁应该控制这个总阀门?

如果确实需要整个小区共享一个总供水系统,那么当然应该有人负责管理它。

但我们不能因为:

“以后可能会用水。”

就让自来水厂提前把总阀门打开。

同样,我们也不应该因为:

“以后可能有多个消费者。”

“这个数据业务上是一个状态。”

“StateFlow 用起来比较方便。”

就让 Data 层提前把 Cold Flow 转换成 Hot Flow。


十五、最后

我并不反对 StateFlowSharedFlowstateInshareIn

这些都是非常有用的工具。

我真正想避免的是一种下意识的设计方式:

看到 Flow,就想着把它转换成 Hot Flow。

在转换之前,不妨先问三个问题:

1. 我真的需要 Hot Flow 吗?

如果没有共享、缓存、独立生命周期等明确需求,那么 Cold Flow 可能已经足够。

2. 我为什么需要 Hot Flow?

明确需求之后,我们才能决定应该使用 stateIn 还是 shareIn,以及采用什么样的 Sharing 策略。

3. 谁应该拥有它的生命周期?

因为:

flow.stateIn(scope)

真正重要的并不只是:

Flow → StateFlow

而是:

Flow
 ↓
拥有一个独立的运行生命周期
 ↓
这个生命周期由 Scope 决定

所以:

Cold Flow 不是等待被转换成 Hot Flow 的半成品。

没有明确需求,就保持 Cold Flow。

确实需要 Hot Flow,再讨论在哪里转换。

而一旦转换,就应该明确:这个生命周期究竟应该由谁拥有。

毕竟:

你家的水龙头,应该由你自己决定什么时候打开。

不要让自来水厂替你决定。