Jetpack MVVM 常见错误用法(四) 使用 LiveData/StateFlow 发送 Event

简介: 在 MVVM 架构中,使用 LiveData 或者 StateFlow 很适合用来向 UI 侧发送更新后的状态,但是用来发送事件就不妥了

前言

在 MVVM 架构中,我们通常使用 LiveData 或者 StateFlow 实现 ViewModel 与 View 之间的数据通信,它们具备的响应式机制非常适合用来向 UI 侧发送更新后的状态(State),但是同样用它们来发送事件(Event),当做 EventBus 使用就不妥了

1. “状态” 与 “事件”

虽然“状态”和“事件”都可以通过响应式的方式通知到 UI 侧,但是它们的消费场景不同:

  • 状态(State):是需要 UI 长久呈现的内容,在新的状态到来之前呈现的内容保持不变。比如显示一个Loading框或是显示一组请求的数据集。
  • 事件(Event):是需要 UI 即时执行的动作,是一个短期行为。比如显示一个 Toast 、 SnackBar,或者完成一次页面导航等。

我们从覆盖性、时效性、幂等性等三个维度列举状态和事件的具体区别

状态 事件
覆盖性 新状态会覆盖旧状态,如果短时间内发生多次状态更新,可以抛弃中间态只保留最新状态即可。这也是为什么 LiveData 连续 postValue 时会出现数据丢失。 新事件不应该覆盖旧事件,订阅者按照发送顺序接收到所有事件,中间的事件不能遗漏。
时效性 最新状态是需要长久保持的,可以被时刻访问到,因此状态一般是“粘性的”,在新的订阅出现时为其发送最新状态。 事件只能被消费一次,消费后应该丢弃。因此事件一般不是“粘性”的,避免多次消费。
幂等性 状态是幂等的,唯一状态决定唯一UI,同样的状态无需响应多次。因此 StateFlow 在 setValue 时会对新旧数据进行比较,避免重复发送。 订阅者需要对发送的每个事件进行消费,即使是同一类事件发送多次。

2. 基于 LiveData 的事件处理

鉴于事件与状态的诸多差异,如果直接使用 LiveData 或 StateFlow 发送事件,会出现不符合预期的行为。其中最常见的可能就是所谓“数据倒灌”问题。

我平常不太喜欢使用 “数据倒灌” 这个词,主要是“倒”这个字与单向数据流思想相违背,容易引起误解,我猜测词汇发明者更多的是想用它强调一种“被动”接收吧。

“数据倒灌”问题的发生源于 LiveData 的 "粘性" 设计,同一个订阅者每次订阅 LiveData 都会收到最近的一个事件,因为事件应该具有“时效性”,对于已消费过的事件我们不希望再次响应。

Jose Alcérreca《LiveData with SnackBar, Navigation and other events》 一文中首次讨论了 LiveData 如何处理事件的话题,并在 architecture-sample-todoapp 中给出了 SingleLiveEvent ) 的解决思路。受到这篇文章的启发,陆续又有不少大佬给出了更优的解决方案,修补了 SingleLiveEvent 中的一些缺陷 - 例如不支持多订阅者等,但主要的解决思路上大体相同:通过增加标记位来记录事件是否被消费,对于已消费的事件则不会在订阅时再次发送。

这里贴一个相对完善的解决方案:

open class LiveEvent<T> : MediatorLiveData<T>() {

    private val observers = ArraySet<ObserverWrapper<in T>>()

    @MainThread
    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        observers.find { it.observer === observer }?.let { _ -> // existing
            return
        }
        val wrapper = ObserverWrapper(observer)
        observers.add(wrapper)
        super.observe(owner, wrapper)
    }

    @MainThread
    override fun observeForever(observer: Observer<in T>) {
        observers.find { it.observer === observer }?.let { _ -> // existing
            return
        }
        val wrapper = ObserverWrapper(observer)
        observers.add(wrapper)
        super.observeForever(wrapper)
    }

    @MainThread
    override fun removeObserver(observer: Observer<in T>) {
        if (observer is ObserverWrapper && observers.remove(observer)) {
            super.removeObserver(observer)
            return
        }
        val iterator = observers.iterator()
        while (iterator.hasNext()) {
            val wrapper = iterator.next()
            if (wrapper.observer == observer) {
                iterator.remove()
                super.removeObserver(wrapper)
                break
            }
        }
    }

    @MainThread
    override fun setValue(t: T?) {
        observers.forEach { it.newValue() }
        super.setValue(t)
    }

    private class ObserverWrapper<T>(val observer: Observer<T>) : Observer<T> {

        private var pending = false

        override fun onChanged(t: T?) {
            if (pending) {
                pending = false
                observer.onChanged(t)
            }
        }

        fun newValue() {
            pending = true
        }
    }
}

