「这是我参与2022首次更文挑战的第11天,活动详情查看:2022首次更文挑战」
前言
不知何时起,网上出现了大量言论说LiveData设计的有问题,其中最主要的点就是LiveData数据倒灌问题,啥是数据倒灌以及该如何解决这个问题,我们本章来好好说一下。
关于LiveData的源码解析可以查看前面系列文章:
# LiveData源码分析3 -- MediatorLiveData的使用与原理解析
# LiveData源码分析4 -- Transformations类解析
正文
为了更好地理解LiveData数据倒灌的原因由来,我们细细来说。
事件和状态
我们一般使用使用LiveData来实现数据驱动UI的思想,即Activity/Fragment监听VM中的LiveData,然后进行展示相应的内容,这时的LiveData数据就是状态,需要保持UI的正确性,在新的状态设置之前能保持不变。
但是呢,我们经常会使用LiveData来保存事件,什么是事件呢 比如显示一个toast,完成一次页面导航等,这时再用LiveData就会有问题,具体问题我们举个例子来说一下。
使用LiveData保存toast内容
我们还是简单看个例子,我想在界面弹出toast,这个toast的内容我保存在VM中的一个LiveData变量中:
val toastMsg = MutableLiveData<String>()
然后在网络加载失败时给它赋值:
fun getUpdateInfo() {
launchOnUI {
_updateData.value = NetworkRequestUIState(isLoading = true)
val result = mineRepository.getUpdateInfo("LowCarbon")
if (result is ResponseResult.Error) {
_updateData.value =
NetworkRequestUIState(isLoading = false, isError = result.errorMsg)
//给toastMsg赋值
toastMsg.value = "获取更新失败"
} else if (result is ResponseResult.Success) {
_updateData.value =
NetworkRequestUIState(isLoading = false, isSuccess = result.data)
}
}
}
接着在View层进行observe,代码如下:
//toast信息
toastMsg.observe(this@MineActivity){
if (it.isNotEmpty()){
Toast.makeText(this@MineActivity, it, Toast.LENGTH_SHORT).show()
}
}
这里当竖屏时,网络不好时,会弹出toast,然后进行横屏后,会发现又弹了一次toast,这说明 "获取更新失败" 这个事件又被消费了一次,这明显不符合逻辑,那为什么会又弹出一次呢。
为什么会消费2次
这个原因其实很简单,在我前面的文章里说过,也就是LiveData源码分析2的原理分析中有介绍,当配置发生变化时,Activity会重新走一遍生命周期函数,而这时会再次添加观察者,而就是这个观察者中的代码导致的问题,代码如下:
//带生命周期的观察者
class LifecycleBoundObserver extends ObserverWrapper implements LifecycleEventObserver {
//省略。。。
@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
//当Activity重建时,生命周期会重新走一遍
Lifecycle.State currentState = mOwner.getLifecycle().getCurrentState();
if (currentState == DESTROYED) {
removeObserver(mObserver);
return;
}
Lifecycle.State prevState = null;
while (prevState != currentState) {
prevState = currentState;
//当走到ON_RESUME时会触发页面活跃,会notify数据
activeStateChanged(shouldBeActive());
currentState = mOwner.getLifecycle().getCurrentState();
}
}
//省略。。。
}
简单来说就是Activity重建时,当界面重新可交互时会通知所有观察者来刷新数据,这里对于界面展示来说完全没有问题,因为我横屏后是需要和竖屏保持一样的数据,但是对于弹toast由于之前保存toast的LiveData数据不为空,所以会再弹一次。
事件和状态的区别
看了上面一个简单的例子,我们大概能看出事件和状态的区别了,主要有以下几点区别:
-
是否粘性:状态要求长久保存的,所以是粘性的,也就是新的订阅者出现时,要为其展示原有的最新状态。事件则要求只被消费一次,当被观察者处理后则进行丢弃,以避免多次消费。
-
覆盖性:这个主要出现在LiveData的postValue函数,当短时间内发生多次状态更新,可以抛弃中间状态只保留最新状态即可,这个原理在之前文章也说过,至于为什么也很好理解,防止UI线程处理太慢,导致界面实时性不对。事件则要求不能覆盖旧事件,因为每个事件都有作用,不能丢弃。
-
幂等性:状态是幂等的,也就是一样的UI状态不需要响应多次,比如我列表的数据没有变化,在LiveData中再设置一次旧值,LiveData还是会通知观察者,这个现象当使用StateFlow时则不会,StateFlow在发送数据给观察者前会做比较,只有不同时才进行发送。而对于事件来说,必须每个事件都要消费,即可以发送同一个事件,进行消费。
解决问题
其实这里并不是说LiveData设计的有问题,而是在某些时候我们对LiveData进行了过度使用,LiveData对于状态的控制和提醒是完美的,没有任何问题,但是对于事件时就有了一些小缺陷, 我们可以想一下如何修改。
修改Activity的配置
既然ViewModel是解决Activity被销毁重建时保留数据,那我就当配置发生变化时不走生命周期,这个其实是可行的。
这里注意界面销毁重建一般会发生在那些情况,一个是系统配置发生变化即横竖屏切换、语言切换等,一个是界面退到后台,由于内存不足被杀死,而ViewModel能保存数据只在第一种情况,而第一种情况我们遇到最多的情况就是横竖屏切换,所以干脆一刀切在横竖屏切换时不走生命周期即可。
在Manifest中对Activity做以下操作:
<activity
android:name=".main.gasesEmissionList.GasesEmissionDesActivity"
android:configChanges="orientation|keyboardHidden|screenSize"/>
这样便可以在横竖屏切换时不走生命周期了,会走特定的函数,也就不会出现上述问题。
使用UnPeekLiveData
这里我们主要解决的是粘性问题,既然事件不需要进行二次消费,所以想办法解决这个即可,网上其实有不少做法,基本都是修改LiveData,这里推荐使用一个UnPeekLiveData,GitHub地址是:
我们直接来看一下这个UnPeekLiveData到底是如何实现的,直接看一下源码:
//这个类相当于MutableLiveData,有设置的方法
public class UnPeekLiveData<T> extends ProtectedUnPeekLiveData<T> {
//这里的setValue方法调用的是父类ProtectUnPeekLiveData的方法
@Override
public void setValue(T value) {
super.setValue(value);
}
//调用MutableLiveData的postValue方法
@Override
public void postValue(T value) {
super.postValue(value);
}
//省略
}
然后我们来看一下关键的ProtectUnPeekLiveData是如何实现的:
//类似LiveData
public class ProtectedUnPeekLiveData<T> extends LiveData<T> {
//初始版本号
private final static int START_VERSION = -1;
//线程安全的变量,保存当前版本
private final AtomicInteger mCurrentVersion = new AtomicInteger(START_VERSION);
//是否允许空值
protected boolean isAllowNullValue;
//当把livedata当做事件来看,非粘性,且具有生命周期感知能力
@Override
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
super.observe(owner, createObserverWrapper(observer, mCurrentVersion.get()));
}
//把livedata当做事件来看,非粘性,不具有生命周期感知能力
@Override
public void observeForever(@NonNull Observer<? super T> observer) {
super.observeForever(createObserverWrapper(observer, mCurrentVersion.get()));
}
//把liveData当做状态来看,粘性,具有生命周期感知能力
public void observeSticky(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) {
super.observe(owner, createObserverWrapper(observer, START_VERSION));
}
//把liveData当做状态来看,粘性,不具有生命周期感知能力
public void observeStickyForever(@NonNull Observer<? super T> observer) {
super.observeForever(createObserverWrapper(observer, START_VERSION));
}
//设置值
@Override
protected void setValue(T value) {
//LiveData当前版本加1
mCurrentVersion.getAndIncrement();
super.setValue(value);
}
//前面添加观察者都必须进行的封装
class ObserverWrapper implements Observer<T> {
//观察者
private final Observer<? super T> mObserver;
//观察者自己的版本号
private int mVersion = START_VERSION;
//当新观察者添加时,这个版本号是-1
public ObserverWrapper(@NonNull Observer<? super T> observer, int version) {
this.mObserver = observer;
this.mVersion = version;
}
@Override
public void onChanged(T t) {
//见分析1
if (mCurrentVersion.get() > mVersion && (t != null || isAllowNullValue)) {
mObserver.onChanged(t);
}
}
//省略。。
}
分析1:当Activity重建时,因为LiveData特性会重新notify所有观察者,所以onChange方法会被调用,但是这里加了一个判断,当LiveData持有的版本号大于观察者持有的版本号,才进行分发数据。
这里因为当界面销毁重建时,LiveData保存的版本号是不会改变的,而销毁重建重新加入的观察者,其版本号为LiveData保存的版本号,这时2者相同,所以不会进行分发。
当调用setValue时,LiveData的版本号加1,这时就大于观察者持有的版本号了,就可以正常分发了。
其他方法
除了上面的UnPeekLiveData还有别的方法,基本都是修改LiveData来实现的,比如SingleLiveEvent、美团通过反色修改源LiveData等等,他们不外乎就是让数据可以不在具有粘性,从而解决问题。
总结
从这篇文章我们可以看出,LiveData的数据粘性或者叫做数据倒灌其实并不是啥大问题,只是对它的过度使用,至于解决方案也非常多,所以大家看到新技术,还是要多看源码,多了解,不能一股脑的使用。