久违的 “ Jetpack MVVM 七宗罪 ” 系列,今日再开。本系列主要盘点 MVVM 架构中各种常见错误写法,并针对性的给出最佳实践,帮助大家掌握 Jetpack 组件最正确的使用姿势。

  • Jetpack MVVM 七宗罪之一: 拿 Fragment 当 LifecycleOwner
  • Jetpack MVVM 七宗罪之二: 在 launchWhenX 中启动协程
  • Jetpack MVVM 七宗罪之三: 在 onViewCreated 中加载数据

前言


在 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>>()@MainThreadoverride fun observe(owner: LifecycleOwner, observer: Observer<in T>) {observers.find { it.observer === observer }?.let { _ -> // existingreturn}val wrapper = ObserverWrapper(observer)observers.add(wrapper)super.observe(owner, wrapper)}@MainThreadoverride fun observeForever(observer: Observer<in T>) {observers.find { it.observer === observer }?.let { _ -> // existingreturn}val wrapper = ObserverWrapper(observer)observers.add(wrapper)super.observeForever(wrapper)}@MainThreadoverride 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}}}@MainThreadoverride fun setValue(t: T?) {observers.forEach { it.newValue() }super.setValue(t)}private class ObserverWrapper<T>(val observer: Observer<T>) : Observer<T> {private var pending = falseoverride fun onChanged(t: T?) {if (pending) {pending = falseobserver.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 viewsuspend 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 flowsuspend 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> = _uiStatefun 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. 像 “状态” 一样管理 “事件”,事件处理有回执、可追踪,也为事件增加了“后处理”的机会

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

6. 总结


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

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

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

参考


  • [1] Jose Alcérreca , LiveData with SnackBar, Navigation and other events
  • [2] Hadi Lashkari Ghouchani , LiveData with single events
  • [3] Roman Elizarov , Shared flows, broadcast channels
  • [4] Michael Ferguson , Android SingleLiveEvent Redux with Kotlin Flow

Jetpack MVVM 七宗罪之四: 使用 LiveData/StateFlow 发送 Events相关推荐

  1. 如何破解迭代评审会七宗罪?

    迭代评审会(Sprint Review)基于真实的用户使用场景,展示当前整个产品增量,通过获取贴合用户使用习惯的反馈和建议,最终输出产品待办列表的优化调整.迭代评审会应重点关注用户需求的解决情况,而不 ...

  2. 活动设计的“七宗罪”(转)

    导语 活动做不好会伤用户,伤产品. 很多人以为活动能让产品收入突飞猛进,很多人以为活动能弥补产品的缺陷. 其实活动不是解决方案,只是止痛药.他解决不了根本的问题,只能减少疼痛而已. 而有时候,药方子开 ...

  3. 大型Android项目架构:基于组件化+模块化+Kotlin+协程+Flow+Retrofit+Jetpack+MVVM架构实现WanAndroid客户端

    前言:苟有恒,何必三更眠五更起:最无益,莫过一日曝十日寒. 前言 之前一直想写个 WanAndroid 项目来巩固自己对 Kotlin+Jetpack+协程 等知识的学习,但是一直没有时间.这里重新行 ...

  4. 驳“AJAX 的七宗罪”

    (本文转载自"Java视线",原文地址:http://forum.javaeye.com/viewtopic.php?t=13844,作者dlee) (AJA X的七宗罪:http ...

  5. 驳AXAJ的七宗罪 (转)

    我不带任何主观色彩来评一下这个所谓的 "AJAX 的七宗罪". 1.连带着 Flash 和 Ajax 一块骂了. 引用:没有链接的web就像森林中迷路的羔羊,这句看似广告语,其实是 ...

  6. OpenStack进入第二阶段需要解决的“七宗罪”

    以下内容节选编译自分析人士Steve Chambers近期撰写了一份OpenStack市场研究报告,发表在Wikibon Premium网站.报告的名称为<OpenStack进入第二阶段> ...

  7. 好产品要满足人性七宗罪

    好产品要满足人性"七宗罪": 作为一个有20年实战经验的互联网老兵,周鸿祎称得上是一个优秀的产品经理,一手打造了众多国民级的产品. 每个人都有成为产品经理的潜质.但是," ...

  8. 产品经理学习---人性七宗罪:打造完美产品的金钥匙

    那些我们不愿承认的人性七宗罪,恰好是打造产品的最佳依据,正视人性之恶,打造产品之美. 但丁在<神曲>中称人有七宗罪:傲慢,嫉妒,暴怒,懒惰,贪婪,贪吃和色欲.个人认为七宗罪其实是人的本能, ...

  9. CSDN Blog 之七宗罪

    CSDN Blog 之七宗罪 第一,不稳定,就为这个,我曾一度离开这里,不过终究还是有点怀念我写的那点儿东西.目前的稳定性比以前有所改善. 第二,我习惯用firefox,csdn的blog大体上还算兼 ...

最新文章

  1. Webpack 代码分离
  2. 不会编程也能做这么酷炫的视频风格迁移?这个工具冲上Reddit热榜,还能在线试玩...
  3. SAP WM 如何看一个交货单相关的TO是从哪些货架上拣配的?
  4. Rera1N环境Linux,降级工具ReRa1n发布,降级真的来了?
  5. 干货!一次kafka卡顿事故排查过程
  6. android 组件(activity,service,content provider,broadcast receiver,intent)详解
  7. 如何成为自己所在领域内前1%的顶尖人才? 凤凰科技 09-29 07:42 原标题:如何成为自己所在领域内前1%的顶尖人才? 有时你会觉得,可能你永远也实现自己的梦想。你清楚地知道自己想做什么,但有
  8. 用户及用户组管理(week1_day4)--技术流ken
  9. wxWidgets:wxHashTable类用法
  10. 蘑菇街2015校招 Java研发笔试题 详解,2015java
  11. 一文汇总 JDK 5 到 JDK 15 中的牛逼功能!
  12. 大数据预测实战-随机森林预测实战(一)-数据预处理
  13. oracle 如何使用循环语句,Oracle条件语句跟循环语句
  14. 龙蜥社区8问,你关心的问题都在这里
  15. sql server 读写txt文件
  16. RNN、CNN、RNN、LSTM、CTC算法原理,pytorch实现LSTM算法
  17. 国美易卡有序实现索引,国美易卡B+树方便区间查找
  18. Vue 项目API接口封装
  19. 使用linux内核仿真ZNS(zoned namespace SSD)
  20. html5 dom video,HTML5 / HTML5 video - 使用 DOM 进行控制 - 汇智网

热门文章

  1. 获取CheckedListBox选中的项
  2. 计算机丢失softwareinspect,logo1 systemInspect山泉查不到。。。和顶的又不一样[求助】...
  3. 全像素双核激光对焦拍照是个什么厉害玩意儿
  4. Shell脚本编程实战
  5. Win10 可以联网,但是右下角图标显示无法连接互联网
  6. 面向对象:对象的概念
  7. 20189220 余超《Linux内核原理与分析》第四周作业
  8. centos7.7 安装google浏览器
  9. CentOS 7安装chrome
  10. 加密通信(三):CA证书