我之前在这篇故事里说到,实习生阿泽在经历项目锻炼后写了一篇文章-《我为什么让 Toast 多弹了一次》,对,就是这篇!
现在,让阿泽来当主人公,主动讲述那 "Toast 多弹了一次" 背后的故事。
从 Toast 多弹一次说起
有一类 Bug 很容易让人怀疑人生:代码看起来没问题,业务逻辑也没问题,甚至第一次运行也完全正常。
直到你旋转了一下屏幕,或者从后台切回前台,Toast 又弹了一次。
那天下午,测试同学小安姐提了一个 bug:会员中心页面的 Toast 会重复弹出。
复现步骤很简单:
- 进入会员中心
- 断网
- 点击重试
- 出现"会员信息加载失败"的 Toast
- 旋转屏幕
- Toast 再次出现
我看着这条 bug,心想:这不可能啊,我明明用的是 LiveData,生命周期感知的,怎么会重复?这也是领导安排我这么做的,难道领导也有问题?
但实际上:LiveData 本身没有错,错在我把它当成了事件总线。
LiveData 更适合表达"状态",而 Toast、Snackbar、页面跳转、弹窗这类行为更像"一次性事件"。
状态可以被重复读取,事件通常只能被消费一次。
一个普通的 LiveData
事情要从一周前说起。当时我在做会员中心的 MVVM 改造,把 Activity 里的逻辑拆到了 ViewModel。
状态管理用的是 LiveData,代码大概是这样:
kotlin
data class MemberUiState(
val userName: String = "",
val levelName: String = "",
val errorMessage: String? = null
)
class MemberCenterViewModel(
private val repository: MemberRepository
) : ViewModel() {
private val _uiState = MutableLiveData(MemberUiState())
val uiState: LiveData<MemberUiState> = _uiState
fun loadMemberInfo() {
_uiState.value = _uiState.value?.copy(loading = true)
viewModelScope.launch {
runCatching { repository.getMemberInfo() }
.onSuccess { info ->
_uiState.value = MemberUiState(
userName = info.userName,
levelName = info.levelName,
)
}
.onFailure {
_uiState.value = MemberUiState(errorMessage = "会员信息加载失败")
}
}
}
}
Activity 里这样观察:
kotlin
viewModel.uiState.observe(this) { state ->
state.errorMessage?.let { message ->
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
看起来很完美,对吧?
ViewModel 持有状态,View 只负责渲染。旋转屏幕后,ViewModel 不会销毁,状态保留,用户体验流畅。
但问题也埋在这里:从业务需求上来讲,errorMessage 里保存的不是一个普通状态,而是一次性动作。它不是"当前页面显示错误信息",而是"请立刻弹一个 Toast"。
一个小陷阱
我仔细排查了一圈,发现问题不在代码错误,而在 LiveData 的一个特性:粘性事件。
什么是粘性事件?
简单说,新注册的观察者会立即收到 LiveData 中当前存储的最新值。
官方文档明确说明:当生命周期从非活跃回到活跃,或者 Activity/Fragment 因配置变更被重建时,观察者会收到最新可用的数据。
旋转屏幕后,Activity 重建,重新观察 LiveData,拿到了上次的 errorMessage,于是 Toast 又弹了一次。
这不是 LiveData 的 bug,是设计如此!!!
当年针对这个还有一个专有名词 ------ 数据倒灌。
Google 设计 LiveData 的职责就是同步状态,而不是发送事件。
所以,此时的用法就会有一些冲突:
- 状态的目标是"最新值可恢复"。比如屏幕旋转后,页面仍然应该显示上一次加载出来的用户信息。
- 事件的目标却是"发生过就结束"。比如 Toast 已经弹过了,它不应该因为观察者重建就重新发生。
我把事件当状态用了,当然会出问题。不是 LiveData 弹错了,是我让它承担了不适合的职责。
加个 Event 包装

当时没有思考那么多,迅速在网上寻找解决方案。幸好,很多人都碰到过这个坑。
找到原因后,解决办法就有了。不要直接保存 String,而是保存一个"可消费"的事件对象。事件被消费一次后,再次观察时就不会重复执行:
kotlin
open class Event<out T>(private val content: T) {
var hasBeenHandled = false
private set
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
fun peekContent(): T = content
}
ViewModel 改成:
kotlin
private val _toastEvent = MutableLiveData<Event<String>>()
val toastEvent: LiveData<Event<String>> = _toastEvent
fun showError(message: String) {
_toastEvent.value = Event(message)
}
Activity 里这样消费:
kotlin
viewModel.toastEvent.observe(this) { event ->
event.getContentIfNotHandled()?.let { message ->
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
问题解决了。Toast 只会弹一次,旋转屏幕后不会重复。
这个方案能解决问题,而且它也把意图表达出来了:这里不是状态,而是一个事件。
Google Android Developers 早期也讨论过 SingleLiveEvent 和 EventWrapper 这类方案,其中 EventWrapper 的优势是能显式表达"是否已处理"的语义。
但说实话,它还是不够优雅。
每次发事件都要包一层 Event,消费时还要调用 getContentIfNotHandled(),样板代码太多。而且如果有多处观察同一个事件,只有第一个观察者能收到,后续观察者会被忽略。业务代码里到处都是 getContentIfNotHandled(),读起来会有一点别扭。
更关键的是:我们为了把 LiveData 改造成事件工具,写了一层额外语义。
既然问题是"我需要一个事件流",那有没有一个东西本来就是为这个场景准备的?
遇见 SharedFlow
后来项目开始使用 Kotlin 协程,我接触到了 SharedFlow。
第一次用它处理事件时,我就感觉:这玩意儿就是为事件而生的。
同样的场景,用 SharedFlow 实现:
kotlin
sealed interface UiEvent {
data class Toast(val message: String) : UiEvent
data object NavigateBack : UiEvent
}
class MemberCenterViewModel(
private val repository: MemberRepository
) : ViewModel() {
private val _uiEvent = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val uiEvent: SharedFlow<UiEvent> = _uiEvent.asSharedFlow()
fun loadMemberInfo() {
viewModelScope.launch {
runCatching { repository.getMemberInfo() }
.onFailure {
_uiEvent.emit(UiEvent.Toast("会员信息加载失败"))
}
}
}
}
Activity 里这样收集:
kotlin
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiEvent.collect { event ->
when (event) {
is UiEvent.Toast -> {
Toast.makeText(this@MemberCenterActivity, event.message, Toast.LENGTH_SHORT).show()
}
UiEvent.NavigateBack -> findNavController().navigateUp()
}
}
}
}
没有 Event 包装,没有 getContentIfNotHandled(),代码干净了很多。
为什么 SharedFlow 不会重复?
因为它默认不缓存历史数据。replay = 0,新订阅者只能收到订阅后发出的事件,之前的事件不会重放。页面重建后重新收集事件,也不会因为上一条 Toast 还"躺在状态里"而再次弹出。
这并不是说 SharedFlow 天然比 LiveData 高级,而是它更贴合"一次性事件"这个问题。
工具本身没有贵贱,关键是语义是否匹配。
详细说说 SharedFlow
既然用到了 SharedFlow,就简单介绍一下它的参数。
SharedFlow 可以理解为一个可配置的热事件流。
热流的意思是:它的实例独立于 collector 存在,不像普通 cold Flow 那样每次 collect 才重新执行上游逻辑。
常见写法是 ViewModel 内部持有 MutableSharedFlow,外部只暴露只读的 SharedFlow:
kotlin
val flow = MutableSharedFlow<Event>(
replay = 0,
extraBufferCapacity = 0,
onBufferOverflow = BufferOverflow.SUSPEND
)
- replay :新订阅者能收到的历史数据数量。默认是
0,表示不缓存。如果设为1,新订阅者会立即收到最近一次的值,类似LiveData的行为。对于 Toast、导航、弹窗等一次性事件,通常设为0。 - extraBufferCapacity :额外缓冲容量。当生产者速度比消费者快时(解决背压问题,这里不做展开),可以临时缓存一些数据。默认是
0,不缓存。给一个缓冲可以给事件流一点缓冲余地,避免在 collector 尚未启动时发出的事件丢失。 - onBufferOverflow :缓冲区满时的处理策略:
SUSPEND:挂起生产者,等待缓冲区有空间(默认)DROP_OLDEST:丢弃最旧的数据,插入新数据DROP_LATEST:丢弃最新的数据,保留旧数据
对于事件流,通常这样配置:
kotlin
val eventFlow = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
replay = 0 确保新订阅者不收到历史事件,extraBufferCapacity 给一个缓冲,DROP_OLDEST 在极端情况下丢弃旧事件。
对于 Toast 这种弱事件,很多时候 DROP_OLDEST 更符合直觉:如果短时间内来了很多提示,保留更新的提示可能更有意义。
这里还有一个实用的区别需要注意:emit 与 tryEmit。emit 是挂起函数,可以等待缓冲或 collector,这意味着这个函数只能在协程中运行;tryEmit 立即返回 Boolean,表示这次发送是否成功。
业务事件通常用 emit 更稳,除非你明确希望"发不出去就算了"。
一个实用经验是:UI 一次性事件可以先从 replay = 0 开始。是否需要 extraBufferCapacity,要看事件是否可能在 collector 尚未启动时发出,以及你能否接受丢失。
不要为了"保险"随手把 replay 设成 1,否则你可能又把事件变成了会重放的状态。
提一句 StateFlow
既然 SharedFlow 处理事件,那状态管理呢?
StateFlow 就是为此而生的。
StateFlow 是 SharedFlow 的特化版本,专门用于状态管理:
kotlin
data class MemberUiState(
val loading: Boolean = false,
val userName: String = "",
val levelName: String = "",
val errorMessage: String? = null
)
class MemberCenterViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MemberUiState())
val uiState: StateFlow<MemberUiState> = _uiState.asStateFlow()
fun loadMemberInfo() {
_uiState.update { it.copy(loading = true) }
viewModelScope.launch {
runCatching { repository.getMemberInfo() }
.onSuccess { info ->
_uiState.update {
MemberUiState(
userName = info.userName,
levelName = info.levelName,
)
}
}
.onFailure {
_uiState.update { MemberUiState(errorMessage = "加载失败") }
}
}
}
}
View 层收集 StateFlow:
kotlin
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
StateFlow 和 SharedFlow 的主要区别:
| 特性 | StateFlow | SharedFlow |
|---|---|---|
| 初始值 | 必须有 | 可选 |
| 缓存 | 始终保留最新值 | 可配置 replay |
| 防抖 | 自动防抖(相同值不触发) | 无防抖 |
| 适用场景 | 状态管理 | 事件分发 |
StateFlow 有几个特点值得注意:
- 它必须有初始值,所以你需要先定义页面的初始状态
- 它有
value属性,可以直接读取当前状态 - 它会基于
equals做去重,新的值和旧的值相等时,不会重复通知 collector - 它会向新 collector 发送当前最新状态,这正是页面重建后恢复 UI 的关键
所以一个简单的分工是:
StateFlow 放"页面现在是什么样":加载中、内容、错误、表单输入、列表数据、按钮是否可用。
SharedFlow 放"页面现在要做什么":弹 Toast、跳转、打开弹窗、触发震动、发送一次埋点。
一点想法
回到最初的问题:为什么 Toast 会多弹一次?
表面上看,是我把事件当状态用了。往深了想,是没分清"状态"和"事件"的区别。
当然,这并不是一个多复杂的 Bug。它真正有价值的地方在于,它逼我重新区分了状态和事件。
LiveData 没有错。它的设计目标就是让 UI 在生命周期变化后仍然拿到最新数据。屏幕旋转后还能显示正确内容,是它的能力;屏幕旋转后 Toast 又弹一次,是我把一次性动作塞进了状态容器。
EventWrapper 也没有什么不好。它是一种务实的补救方案,尤其在项目还以 LiveData 为主时,能用较小成本解决重复消费问题。
只是当项目已经使用 Kotlin Coroutines 时,SharedFlow 会让"事件"这件事表达得更自然。
StateFlow 和 SharedFlow 也不是要完全替代 LiveData。更准确的说法是:我们有了更细的工具,可以把"状态"和"事件"拆开建模。
- 状态要可恢复,事件要可消费
- 状态关心最新值,事件关心发生过
- 状态可以重放,事件通常不该重放
工具是死的,人是活的。但选对工具,能少走很多弯路。
这篇文章的灵感来自一个真实的 bug。那个 Toast 多弹了一次的下午,让我重新思考了状态和事件。希望对你也有启发。
参考资料
- Android Developers:LiveData overview --- developer.android.com/topic/libra...
- Android Developers:StateFlow and SharedFlow --- developer.android.com/kotlin/flow...
- Kotlin kotlinx.coroutines API:SharedFlow --- kotlinlang.org/api/kotlinx...
- Kotlin kotlinx.coroutines API:MutableSharedFlow --- kotlinlang.org/api/kotlinx...
- Kotlin kotlinx.coroutines API:StateFlow --- kotlinlang.org/api/kotlinx...
- Android Developers Blog:LiveData with SnackBar, Navigation and other events --- medium.com/androiddeve...