一个Bug事件,引发我对 LiveData 与 协程 的思考

Posted 清风Coolbreeze

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一个Bug事件,引发我对 LiveData 与 协程 的思考相关的知识,希望对你有一定的参考价值。

本文主要介绍笔者在项目开发过程中使用MutableLiveData的postValue()遇到的问题,并以此展开关于协程以及LiveData的一些知识点的分析

一.LiveData相关知识

MutableLiveData提供了两种更新数据的方法setValue()postValue(),其中postValue()可以用于在子线程中更新数据,本质上也是通过Handler切换到主线程进行数据更新

简单的分析如下:

#LiveData.java
protected void postValue(T value) {
        //决定是否通过handler发送message通知观察者
        boolean postTask;
        synchronized (mDataLock) {
            postTask = mPendingData == NOT_SET;
            //全局变量赋值
            mPendingData = value;
        }
        if (!postTask) {
            return;
        }
        //切换到主线程通知观察者
        ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
    }

注意:

  • 通过此处源码我们可以了解到,这个地方进行了一次防止同一时间频繁多次调用postValue()方法的处理,也就是说当我们多次调用postValue()多次更新值的时候,可能最终只会有一条被封装的message来实现通知观察者的逻辑
  • 这个地方处理是可以理解的,如果不加以控制并发生了postValue()某一时间内被多次调用的情况,这就导致主线程消息队列中被插入大量的message,以至于引发卡顿,这是原因之一

接下来看一下postToMainThread()方法,这个里面藏着另一个为什么要对postValue()限制防止频繁调用的原因

#ArchTaskExecutor.java
    @Override
    public void postToMainThread(Runnable runnable) {
        //其中这个mDelegate是DefaultTaskExecutor实例
        mDelegate.postToMainThread(runnable);
    }

#DefaultTaskExecutor.java
@Override
    public void postToMainThread(Runnable runnable) {
        if (mMainHandler == null) {
            synchronized (mLock) {
                if (mMainHandler == null) {
                    //创建入口
                    mMainHandler = createAsync(Looper.getMainLooper());
                }
            }
        }
        //noinspection ConstantConditions
        mMainHandler.post(runnable);
    }

这个地方我们可以看到会创建一个Handler,并且传入的Looper是主线程的looper,实现线程切换,这个Handler创建的是通过方法creaeAsync()实现的,从函数名理解上创建一个异步handler

进入方法creaeAsync()去看一看

#DefaultTaskExecutor.java

private static Handler createAsync(@NonNull Looper looper) {
        if (Build.VERSION.SDK_INT >= 28) {
            //关键调用
            return Handler.createAsync(looper);
        }
        ...
        return new Handler(looper);
    }

#Handler.java
public static Handler createAsync(@NonNull Looper looper, @NonNull Callback callback) {
        if (looper == null) throw new NullPointerException("looper must not be null");
        if (callback == null) throw new NullPointerException("callback must not be null");
        //第三个参数代表是否异步
        return new Handler(looper, callback, true);
    }

public Handler(@NonNull Looper looper, @Nullable Callback callback, boolean async) {
        mLooper = looper;
        mQueue = looper.mQueue;
        mCallback = callback;
        //最终会将Handler内部的一个成员变量mAsynchronous=true
        mAsynchronous = async;
    }

mAsynchronous变量是不是很熟悉,如果这个变量为true的,那么在构造Message的时候会通过方法setAsynchronous()将当前message标识为一个异步消息,这个异步消息在碰到Looper的消息队列中加入了同步屏障后相比于同步消息被优先执行

之前说的对postValue()限制防止频繁调用的第二个原因就是因为,最终创建message为异步消息;总所周知,View视图在刷新渲染的时候会首先发送一个同步屏障消息,接下来将刷新渲染等message设置为异步消息,这样是为了view刷新优先被处理,如果开发者频繁调用了postValue()方法且不加以限制,就会导致消息队列被插入大量的异步消息,这个时候如果发生了视图刷新,那么很有可能造成视图刷新等相关message无法及时被处理,造成卡顿

二.协程相关知识

我们手动创建协程作用域CoroutineScope的时候,构造参数中传入的Job类型一般有两种:

Job():当子job发生异常的时候,会向上传递导致父job被取消;父job被取消会导致父job的所有子job被取消
SupervisorJob():当子job发生异常的时候,该异常不会向上传递,也就是说不会影响其父job,也就不会影响父job的其他子job的执行

接下来我们通过两个例子来具体了解下:

首先我们指定构造CoroutineScope是传入的是Job():