代码很清晰,我们使用 ObserverWrapperObserver 进行封装后,可以使用 pending 针对单个消费者记录事件的消费,避免二次消费。

简单介绍了 LiveData 的事件处理,接下来重点看一下 Flow 如何进行事件处理,因为随着 lifecycle-runtime-ktx 对 Coroutine 的支持, Flow 将会成为主流的数据通信方式,Flow 将会成为主流的数据通信方式。

3. 基于 SharedFlow 的事件处理

StateFlow 和 LiveData 一样具备“粘性”特性,同样有“数据倒灌”的问题,甚至更有过之还会出现“数据丢失”的问题,因为 StateFlow 进行 updateState 时会过滤对新旧数据进行比较,同样类型的事件有可能被丢弃。

Roman Elizarov 曾在 《Shared flows, broadcast channels》 一文中提出用 SharedFlow 实现 EventBus 的做法:

class BroadcastEventBus {
    private val _events = MutableSharedFlow<Event>()
    val events = _events.asSharedFlow() // read-only public view

    suspend fun postEvent(event: Event) {
        _events.emit(event) 
    }
}

SharedFlow 确实一个不错的选择,它的很多特性与事件消费方式比较贴合:

  • 首先,它可以有多个收集器(订阅者),多个收集器“共享”事件,实现事件的广播,如下图所示:

  • 其次,SharedFlow 的数据会以流的形式发送,不会丢失,新事件不会覆盖旧事件;
  • 最后,它的数据不是粘性的,消费一次就不会再次出现。

但是,SharedFlow 存在一个问题,接收器无法接收到 collect 之前发送的事件,看下面例子:

class MainViewModel : ViewModel(), DefaultLifecycleObserver {

    private val _toast = MutableSharedFlow<String>()
    val showToast = _toast.asSharedFlow()
    
    init {
        viewModelScope.launch {
            delay(1000)
            _toast.emit("Toast")
        }
    }
}


//Fragment side
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        mainViewModel.showToast.collect {
            Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
        }
    }
}

例子中,我们使用 repeatOnLifecycle 保证了事件收集在 STARTD 之后开始,如果此时注释掉 delay(1000) 的代码,emit 早于 collect,所以 toast 将无法显示。

有些时候我们在订阅出现之前就发出事件,并希望订阅者出现时执行响应这个事件,比如完成一个初始化任务等,注意这并非一种“数据倒灌”,因为这它只被允许消费一次,一旦消费就不再发送,所以 SharedFlow 的 replay 参数不能使用,因为 repaly 不能保证只消费一次。

4. 基于 Channel 的处理事件

针对 SharedFlow 的这个不足, Roman Elizarov 也给了解决方案,即使用 Channel。

class SingleShotEventBus {
    private val _events = Channel<Event>()
    val events = _events.receiveAsFlow() // expose as flow

    suspend fun postEvent(event: Event) {
        _events.send(event) // suspends on buffer overflow
    }
}

当 Channel 没有订阅者时,向其发送的数据会挂起,保证订阅者出现时第一时间接收到这个数据,类似于阻塞队列的原理。 Channel 本身也是 Flow 实现的基础,所以通过 receiveAsFlow 可以转成一个 Flow 暴露给订阅者。回看前面的例子,改为 Channel 后如下:

class MainViewModel : ViewModel(), DefaultLifecycleObserver {

    private val _toast = Channel<String>()
    val showToast = _toast.receiveAsFlow()
    
    init {
        viewModelScope.launch {
            _toast.send("Toast")
        }
    }
}

//Fragment side
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        mainViewModel.showToast.collect {
            Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
        }
    }
}

UI 侧仍然针对 Flow 订阅,代码不做任何改动,但是在 STATED 之后也可以接受到已发送的事件。

需要注意,Channel 也有一个使用上的限制,当 Channel 有多个收集器时,它们不能共享 Channel 传输的数据,每个数据只能被一个收集器独享,因此 Channel 更适合一对一的通信场景。

综上,SharedFlow 和 Channel 在事件处理上各有特点,大家需要根据实际场景灵活选择:

SharedFlow Channel
订阅者数量 订阅者共享通知,可以实现一对多的广播 每个消息只有一个订阅者可以收到,用于一对一的通信
事件接受 collect 之前的事件会丢失 第一个订阅者可以收到 collect 之前的事件

