【Kotlin 协程修仙录 · 化神境 · 初阶】 | 热流真意:StateFlow 与 SharedFlow 的响应式状态管理

0 阅读9分钟

image_11.png

前言

元婴大圆满,你已驯服 Flow 的数据洪流。冷流的懒加载、背压的取舍之道,你皆了然于胸。你能用 Flow 构建精密的管道,数据从源头到终端,流转自如。

然而,一个尴尬的场景正在你的代码中潜伏:

商品详情页,你用 flow {} 发起网络请求,并在 Compose UIcollectAsState() 收集。一切正常。但当屏幕旋转时,Activity 重建,Flow重新收集,网络请求再次发起。用户眼睁睁看着 Loading 闪烁两次,流量被白白浪费。

你猛然想起 Flow 是冷流——每次 collect 都触发一次全新的数据生产。你需要的是一种热流——数据生产独立于收集者,多个收集者共享同一份数据源,屏幕旋转后能直接拿到最新的缓存值。

这正是 Kotlin 协程为你准备的两种热流武器:

StateFlow:一个有状态的、可观察的数据持有者。它始终持有一个当前值,新收集者立即收到当前值。它是 LiveData 的协程版替代品,专为 UI 状态管理而生。

SharedFlow:一个可配置缓存和重放策略的、多播的事件流。它不持有“当前值”,但可以缓存历史事件,新收集者可选是否接收历史。它是 EventBus 的协程版替代品,专为事件分发而生。

本讲是化神境的初阶修炼。你将:

  • 彻底搞懂冷流与热流的本质差异,以及何时该用哪种。
  • 掌握 MutableStateFlow 的用法,理解其“状态”语义。
  • 学会用 stateIn 将冷流转换为热流,避免重复请求。
  • 初探 MutableSharedFlow,理解其与 Channel 的区别。

准备好点燃热流之火了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


冷流 vs 热流:一个决定生死的关键差异

在深入 API 之前,我们必须先建立对“冷”与“热”的精准直觉。

对比维度冷流(Flow)热流(StateFlow / SharedFlow)
执行时机每次 collect重新触发生产生产独立于收集者,持续运行
数据共享不同收集者收到独立的数据流所有收集者共享同一份数据源
历史值无历史,只接收收集之后发射的值StateFlow 始终有当前值;SharedFlow 可配置重放数量
资源消耗不收集时不消耗资源即使无收集者,也可能消耗资源
典型场景网络请求、数据库查询UI 状态、事件总线、传感器数据
flowchart LR
    subgraph Cold[冷流 每次独立]
        direction TB
        C1[收集者1] --> CF1[触发生产1]
        C2[收集者2] --> CF2[触发生产2]
        CF1 --> R1[结果A]
        CF2 --> R2[结果A']
    end
    
    subgraph Hot[热流 共享生产]
        direction TB
        HP[持续生产] --> HS[共享数据源]
        HS --> H1[收集者1]
        HS --> H2[收集者2]
        H1 --> SR1[相同结果]
        H2 --> SR2[相同结果]
    end
    
    style Cold fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Hot fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style C1 fill:#90caf9
    style C2 fill:#90caf9
    style HP fill:#ef9a9a
    style HS fill:#ffb74d
  • 冷流视频点播:每个用户点击播放,服务器都从头发送一条独立的视频流。
  • 热流电视直播:电视台持续发射信号,无论多少台电视打开,接收的都是同一画面。

在 Android 中,屏幕旋转导致 UI 重建,你通常希望数据不要重新加载,而是直接展示缓存——这正是热流的用武之地。


StateFlow:有状态的 UI 数据持有者

什么是 StateFlow

在官方定义中:

StateFlow 是一个有状态的、可观察的、只读的数据流。它始终持有一个当前值value 属性),并会在值变化时通知所有收集者。新收集者会立即收到当前值

它本质上是一个带有 Flow 能力的 LiveData,但摆脱了 Android 平台的依赖,且更深度集成协程。

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

// 私有可变版本,用于内部更新
private val _uiState = MutableStateFlow(UiState.Loading)
// 公开只读版本,暴露给 UI
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

fun updateState(newState: UiState) {
    _uiState.value = newState // 更新值,自动通知所有收集者
}
sequenceDiagram
    participant VM as ViewModel
    participant SF as MutableStateFlow
    participant UI1 as UI 收集者1
    participant UI2 as UI 收集者2(后加入)

    VM->>SF: value = Loading
    UI1->>SF: collect
    SF-->>UI1: 立即收到 Loading
    
    VM->>SF: value = Success
    SF-->>UI1: 收到 Success
    
    UI2->>SF: collect(后加入)
    SF-->>UI2: 立即收到 Success(当前值)

StateFlow 的核心特性

  1. 强一致性value 的更新是原子的,且 collect 收到的值序列严格单调(不会收到旧值)。
  2. 去重:如果新值与当前值相等(通过 equals 判断),则不会通知收集者。
  3. 粘性:新收集者总是先收到当前值,再收到后续更新。
val state = MutableStateFlow(0)
state.value = 1
state.value = 1 // 第二次赋相同值,不会触发通知

