久违的 “ Jetpack MVVM 七宗罪 ” 系列,今日再开。本系列主要盘点 MVVM 架构中各种常见错误写法,并针对性的给出最佳实践,帮助大家掌握 Jetpack 组件最正确的使用姿势。
在 MVVM 架构中,我们通常使用 LiveData 或者 StateFlow 实现 ViewModel 与 View 之间的数据通信,它们具备的响应式机制非常适合用来向 UI 侧发送更新后的状态(State),但是同样用它们来发送事件(Event),当做 EventBus 使用就不妥了
虽然“状态”和“事件”都可以通过响应式的方式通知到 UI 侧,但是它们的消费场景不同:
我们从覆盖性、时效性、幂等性等三个维度列举状态和事件的具体区别
状态 | 事件 | |
---|---|---|
覆盖性 | 新状态会覆盖旧状态,如果短时间内发生多次状态更新,可以抛弃中间态只保留最新状态即可。这也是为什么 LiveData 连续 postValue 时会出现数据丢失。 | 新事件不应该覆盖旧事件,订阅者按照发送顺序接收到所有事件,中间的事件不能遗漏。 |
时效性 | 最新状态是需要长久保持的,可以被时刻访问到,因此状态一般是“粘性的”,在新的订阅出现时为其发送最新状态。 | 事件只能被消费一次,消费后应该丢弃。因此事件一般不是“粘性”的,避免多次消费。 |
幂等性 | 状态是幂等的,唯一状态决定唯一UI,同样的状态无需响应多次。因此 StateFlow 在 setValue 时会对新旧数据进行比较,避免重复发送。 | 订阅者需要对发送的每个事件进行消费,即使是同一类事件发送多次。 |
鉴于事件与状态的诸多差异,如果直接使用 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
}
}
}
代码很清晰,我们使用 ObserverWrapper
对 Observer
进行封装后,可以使用 pending
针对单个消费者记录事件的消费,避免二次消费。
简单介绍了 LiveData 的事件处理,接下来重点看一下 Flow 如何进行事件处理,因为随着 lifecycle-runtime-ktx
对 Coroutine 的支持, Flow 将会成为主流的数据通信方式,Flow 将会成为主流的数据通信方式。
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 存在一个问题,接收器无法接收到 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
不能保证只消费一次。
针对 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,因此又有了下面一节内容…
这里仅针对 Guide 中关于事件处理部分做一个摘要,可以总结为以下三条:
这三条汇总成一句话就是:像 “状态” 一样管理 “事件”
结合官方的实例代码,体会一下具体实现:
// 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
作为消息事件列表,跟 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 的反向推荐难免让人感觉有些打脸。不过细细想来,这种做法也确实有它的意义:
当然这里也存在隐患,比如在事件处理结束并给出回执之前,如果有新的状态通知到来,此时由于事件列表中没有清空当前事件,是否会造成重复消费? 这个还有待进一步验证。
本文介绍了 MVVM 事件处理的多种方案,没有十全十美的方案,需要大家结合具体场景做出选择:
其实最有效的事件处理方式就是尽量避免定义 “事件”,尝试用 “状态” 替换 “事件” 来设计你的数据通信,这才更贴合数据驱动的架构思想。**