LiveData 粘性事件与单次消费:从重复触发到可靠事件总线
在 Android MVVM 架构中,LiveData 是连接 ViewModel 与 UI 层的核心工具。但在实际项目中,LiveData 的"粘性事件"特性常常引发重复触发问题:旋转屏幕后 Toast 再次弹出、导航事件重复执行、对话框多次显示。本文将从 LiveData 粘性机制的根源出发,介绍单次消费事件的常见方案,并给出生产环境中可靠的事件总线实现。
一、LiveData 粘性事件的根源
1. 什么是粘性事件
LiveData 会保留最后一次 setValue/postValue 的值,当新的 Observer 注册时(例如页面重建后重新 observe),即使事件已经发生过,Observer 仍然会收到这个"旧值"。
class MyViewModel : ViewModel() {
private val _toastEvent = MutableLiveData<String>()
val toastEvent: LiveData<String> = _toastEvent
fun showToast() {
_toastEvent.value = "操作成功"
}
}
// Activity
viewModel.toastEvent.observe(this) { message ->
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
问题场景:
- 点击按钮触发
showToast(),弹出 Toast - 旋转屏幕,Activity 重建,
observe再次执行 - Observer 收到旧值
"操作成功",Toast 再次弹出
2. 为什么会这样
LiveData 的设计目标是状态同步,而非事件分发。它假设数据是"当前状态"(例如用户信息、加载状态),新 Observer 应该立即获取最新状态。但对于"一次性事件"(例如导航、Toast、对话框),这种行为就成了 bug。
二、常见解决方案与问题
方案 1:手动标记消费状态
data class Event<out T>(private val content: T) {
private var hasBeenHandled = false
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
fun peekContent(): T = content
}
// ViewModel
private val _navigateEvent = MutableLiveData<Event<String>>()
val navigateEvent: LiveData<Event<String>> = _navigateEvent
fun navigateToDetail() {
_navigateEvent.value = Event("detail_screen")
}
// Activity
viewModel.navigateEvent.observe(this) { event ->
event.getContentIfNotHandled()?.let { route ->
// 只执行一次
findNavController().navigate(route)
}
}
问题:
- 每次都要包装
Event,增加模板代码 peekContent()容易误用,绕过消费机制- 多个 Observer 场景下,第一个消费后其他 Observer 拿不到数据
方案 2:observe 前先 removeObserver
viewModel.toastEvent.removeObservers(this)
viewModel.toastEvent.observe(this) { message ->
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
问题:
- 需要在每个 observe 点手动调用
removeObservers - 无法处理 Fragment 重建后的场景(Lifecycle owner 已变化)
方案 3:SingleLiveEvent
Google 架构示例中的 SingleLiveEvent:
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val pending = AtomicBoolean(false)
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner) { t ->
if (pending.compareAndSet(true, false)) {
observer.onChanged(t)
}
}
}
override fun setValue(value: T?) {
pending.set(true)
super.setValue(value)
}
}
问题:
- 只支持单个 Observer(多个 Observer 时只有一个能收到事件)
postValue在快速调用时可能丢失事件
三、生产级方案:EventLiveData
结合上述方案的教训,我们需要一个满足以下要求的事件分发机制:
- 单次消费:屏幕旋转后不会重复触发
- 多 Observer 支持:每个 Observer 都能收到事件
- 线程安全:支持
postValue从后台线程调用 - 生命周期感知:Observer 在
STARTED时才接收事件
实现代码
class EventLiveData<T> : MutableLiveData<T>() {
private val observers = mutableMapOf<Observer<in T>, EventObserverWrapper<T>>()
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
val wrapper = EventObserverWrapper(observer)
observers[observer] = wrapper
super.observe(owner, wrapper)
}
override fun observeForever(observer: Observer<in T>) {
val wrapper = EventObserverWrapper(observer)
observers[observer] = wrapper
super.observeForever(wrapper)
}
override fun removeObserver(observer: Observer<in T>) {
val wrapper = observers.remove(observer)
if (wrapper != null) {
super.removeObserver(wrapper)
} else {
super.removeObserver(observer)
}
}
override fun setValue(value: T?) {
observers.values.forEach { it.newValue() }
super.setValue(value)
}
private class EventObserverWrapper<T>(private val observer: Observer<in T>) : Observer<T> {
private var pending = false
fun newValue() {
pending = true
}
override fun onChanged(value: T) {
if (pending) {
pending = false
observer.onChanged(value)
}
}
}
}
使用示例
class DetailViewModel : ViewModel() {
private val _toastEvent = EventLiveData<String>()
val toastEvent: LiveData<String> = _toastEvent
private val _navigateEvent = EventLiveData<String>()
val navigateEvent: LiveData<String> = _navigateEvent
fun saveData() {
// 保存逻辑
_toastEvent.value = "保存成功"
}
fun navigateToList() {
_navigateEvent.value = "list_screen"
}
}
// Activity
viewModel.toastEvent.observe(this) { message ->
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
viewModel.navigateEvent.observe(this) { route ->
findNavController().navigate(route)
}
核心机制
- EventObserverWrapper 持有
pending标志,初始为false - 调用
setValue时,先将所有 wrapper 的pending设为true,再触发 LiveData 的setValue - LiveData 通知 Observer 时,wrapper 检查
pending:- 如果为
true,执行回调并重置为false - 如果为
false(例如屏幕旋转后重新 observe),直接忽略
- 如果为
- 支持多个 Observer,每个 wrapper 独立维护
pending状态
四、对比:LiveData vs SharedFlow
Kotlin Coroutines 提供了 SharedFlow,也可以作为事件总线使用:
class DetailViewModel : ViewModel() {
private val _toastEvent = MutableSharedFlow<String>()
val toastEvent: SharedFlow<String> = _toastEvent.asSharedFlow()
fun saveData() {
viewModelScope.launch {
_toastEvent.emit("保存成功")
}
}
}
// Activity
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.toastEvent.collect { message ->
Toast.makeText(this@DetailActivity, message, Toast.LENGTH_SHORT).show()
}
}
}
优劣对比
| 维度 | EventLiveData | SharedFlow |
|---|---|---|
| 生命周期感知 | 原生支持,自动绑定 Lifecycle | 需手动用 repeatOnLifecycle |
| 单次消费 | 内置实现 | 默认行为(不缓存旧值) |
| 多 Observer | 支持 | 支持 |
| 线程安全 | postValue 自动切主线程 |
需手动 launch |
| 学习成本 | 低(熟悉 LiveData 即可) | 需理解 Flow、协程作用域 |
| 适用场景 | 传统 MVVM 项目、团队不熟悉协程 | 新项目、团队已全面使用协程 |
推荐选择:
- 新项目或协程体系完善的团队 → SharedFlow
- 存量项目或团队技术栈偏保守 → EventLiveData
五、实战案例:可靠的表单提交反馈
场景需求
- 点击"提交"按钮后,显示加载状态
- 提交成功后,弹出 Toast、关闭加载、返回上一页
- 提交失败后,弹出错误对话框
- 屏幕旋转或进程重建后,不重复执行上述操作
ViewModel 实现
sealed class SubmitEvent {
object Loading : SubmitEvent()
data class Success(val message: String) : SubmitEvent()
data class Error(val error: String) : SubmitEvent()
}
class FormViewModel : ViewModel() {
private val _submitEvent = EventLiveData<SubmitEvent>()
val submitEvent: LiveData<SubmitEvent> = _submitEvent
fun submitForm(data: FormData) {
_submitEvent.value = SubmitEvent.Loading
viewModelScope.launch {
try {
val result = repository.submitForm(data)
_submitEvent.value = SubmitEvent.Success(result.message)
} catch (e: Exception) {
_submitEvent.value = SubmitEvent.Error(e.message ?: "提交失败")
}
}
}
}
Activity 实现
class FormActivity : AppCompatActivity() {
private val viewModel: FormViewModel by viewModels()
private val loadingDialog by lazy { LoadingDialog(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_form)
viewModel.submitEvent.observe(this) { event ->
when (event) {
is SubmitEvent.Loading -> {
loadingDialog.show()
}
is SubmitEvent.Success -> {
loadingDialog.dismiss()
Toast.makeText(this, event.message, Toast.LENGTH_SHORT).show()
finish()
}
is SubmitEvent.Error -> {
loadingDialog.dismiss()
AlertDialog.Builder(this)
.setTitle("提交失败")
.setMessage(event.error)
.setPositiveButton("确定", null)
.show()
}
}
}
binding.submitButton.setOnClickListener {
val data = FormData(binding.nameInput.text.toString())
viewModel.submitForm(data)
}
}
}
关键保障
- 单次消费:旋转屏幕后,
EventLiveData不会重复触发Success事件,不会再次弹 Toast、再次finish() - 状态一致:加载对话框的显示/隐藏由事件驱动,不会出现"提交成功但对话框还在"的情况
- 错误恢复:提交失败后,用户可以修改表单再次提交,不会受旧事件影响
六、常见问题
1. EventLiveData 在快速连续调用 postValue 时会丢失事件吗?
不会。postValue 内部会将任务 post 到主线程,每次调用都会触发 setValue,进而更新所有 wrapper 的 pending 标志。但需要注意:如果在同一帧内多次 setValue 同一个值,LiveData 的去重机制可能导致 Observer 只收到一次通知。解决方案:
// 用时间戳或随机数包装事件,确保每次都是新值
data class TimestampedEvent<T>(val data: T, val timestamp: Long = System.currentTimeMillis())
_toastEvent.value = TimestampedEvent("保存成功")
2. 如何在 Fragment 中使用 EventLiveData?
// Fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.toastEvent.observe(viewLifecycleOwner) { message ->
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
}
关键:使用 viewLifecycleOwner 而非 this,避免 Fragment view 销毁后仍然收到事件。
3. 如何测试 EventLiveData?
@Test
fun `submit success should emit success event`() = runTest {
val viewModel = FormViewModel(fakeRepository)
val events = mutableListOf<SubmitEvent>()
viewModel.submitEvent.observeForever { events.add(it) }
viewModel.submitForm(testData)
advanceUntilIdle()
assertEquals(2, events.size)
assertTrue(events[0] is SubmitEvent.Loading)
assertTrue(events[1] is SubmitEvent.Success)
}
七、总结
LiveData 的粘性事件问题源于其"状态同步"的设计初衷与"一次性事件"的使用场景不匹配。解决方案的核心是为每个 Observer 维护独立的消费状态:
- EventLiveData:封装消费逻辑,支持多 Observer、线程安全、生命周期感知
- SharedFlow:协程体系下的原生方案,无粘性、需手动管理生命周期
在实际项目中,选择哪种方案取决于团队技术栈与项目阶段。存量项目建议使用 EventLiveData 平滑过渡,新项目优先考虑 SharedFlow 建立协程优先的架构。
掌握这些机制后,你可以构建可靠的事件总线,让 Toast、导航、对话框等一次性操作在任何配置变更场景下都能稳定执行。