Android 实战:用 StateFlow 管理 UI 状态

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

class UserViewModel(
    private val userRepo: UserRepository
) : ViewModel() {

    sealed class UiState {
        object Loading : UiState()
        data class Success(val user: User) : UiState()
        data class Error(val message: String) : UiState()
    }

    // 私有可变,公开只读
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    init {
        loadUser()
    }

    fun loadUser() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val user = userRepo.getUser()
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "未知错误")
            }
        }
    }
}

Compose UI 中使用 collectAsState() 收集:

@Composable
fun UserScreen(viewModel: UserViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    
    when (uiState) {
        is UiState.Loading -> CircularProgressIndicator()
        is UiState.Success -> Text("用户名:${uiState.user.name}")
        is UiState.Error -> Text("错误:${uiState.message}", color = Color.Red)
    }
}

关键优势:屏幕旋转时,collectAsState() 重新订阅,会立即收到 StateFlow 的当前值(缓存的数据),无需重新请求网络。


stateIn:将冷流转化为热流

实际开发中,数据源往往是冷流(如 Room 的 Flow 查询、Retrofit 的 suspend 函数转换的 Flow)。我们不想每次收集都重新查询数据库或请求网络。

stateIn 操作符正是为此而生:将冷流转换为热 StateFlow

val userFlow: Flow<User> = userRepo.observeUser() // 冷流

val userStateFlow: StateFlow<User?> = userFlow
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = null
    )

SharingStarted 三种策略

started 参数决定上游冷流何时开始执行、何时停止。

策略行为适用场景
Eagerly立即启动,永不停止数据需要全局持续更新(如全局配置)
Lazily第一个收集者出现时启动,永不停止数据初始化后始终存在(如用户信息)
WhileSubscribed至少有一个收集者时运行,无收集者后延迟停止Android 推荐,节省资源
// 典型用法:最后一个订阅者离开 5 秒后,取消上游冷流
.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000),
    initialValue = emptyList()
)
timeline
    title SharingStarted.WhileSubscribed 生命周期
    section 无订阅者
        初始状态 : 上游停止
    section 第一个订阅者加入
        订阅触发 : 上游启动,开始执行
    section 最后一个订阅者离开
        等待超时 : 上游继续,等待 5 秒
    section 5 秒后仍无订阅者
        超时关闭 : 上游停止,释放资源

实战:Room 查询转为 StateFlow

@Dao
interface UserDao {
    @Query("SELECT * FROM user WHERE id = :id")
    fun getUserFlow(id: String): Flow<User> // Room 返回冷流
}

class UserViewModel(
    private val userDao: UserDao
) : ViewModel() {
    
    val user: StateFlow<User?> = userDao
        .getUserFlow("123")
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = null
        )
}

这样,多个 UI 组件收集同一个 user StateFlow,Room 查询只执行一次,数据变化时自动更新所有 UI。


SharedFlow:事件总线的协程版替代品

什么是 SharedFlow

SharedFlow 是一个可配置缓存与重放策略的、多播的事件流。它不持有“当前值”,但可以缓存历史事件,新收集者可选择接收历史事件。

它适合一次性事件:导航指令、Toast 提示、埋点上报。

private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()

suspend fun sendEvent(event: Event) {
    _events.emit(event) // 发射事件,所有收集者都会收到
}

配置参数详解

val sharedFlow = MutableSharedFlow<Int>(
    replay = 1,           // 缓存最近 1 个值,新订阅者立即收到
    extraBufferCapacity = 10, // 额外缓冲区大小
    onBufferOverflow = BufferOverflow.DROP_OLDEST // 缓冲区满时丢弃最旧
)
参数含义推荐值
replay新收集者能收到的历史事件数量0(不重放)或 1(如 Toast 防止丢失)
extraBufferCapacityreplay 外的缓冲区容量根据并发量设置
onBufferOverflow缓冲区满时的处理策略DROP_OLDESTSUSPEND

与 Channel 的对比

对比项SharedFlowChannel
多播支持,多个收集者都收到不支持,每个值只能被一个消费者接收
缓存/重放支持不支持
用途事件分发协程间一对一或一对多通信
flowchart LR
    subgraph SF[SharedFlow 多播]
        E1[emit 事件] --> S[SharedFlow]
        S --> C1[收集者A]
        S --> C2[收集者B]
        S --> C3[收集者C]
    end
    
    subgraph CH[Channel 单播]
        E2[发送] --> CHL[Channel]
        CHL --> R[只有一个接收者]
    end
    
    style SF fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style CH fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px

实战:用 SharedFlow 发送导航事件

class LoginViewModel : ViewModel() {
    
    sealed class Event {
        object NavigateToHome : Event()
        data class ShowToast(val message: String) : Event()
    }
    
    private val _events = MutableSharedFlow<Event>(extraBufferCapacity = 10)
    val events: SharedFlow<Event> = _events.asSharedFlow()
    
    fun login(username: String, password: String) {
        viewModelScope.launch {
            try {
                // 登录逻辑...
                _events.emit(Event.NavigateToHome)
            } catch (e: Exception) {
                _events.emit(Event.ShowToast("登录失败"))
            }
        }
    }
}

