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

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui