Jetpack MVVM 常见错误四:使用 LiveData/StateFlow 发送 Events

前言

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

“状态” 与 “事件”

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

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

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

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

基于 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 将会成为主流的数据通信方式。

基于 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 不能保证只消费一次。

基于 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 更适合一对一的通信场景。

![七宗罪.png](https://upload-images.jianshu.io/upload_images/26794336-cd1e9eeb6982acc3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

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

为了在更正确的时机接受事件,通常会配合 lifecycle-runtime-ktx 完成事件订阅,例如前面例子中使用的 repeatOnLifecycle (关于这个 API 可以参考 ),这里提供一个避免模板代码的方法,仅供参考

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,因此又有了下面一节内容......

关于 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.

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. 像 “状态” 一样管理 “事件”,事件处理有回执、可追踪,也为事件增加了“后处理”的机会

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

总结

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

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

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

参考

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,902评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,037评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,978评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,867评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,763评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,104评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,565评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,236评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,379评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,313评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,363评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,034评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,637评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,719评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,952评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,371评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,948评论 2 341

推荐阅读更多精彩内容