// Compose UI 中收集
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is Event.NavigateToHome -> navController.navigate("home")
                is Event.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
            }
        }
    }
}

注意:使用 SharedFlow 处理导航时,需确保 LaunchedEffect 在组件生命周期内,避免事件丢失。也可使用 replay = 1 防止配置变更导致事件丢失。


shareIn:将冷流转为 SharedFlow

stateIn 类似,shareIn 将冷流转换为热 SharedFlow。

val coldFlow: Flow<Int> = flow { ... }

val sharedFlow: SharedFlow<Int> = coldFlow
    .shareIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(),
        replay = 1
    )

适用场景对比

转换目标操作符是否有当前值典型用途
StateFlowstateInUI 状态
SharedFlowshareIn事件流、传感器数据
flowchart LR
    subgraph Cold[冷流]
        C[Flow]
    end
    
    subgraph Hot[热流转换]
        direction TB
        SI[stateIn] --> SF[StateFlow 状态]
        SH[shareIn] --> SHF[SharedFlow 事件]
    end
    
    C --> SI
    C --> SH
    
    style Cold fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Hot fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style SI fill:#90caf9
    style SH fill:#90caf9
    style SF fill:#a5d6a7
    style SHF fill:#a5d6a7

常见错误与避坑指南

错误 1:在 ViewModel 中用 StateFlow 存放大对象列表且频繁更新

// ❌ 每次更新都创建新列表,触发所有收集者重组
_state.value = _state.value.copy(items = newList)

优化:使用 update 函数或确保 equals 正确实现。

// ✅ 使用 MutableStateFlow.update
_state.update { it.copy(items = newList) }

错误 2:忘记设置 replay,导致 SharedFlow 事件在配置变更后丢失

val events = MutableSharedFlow<Event>() // replay=0,新订阅者收不到历史

修复:对于重要的一次性事件(如导航),设置 replay = 1

错误 3:在 stateIn 中使用 Eagerly 导致不必要的资源消耗

.stateIn(viewModelScope, SharingStarted.Eagerly, null) // 即使无 UI 也在运行

推荐:Android 中优先 WhileSubscribed()

错误 4:在 Compose 中直接 collect 而非 collectAsState

var state by remember { mutableStateOf(initial) }
LaunchedEffect(Unit) {
    viewModel.uiState.collect { state = it } // 写法繁琐
}

正确val state by viewModel.uiState.collectAsState()


最佳实践

  1. UI 状态用 StateFlow,事件用 SharedFlow:清晰区分“是什么”与“发生了什么”。
  2. 始终通过 asStateFlow() / asSharedFlow() 暴露只读版本:防止外部意外修改。
  3. 在 ViewModel 中使用 stateIn 转换冷流:避免重复请求,管理生命周期。
  4. SharedFlow 的 replay 按需设置:Toast 等建议 replay = 0,导航建议 replay = 1
  5. 使用 WhileSubscribed 作为 SharingStarted 策略:兼顾实时性与资源节省。
  6. 对于 StateFlow,使用 update 函数原子更新:避免竞态。

总结与下回预告

恭喜,你已点燃热流之火,化神境初阶修炼完成!

本讲核心收获

  • 冷流每次收集独立执行,热流共享同一数据源。
  • StateFlow 持有当前值,适合 UI 状态,粘性去重。
  • MutableStateFlow 内部可写,asStateFlow() 暴露只读。
  • stateIn 将冷流转为 StateFlow,SharingStarted.WhileSubscribed 是 Android 首选。
  • SharedFlow 支持多播、缓存、重放,适合事件分发。
  • shareIn 将冷流转为 SharedFlow。

在下一讲 【化神境·中阶】 中,我们将深入学习 SharedFlow 的高级配置:缓冲区策略、重放深度、与 Channel 的互操作。届时你会明白:

  • SharedFlowreplayCache 如何工作?
  • 何时该用 SharedFlow 而非 BroadcastChannel
  • 如何在多个协程间高效广播事件?

【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
化神境 · 初阶1、StateFlow 状态真意
2、stateIn 冷热转换诀
3、SharedFlow 多播术
当前进度30%
修为300/1000
下一突破[化神境 · 中阶] (需领悟:SharedFlow 高级配置、replayCache 机制、与 Channel 对比)
冷流是点播,热流是广播。StateFlow有记忆,SharedFlow可重放。选对了,事半功倍。

【本讲思考题】

1、表象题:以下代码有什么问题?

val state = MutableStateFlow(0)
state.value = 1
state.value = 1
println(state.value)

2、场景题:你需要在 ViewModel 中监听一个每秒更新的位置 Flow,并在多个 Compose 组件中显示。如何设计以避免重复请求和内存泄漏?写出核心代码。

3、原理题StateFlow 是如何实现“新值相等时不通知收集者”的?equals 比较在何处执行?请结合源码简述。


道友,化神境的大门已敞开。掌握了热流,你的响应式架构将如虎添翼。化神境·中阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论