fun main() {
       //构造CoroutineScope中传入的job是job()
    val scope = CoroutineScope(Job() + Dispatchers.Default)
    //A协程块
    scope.launch(CoroutineExceptionHandler { _, throwable ->
        println("CoroutineExceptionHandler top: $throwable")
    }) {
        delay(1500)
        throw Exception("hahahaha")
    }
    //B协程块
    scope.launch(CoroutineExceptionHandler { _, throwable ->
        println("CoroutineExceptionHandler top: $throwable")
    }) {
        withContext(Dispatchers.IO) {
            val i = 0
            while (true) {
                println("aaaaa $i")
                delay(200)
            }
        }
    }

    println("--- thread = " + Thread.currentThread())
    Thread.sleep(10000)
}

看下运行效果:

我们开启一个协程B每隔200ms打印一次字符串“aaaaa 0”,开启另一个协程A先休眠1500ms然后抛出异常,从运行结果中可以看到,当A抛出异常的时候协程B就被取消不执行了,这验证了我们上面关于Job()的结论

接下来我们将Job()改成SupervisorJob()

    val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

看下运行效果:

从上图中可以看到:当A协程块发生异常的时候不会影响B协程块的执行,这就验证了之前讲的SupervisorJob()的结论

PS:建议大家使用协程时不用直接try-catch捕捉异常,而是手动构建一个CoroutineExceptionHandler对象并作为一个Element元素在协程创建的时候作为参数传入

三.为什么要谨慎使用MutableLiveData的postValue()方法

简单叙述下我在项目的发现这个问题的经过

  1. 在一个界面显示一个列表,数据从服务器去请求并缓存到本地,这样每次打开这个界面的时候,都优先从本地读取数据,然后在请求服务器,这是个很基本的业务流程
  1. 假如此时发生了断网,当打开这个界面从本地读取到数据后我使用了postValue()方法更新LiveData中的数据并通知观察者,然后请求网络,因为这个时候断网了肯定会发生异常,然后在异常处理处又调用了postValue()方法将发生的异常通知到当前LiveData注册的观察者,这个时候就在非常短的时间内连续调用了两次postValue()方法
  1. 最终发现界面在即使本地有缓存数据还是很高概率的显示一片空白,显示不出任何数据
  1. 笔者第一个想法是协程的创建是不是存在问题,因为这个地方首先我会创建一个协程从本地读取网络数据,然后再创建一个协程去请求服务器数据,类似代码如下:
class MainViewModel: ViewModel() {
    private val _data = MutableLiveData<String>()

    val data: LiveData<String> = _data

    fun updateData() {
        viewModelScope.launch { 
            //读取本地缓存数据
            _data.postValue("读取的本地数据")
        }
        viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
            _data.postValue("请求发生异常")
        }) {
            //请求服务器数据
            _data.postValue("读取的服务器数据")
        }
    }
}

怀疑是断网情况下当请求服务器的协程会发生异常导致读取本地缓存数据的协程被取消,这个问题也就转换成了viewModelScope这个ViewModel的扩展属性创建协程作用域的job类型是不是用的是Job()而不是SupervisorJob(),看下源码:

public val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(
            JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
        )
    }

发现就是SupervisorJob(),那么这个问题就和协程的创建没有多大关系了

5.这个时候就卡住了,然后通过打印日志排查发现视图层注册的观察者对象只接收到了一次通知,按道理来讲,本地读取完毕会通知视图层的观察者,然后网络请求失败也会通知视图层的观察者,应该是两次才对,所以怀疑是postValue()方法里做了特殊处理,所以研究了下postValue()源码,发现就是postValue()里面的逻辑做了处理,防止频繁通过hander创建更新LiveData中数据以及通知观察者的message消息,具体分析前文有讲

  1. 所以这个问题出现原因就是读取本地成功后调用的postValue()和请求失败后调用的postValue()间隔极短,以至于真正通知到视图层观察者的通知只发生了一次,然后将postValue()替换成setValue()方法问题得到解决

四.总结

  1. 要根据业务场景再决定使用MutableLiveData类的setValue()方法还是postValue()方法通知观察者

  2. 协程作用域的创建根据业务场景决定是使用Job()还是SupervisorJob()

以上是关于一个Bug事件,引发我对 LiveData 与 协程 的思考的主要内容,如果未能解决你的问题,请参考以下文章

Android - ViewModel、LiveData、Room 和 Retrofit 以及协程放在 kotlin 中

一个bug引发的血案——从程序员角度看罗一笑事件

livedata数据倒灌与粘性事件

面试官:我看你简历上有写 LiveData,那谈谈LiveData事件机制

Android LiveData防止在观察时接收到最后一个值

jquery slide动画引发的bug解决方法