关于 kotlin-flow-state-event-modeling
帮助 Kotlin 开发者选择 Flow 原语来建模状态与事件,依据重放、投递和同步读取需求避开常见的共享状态问题。
主要功能
- 01说明 StateFlow、SharedFlow、Channel 和 Flow 的语义
- 02指导建模缺失与加载状态
- 03展示原子化的 StateFlow 更新
- 04介绍 stateIn 共享及启动策略
- 05说明如何保留同步 value 访问
适用场景
- 01建模一次性导航或 Snackbar 事件
- 02为同步读取选择状态流
- 03避免并发协程造成更新丢失
- 04审查 Flow 共享方式与占位状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill kotlin-flow-state-event-modeling安装后在 Claude Code 中输入/kotlin-flow-state-event-modeling确认已加载。
详细介绍
Kotlin Flow:状态与事件建模
核心原则
选择符合重放、扇出和同步读取需求的基本类型。 StateFlow、SharedFlow、由 Channel 支撑的 Flow 和冷 Flow 在缓冲方式、每次发射的接收者,以及是否提供 .value 方面各不相同。选择不当会导致事件丢失、共享协程泄漏,或不得不把虚假的领域哨兵值塞进状态中。
何时使用此技能
当你编写或审查涉及以下内容的 Kotlin 代码时:
MutableStateFlow<T>(SomeSentinel)——例如NoUser、Empty、Loading等,因为真实值需要异步获取- 在函数内部调用
.stateIn(...),而不是将其赋给属性 - 在某个 Flow 上使用
SharingStarted.WhileSubscribed(...),但该 Flow 的.value会被同步读取且必须保持最新 - 使用
MutableSharedFlow处理导航事件、Snackbar 或其他一次性发射,而丢失这些事件会构成 bug - 在
StateFlow上使用.map { },但消费者仍然需要同步读取.value - 使用
MutableStateFlow.value = _state.value.copy(...),或在update { ... }中创建开销较大的对象
使用 SharedFlow 处理只由单个消费者接收的一次性事件
SharedFlow 默认没有重放缓冲区。如果发射的那一刻没有任何协程正在收集,事件就会丢失。对于处理导航或 Snackbar 等严格要求恰好一次的事件的单个 UI 消费者,将带缓冲的 Channel 作为 Flow 暴露通常更符合所需语义:
// ❌ BAD
private val _navEvents = MutableSharedFlow<NavigationEvent>()
val navEvents: SharedFlow<NavigationEvent> = _navEvents.asSharedFlow()
// ✅ GOOD
private val _navEvents = Channel<NavigationEvent>(Channel.BUFFERED)
val navEvents: Flow<NavigationEvent> = _navEvents.receiveAsFlow()
Channel.receiveAsFlow() 实现的是扇出,而非广播:有多个收集者时,每个事件只会传递给其中一个收集者。Channel.BUFFERED 的容量有限,因此发送可能会挂起,trySend 也可能失败。如果多个观察者都必须看到同一个事件,请使用显式状态、持久化存储,或经过有意配置的 SharedFlow。
StateFlow 被无效的哨兵默认值污染
StateFlow 强制要求初始值。当真实值需要异步获取时,开发者有时会虚构领域值,例如 NoUser、EmptyUser、占位 ID,导致每个消费者都不得不把这个哨兵值当作真实数据处理。
// ❌ BAD — sentinel leaks into the type
class UserSession(private val db: Db) {
private val _user = MutableStateFlow<User>(NoUser)
val user: StateFlow<User> = _user.asStateFlow()
init { scope.launch { _user.value = db.load() } }
}
一种解决方法是分阶段暴露:在真实值存在之前,不暴露 StateFlow。
// ✅ GOOD — bootstrap suspends; observers only see real users
class UserSession(private val db: Db) {
private var _user: MutableStateFlow<User>? = null
val user: StateFlow<User>
get() = checkNotNull(_user) { "Call login() first" }
suspend fun login() {
_user = MutableStateFlow(db.load())
}
}
如果缺失、加载或错误本身就是实际状态,请显式建模(例如 User?、sealed interface UserUiState、Result 等)。真正的问题在于虚假的领域值伪装成了真实数据,而不是每个初始值都有问题。
使用 update { ... } 修改 MutableStateFlow
优先使用 MutableStateFlow.update { current -> ... },而不是读取 .value 再写回。update 会基于最新状态原子地应用转换,避免多个协程修改同一状态时发生更新丢失。
// BAD — read/modify/write can lose concurrent updates.
_state.value = _state.value.copy(
selectedId = id,
details = details,
)
// GOOD — transform starts from the latest state.
_state.update { current ->
current.copy(
selectedId = id,
details = details,
)
}
除非创建对象需要依赖当前状态,否则请在 update 代码块外创建对象。更新 lambda 可能会重试,因此其中的高开销操作或副作用可能执行多次:
// GOOD — details does not depend on current state, so build it once.
val details = Details.from(response)
_state.update { current ->
current.copy(details = details)
}
// GOOD — derived value depends on current state, so compute it inside.
_state.update { current ->
val nextItems = current.items.replaceById(updatedItem)
current.copy(items = nextItems)
}
代码块应当是纯粹、快速的状态转换:不要在其中调用网络、写入数据库、执行带副作用的日志操作、生成随机 ID 或读取时间,除非这些值已在代码块外提前获取。
在函数内部调用 stateIn()
// ❌ BAD — new sharing coroutine every call
fun getPreferences(): StateFlow<Prefs> =
repo.prefsFlow.stateIn(scope, SharingStarted.Eagerly, Prefs.Default)
每次调用 getPreferences(),都会在 scope 上启动一个永不结束的新协程。反复读取很快就会导致性能急剧下降。
// ✅ GOOD — one shared instance, computed once
val preferences: StateFlow<Prefs> =
repo.prefsFlow.stateIn(viewModelScope, SharingStarted.Eagerly, Prefs.Default)
WhileSubscribed 与同步读取 .value
当没有活跃的收集者时,SharingStarted.WhileSubscribed(timeout) 会断开上游连接。断开期间,.value 返回最后缓存的值,这个值可能已过时,也可能仍是初始值。
**规则:**如果 .value 必须保持最新,或必须在没有活跃收集者时完成初始化,请使用 SharingStarted.Eagerly 或显式初始化。如果可以接受过时或缓存值,且消费者主要以异步方式收集,那么使用 WhileSubscribed 没有问题。
StateFlow 上的 .map 会丢失 .value
// ❌ BAD — `name.value` won't compile; it's now a plain Flow
val name: Flow<String> = userState.map { it.name }
如果需要同步读取 .value,请以 .stateIn(...) 结束链式调用:
// ✅ GOOD
val name: StateFlow<String> = userState
.map { it.name }
.stateIn(viewModelScope, SharingStarted.Eagerly, userState.value.name)
社区提供的“派生状态流”工具会在每次读取 .value 时执行转换——只有转换速度快且具备幂等性时才适用。默认使用 .stateIn(...)。
决策:选择哪种 Flow 类型?
| 需求 | 基础类型 |
|---|---|
| 始终有值的状态,既由异步收集器读取,也由同步代码读取 | StateFlow;如果 .value 很重要,通常使用 SharingStarted.Eagerly |
热流,有多个订阅者,不要求同步读取 .value | SharedFlow |
| 面向单个使用者的离散事件,要求恰好交付一次 | 可考虑 Channel(BUFFERED).receiveAsFlow() |
| 冷流,每次收集对应一个使用者 | 普通 Flow |
如果你想使用 SharedFlow,不妨先问问自己:丢弃一次发射会不会造成错误?有多少个使用者必须看到它?如果必须由一个使用者恰好处理一次,Channel 可能更合适。如果所有观察者都必须看到它,请将其建模为持久状态,或有意配置广播流。
快速参考
| 症状 | 问题 | 修复方案 |
|---|---|---|
MutableStateFlow<X>(FakeDomainValue) | 无效的占位默认值 | 明确建模“无值”状态,或使用分阶段初始化 |
将 MutableSharedFlow<Event> 用于单个使用者的导航/Snackbar | 默认事件流可能丢失事件 | 可考虑 Channel(BUFFERED).receiveAsFlow() |
fun foo() = flow.stateIn(...) | 每次调用都会创建共享协程 | 将其设为 val/共享实例 |
WhileSubscribed + 必须保持 .value 最新/已初始化 | 数据过时或只有初始数据 | 使用 SharingStarted.Eagerly 或显式初始化 |
将 stateFlow.map { ... } 作为状态使用 | 丢失 .value | 以 .stateIn(...) 结束链式调用 |
_state.value = _state.value.copy(...) | 非原子读取/修改/写入 | _state.update { it.copy(...) } |
在不使用当前状态的 update { ... } 中创建开销较大的对象 | 如果更新重试,相关操作可能重复 | 在 update 之前创建对象;只在其中保留依赖当前状态的转换 |
审查时的危险信号
| 想法 | 实际情况 |
|---|---|
“有多个订阅者,所以我们需要 SharedFlow” | 多个订阅者会改变语义。Channel.receiveAsFlow() 不是广播;应有意选择事件模型。 |
“我们会用 WhileSubscribed 来节省资源” | 只有在可以接受读取到过时或初始 .value 时才这样做。应用前请先确认。 |
| “我会用哨兵值,直到加载到真实数据” | 使用者会把它当作真实领域数据;最好显式建模 UI/状态,或采用分阶段处理。 |
“在 update 里创建新对象比较方便” | Lambda 可能会重试。除非对象依赖当前状态,否则应在外部创建。 |
相关内容
kotlin-control-flow— 在建模状态和事件时,选择when、保护条件、完备性检查、智能类型转换和提前返回。kotlin-coroutines-structured-concurrency— 作用域所有权、初始化时启动协程、即发即弃边界、取消、runBlockingcompose-side-effects— 在 Compose 中收集事件流并连接副作用compose-state-hoisting— 状态持有者向由状态驱动的普通 UI 暴露 Flow 的位置