为了在更正确的时机接受事件,通常会配合 lifecycle-runtime-ktx 完成事件订阅,例如前面例子中使用的 repeatOnLifecycle ( 可以参考 Jetpack MVVM 七宗罪之二: 在 launchWhenX 中启动协程),这里提供一个避免模板代码的方法,仅供参考

inline fun <reified T> Flow<T>.observeWithLifecycle(
        lifecycleOwner: LifecycleOwner,
        minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
        noinline action: suspend (T) -> Unit
): Job = lifecycleOwner.lifecycleScope.launch {
    flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}

inline fun <reified T> Flow<T>.observeWithLifecycle(
        fragment: Fragment,
        minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
        noinline action: suspend (T) -> Unit
): Job = fragment.viewLifecycleOwner.lifecycleScope.launch {
    flowWithLifecycle(fragment.viewLifecycleOwner.lifecycle, minActiveState).collect(action)
}

如上,observeWithLifecycle 作为 Flow 的扩展方法,在指定生命周期进行订阅,这样在 UI 侧的代码可以简写如下了:

viewModel.events
    .observeWithLifecycle(fragment = this, minActiveState = Lifecycle.State.RESUMED) {
        // do things
    }

viewModel.events
    .observeWithLifecycle(lifecycleOwner = viewLifecycleOwner, minActiveState = Lifecycle.State.RESUMED) {
        // do things
    }

本来文章到这里就该结束了,但突然发现近日 Google 对架构规范进行了更新,其中特别对 MVVM 的事件处理给了新的推荐做法:https://developer.android.com/jetpack/guide/ui-layer/events#handle-viewmodel-events,因此又有了下面一节内容......

5. 关于 Google 最新 Guide

这里仅针对 Guide 中关于事件处理部分做一个摘要,可以总结为以下三条:

  1. 凡是发送给 View 的事件都应该涉及 UI 变动,与 UI 无关的事件不应该由 View 监听
  2. 既然是涉及 UI 的事件,可以跟随 UI 状态一起发送(基于 StateFlow 或 LiveData ),不必另建新的渠道。
  3. View 在处理完事件后,需要告知 ViewModel 事件已处理,ViewModel 更新状态避免再次消费

这三条汇总成一句话就是:像 “状态” 一样管理 “事件”

结合官方的实例代码,体会一下具体实现:

// Models the message to show on the screen.
data class UserMessage(val id: Long, val message: String)

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessages: List<UserMessage> = emptyList()
)

如上,List<UserMessge> 作为消息事件列表,跟 UiState 放在一起管理。

class LatestNewsViewModel(/* ... */) : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                _uiState.update { currentUiState ->
                    val messages = currentUiState.userMessages + UserMessage(
                        id = UUID.randomUUID().mostSignificantBits,
                        message = "No Internet connection"
                    )
                    currentUiState.copy(userMessages = messages)
                }
                return@launch
            }

            // Do something else.
        }
    }

}

如上,ViewModel 在 refreshNews 中请求最新的数据,如果网络未连接,则增加一条 userMessage 跟随状态一起发送给 View 。

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessages.firstOrNull()?.let { userMessage ->
                        // TODO: Show Snackbar with userMessage.
                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown(userMessage.id)
                    }
                    ...
                }
            }
        }
    }
}

View 侧订阅 UiState 的状态变化,收到状态变化通知时,处理其中的 UserMessage 事件,例如这里是显示一条 SnackBar ,事件处理后,调用 viewModel.userMessageShown 方法,通知 ViewModel 处理结束。

    fun userMessageShown(messageId: Long) {
        _uiState.update { currentUiState ->
            val messages = currentUiState.userMessages.filterNot { it.id == messageId }
            currentUiState.copy(userMessages = messages)
        }
    }

最后看一下 userMessageShown 的实现,从消息列表中删除相关信息,表示消息已被消费。

其实 Jose Alcérreca 早在 《LiveData with SnackBar, Navigation and other events》 一文中就提到过这种处理思路,并予以了否定,

With this approach you add a way to indicate from the View that you already handled the event and that it should be reset. <br/>
The problem with this approach is that there’s some boilerplate (one new method in the ViewModel per event) and it’s error prone; it’s easy to forget the call to the ViewModel from the observer.

否定的理由是这会增加模板代码,而且容易遗漏 View -> ViewModel 的反向通知。虽说 Jose 的文章只代表个人,但由于文章已经深入人心,如今 Google 的反向推荐难免让人感觉有些打脸。不过细细想来,这种做法也确实有它的意义:

  1. 它避免了 SharedFlow ,Channel 等更多工具的引入,技术栈更加简洁。
  2. 弱化 “事件” 的概念,强化 “状态” 的概念,实则就是命令式逻辑为状态驱动的思考方式让路,这也与 Compose 的理念更加贴近,有利于声明式 UI 的进一步推广
  3. 像 “状态” 一样管理 “事件”,事件处理有回执、可追踪,也为事件增加了“后处理”的机会

