[Android 从零到一] LiveData 粘性事件与单次消费:从重复触发到可靠事件总线

LiveData 粘性事件与单次消费:从重复触发到可靠事件总线

在 Android MVVM 架构中,LiveData 是连接 ViewModel 与 UI 层的核心工具。但在实际项目中,LiveData 的"粘性事件"特性常常引发重复触发问题:旋转屏幕后 Toast 再次弹出、导航事件重复执行、对话框多次显示。本文将从 LiveData 粘性机制的根源出发,介绍单次消费事件的常见方案,并给出生产环境中可靠的事件总线实现。


一、LiveData 粘性事件的根源

1. 什么是粘性事件

LiveData 会保留最后一次 setValue/postValue 的值,当新的 Observer 注册时(例如页面重建后重新 observe),即使事件已经发生过,Observer 仍然会收到这个"旧值"。

kotlin 复制代码
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:手动标记消费状态

kotlin 复制代码
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

kotlin 复制代码
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

kotlin 复制代码
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

结合上述方案的教训,我们需要一个满足以下要求的事件分发机制:

  1. 单次消费:屏幕旋转后不会重复触发
  2. 多 Observer 支持:每个 Observer 都能收到事件
  3. 线程安全 :支持 postValue 从后台线程调用
  4. 生命周期感知 :Observer 在 STARTED 时才接收事件

实现代码

kotlin 复制代码
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)
            }
        }
    }
}

使用示例

kotlin 复制代码
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)
}

核心机制

  1. EventObserverWrapper 持有 pending 标志,初始为 false
  2. 调用 setValue 时,先将所有 wrapper 的 pending 设为 true,再触发 LiveData 的 setValue
  3. LiveData 通知 Observer 时,wrapper 检查 pending
    • 如果为 true,执行回调并重置为 false
    • 如果为 false(例如屏幕旋转后重新 observe),直接忽略
  4. 支持多个 Observer,每个 wrapper 独立维护 pending 状态

四、对比:LiveData vs SharedFlow

Kotlin Coroutines 提供了 SharedFlow,也可以作为事件总线使用:

kotlin 复制代码
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 实现

kotlin 复制代码
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 实现

kotlin 复制代码
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)
        }
    }
}

关键保障

  1. 单次消费 :旋转屏幕后,EventLiveData 不会重复触发 Success 事件,不会再次弹 Toast、再次 finish()
  2. 状态一致:加载对话框的显示/隐藏由事件驱动,不会出现"提交成功但对话框还在"的情况
  3. 错误恢复:提交失败后,用户可以修改表单再次提交,不会受旧事件影响

六、常见问题

1. EventLiveData 在快速连续调用 postValue 时会丢失事件吗?

不会postValue 内部会将任务 post 到主线程,每次调用都会触发 setValue,进而更新所有 wrapper 的 pending 标志。但需要注意:如果在同一帧内多次 setValue 同一个值,LiveData 的去重机制可能导致 Observer 只收到一次通知。解决方案:

kotlin 复制代码
// 用时间戳或随机数包装事件,确保每次都是新值
data class TimestampedEvent<T>(val data: T, val timestamp: Long = System.currentTimeMillis())

_toastEvent.value = TimestampedEvent("保存成功")

2. 如何在 Fragment 中使用 EventLiveData?

kotlin 复制代码
// 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?

kotlin 复制代码
@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、导航、对话框等一次性操作在任何配置变更场景下都能稳定执行。

相关推荐
小孔龙8 小时前
GraphicBuffer 跨进程共享
android
行业研究员8 小时前
Android内置GPS与腾讯地图定位技术对比分析
android·android定位·腾讯地图定位
Android-Flutter9 小时前
android WMS 详解(二)
android·kotlin
游戏开发爱好者89 小时前
App Store 上传 IPA 自动化,.p8 密钥认证与 CI/CD 接入实战
android·运维·ci/cd·小程序·uni-app·自动化·iphone
游戏开发爱好者811 小时前
iOS 推送怎么配置,APNs 推送证书、设备库与群发
android·ios·小程序·https·uni-app·iphone·webview
阿pin11 小时前
Android随笔-kotlin 高阶函数
android·kotlin·高阶函数
wxson72821 天前
【Android视频监控系统技术总结】
android·音视频
hunterandroid1 天前
[Android 从零到一] Android 事件分发机制:从 ACTION_DOWN 到 View 事件消费链路
android