当然这里也存在隐患,比如在事件处理结束并给出回执之前,如果有新的状态通知到来,此时由于事件列表中没有清空当前事件,是否会造成重复消费? 这个还有待进一步验证。

6. 总结

本文介绍了 MVVM 事件处理的多种方案,没有十全十美的方案,需要大家结合具体场景做出选择:

  • 如果你的项目仍然在使用 LiveData,那么需要对事件的消费做记录,避免事件二次消费,可以参考本文中 LiveEvent 的例子
  • 如果你的代码大部分是 Kotlin ,那么推荐优先使用 Coroutine 实现 MVVM 数据通信,此时可以使用 SharedFlow 处理事件,如果你希望接收到 collect 之前的事件则可以选择 Channel
  • 有条件的话可以考虑采用 Google 最新的架构规范,虽然它在写法上略显冗余,而且增加了 View 的负担,所以能否得到开发者的最终认可还有待检验。

其实最有效的事件处理方式就是尽量避免定义 “事件”,尝试用 “状态” 替换 “事件” 来设计你的数据通信,这才更贴合数据驱动的架构思想。

参考

目录
相关文章
|
4月前
|
前端开发 测试技术 API
Jetpack MVVM 七宗罪之六:ViewModel 接口暴露不合理
Jetpack MVVM 七宗罪之六:ViewModel 接口暴露不合理
45 0
|
4月前
|
前端开发 Java API
Jetpack MVVM 七宗罪之五: 在 Repository 中使用 LiveData
Jetpack MVVM 七宗罪之五: 在 Repository 中使用 LiveData
59 0
|
10天前
|
设计模式 前端开发 数据库
构建高效Android应用:使用Jetpack架构组件实现MVVM模式
【4月更文挑战第21天】 在移动开发领域,构建一个既健壮又易于维护的Android应用是每个开发者的目标。随着项目复杂度的增加,传统的MVP或MVC架构往往难以应对快速变化的市场需求和复杂的业务逻辑。本文将探讨如何利用Android Jetpack中的架构组件来实施MVVM(Model-View-ViewModel)设计模式,旨在提供一个更加模块化、可测试且易于管理的代码结构。通过具体案例分析,我们将展示如何使用LiveData, ViewModel, 和Repository来实现界面与业务逻辑的分离,以及如何利用Room数据库进行持久化存储。最终,你将获得一个响应迅速、可扩展且符合现代软件工
14 0
|
22天前
|
存储 设计模式 前端开发
构建高效安卓应用:Jetpack MVVM 架构的实践之路
【4月更文挑战第9天】 在移动开发的迅猛浪潮中,Android 平台以其开放性和灵活性受到开发者青睐。然而,随着应用复杂度的不断增加,传统的开发模式已难以满足快速迭代和高质量代码的双重要求。本文将深入探讨 Jetpack MVVM 架构模式在 Android 开发中的应用实践,揭示如何通过组件化和架构设计原则提升应用性能,实现数据驱动和UI分离,进而提高代码可维护性与测试性。我们将从理论出发,结合具体案例,逐步展开对 Jetpack MVVM 架构的全面剖析,为开发者提供一条清晰、高效的技术实施路径。
|
4月前
|
Android开发 开发者
什么是Android Jetpack,它包括哪些组件?
什么是Android Jetpack,它包括哪些组件?
42 0
|
4月前
|
IDE API 开发工具
Google I/O :Android Jetpack 最新变化(四)Compose
Google I/O :Android Jetpack 最新变化(四)Compose
107 0
|
4月前
|
JSON IDE 测试技术
Google I/O :Android Jetpack 最新变化(二) Performance
Google I/O :Android Jetpack 最新变化(二) Performance
114 0
|
4月前
|
SQL API Android开发
Google I/O :Android Jetpack 最新变化(一) Architecture
Google I/O :Android Jetpack 最新变化(一) Architecture
70 0
|
4月前
|
API Android开发
Google I/O :Android Jetpack 最新变化(三)UI
Google I/O :Android Jetpack 最新变化(三)UI
51 0
|
2月前
|
XML API Android开发
【Android 从入门到出门】第三章:使用Hilt处理Jetpack Compose UI状态
【Android 从入门到出门】第三章:使用Hilt处理Jetpack Compose UI状态
26 4