Android 利用 Kotlin Flow 实现事件通知

1. 背景

在基于 Lifecycle+LiveData+ViewModel 等的 MVVM 架构中,常规做法是把数据定义在 ViewModel 中,在 Activity 或 Fragment 中监听数据的变化,从而更新 UI。你肯定会碰到这方便的场景,执行某个耗时操作时需要显示一个加载对话框,或者操作成功/失败时分别 Toast 对应的信息。以 Toast 为例,采用 LiveData 一般会这样来写:

ViewModel 里定义关于 toast 信息的 LiveData数据:

class MyViewModel: ViewModel() {

    private val _toastLiveData = MutableLiveData(null)
    val toastLiveData: LiveData = _toastLiveData

    fun toastInfo() {
        //......
        _toastLiveData.postValue("数据加载成功...")
    }
    
}

在 Activity 里:

class MyActivity: AppCompatActivity() {

    lateinit var viewModel: MyViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel = ViewModelProvider(this).get(MyViewModel::class.java)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.toastLiveData.observe(this@MyActivity) {
                    Toast.makeText(this@MyActivity, it, Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

这是一个很典型的 LiveData 使用方法,正常情况下是没有问题的,但是当我们进行横竖屏切换时就会出问题了。假设你已经 Toast 过一个信息,那么 toastLiveData 持有的就是最新的数据,当横竖屏切换时,Activity 会进行重建,但是 ViewModel 并不会变化,Activity 里再次观察 toastLiveData时,toastLiveData 会将之前最新的数据分发给观察者,那么立马就又会 Toast 一个信息出来。用户会发现他就进行了一个横竖屏切换,怎么突然冒出一个 Toast 来,非常令人困惑,而实际上这个 Toast 就是横竖屏切换之前最近的一次 Toast 信息。

2. 分析问题

类似的问题还有很多,比方说有一个页面,当数据为某种状态时显示一个动画然后就结束,当切换到另一种状态时再显示一个相应的动画。如果采用上面的方法,横竖屏切换操作时,必然会有一些奇怪的动作。我们总结一下这种现象,它们都是一种“事件”,对不同的事件有不同的响应,并且“事件”大多是一次性消费的。LiveData 适合用来表示“状态”,但“事件”就不太适合用“状态”来表示了。
那么在 MVVM 架构下,我们怎么实现这种需求呢,也就是事件通知。在 MVVM 下 View 与 ViewModel 层是解耦的,ViewModel 层代码是无法直接调用 View 层代码的,当然你可以通过 EventBus 来解决,这是很传统的解决方案,我们有更好的解决方案。

3. 解决方案一:SingleLiveEvent

前面 Toast 的例子中,我们对观察过的数据不想再次接收变化了,可以对此做个标记,只有数据更新时,观察者才能收到数据更新。

class SingleLiveData(data: T): MutableLiveData(data) {

    private val mPending = AtomicBoolean(false)

    override fun setValue(value: T) {
        mPending.set(true)
        super.setValue(value)
    }

    override fun observe(owner: LifecycleOwner, observer: Observer) {
        super.observe(owner) {
            //如果已经观察过了,就不再分发
            if (mPending.compareAndSet(true, false)) {
                observer.onChanged(it)
            }
        }
    }

}

在 ViewModel 中改成如下即可:

    private val _toastLiveData = SingleLiveData("")
    val toastLiveData: LiveData = _toastLiveData

4. 解决方案二:Kotlin Flow / Channel

上面这种方法勉强可以解决我们的问题,但 LiveData 的设计初衷并不是如此,总感觉有点别扭。它还有一个问题,如果在一个刷新周期内多次更新数据,LiveData 会将最新的一个数据通知给观察者,而中间的则可能会丢失。因此我们有另一种方案 Kotlin Flow/Channel,它天然支持 Kotlin Coroutine,两者结合起来,可以有效解决我们的问题。
关于 Kotlin Flow 的基础知识我不在这里赘述了,熟悉 RxJava 的同学会发现它就是其替代品,并且更加简洁好用。同样 Flow 也有冷流(Cold Stream)和热流(Hot Stream)之分,冷流的意思是只有当数据流被收集(或者说被订阅时)才会发射数据,而热流则并不一定需要有订阅者才会发射数据,没有时数据可以缓存下来。Channel 是一种热流,它可以帮助我们解决这种事件通知的问题。

我们先定义事件如下:

sealed class Event {
    
    //Toast 事件通知
    data class ToastEvent(val text: String): Event()
    
    //加载弹窗事件通知
    data class LoadingEvent(val text: String): Event()
    
}

以常见的请求网络接口为例,在 ViewModel 中定义 Channel,通过 Channel 来发射数据:

class MyViewModel: ViewModel() {

    private val _eventChannel = Channel()
    //Channel 转换为 Flow
    val eventFlow = _eventChannel.receiveAsFlow()

    fun loadDataAsync() {
        viewModelScope.launch {
            //耗时操作之前显示一个加载弹窗
            _eventChannel.send(Event.LoadingEvent("数据正在加载中,请稍后..."))
            flow {
                //Retrofit api 请求
                var response = RetrofitClient.apiService.getBanners()
                if (response.errorCode == 0) {       
                    //正常获取到结果             
                    emit(response.data)
                } else {
                    //手动抛出异常,后面 catch { } 可以捕捉到进行异常统一处理
                    throw ApiException(response.errorCode, response.errorMsg)
                }
            }.flowOn(Dispatchers.IO)
                .catch { e ->
                    e.printStackTrace()
                    //出现异常,通知 Toast 错误信息
                    _eventChannel.send(Event.ToastEvent("数据获取失败..."))
                }.onCompletion {
                    //执行完毕,关闭加载弹窗
                    _eventChannel.send(Event.LoadingEvent(""))
                }.collect {
                    //成功得到数据
                    _eventChannel.send(Event.ToastEvent("数据获取成功..."))
                }
        }
    }

}

在 Activity 中这样处理:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    ......

    //对 Flow 的收集必须运行在协程里
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.eventFlow.collect { event ->
                when (event) {
                    is Event.LoadingEvent -> {
                        if (event.text.isNullOrEmpty()) {
                            //关闭加载弹窗
                        } else {
                            //显示加载弹窗
                        }
                    }
                    is Event.ToastEvent -> {
                        //Toast 信息
                    }
                }
            }
        }
    }
}

5. Kotlin Channel 注意事项

初次使用 Channel 的时候,很容易出现问题,比如定义了多个 Channel,怎么 Flow 在收集的时候发现只有一个生效,还有就是发现代码不执行等等。首先我们先了解下 Channel 是个什么东西,官方文档对其的定义主要要点有:

  1. Chanel 用于在一个 sender(发送者) 与一个 receiver(接收者) 之间进行通信,并且它是非阻塞的,也就是说它不会阻塞线程;
  2. Channel 类似 Java 里的 BlockingQueue(阻塞队列);

在 Java 中的 BlockingQueue 是一个队列,它通常用于生产者与消费者之间的这种场景,生产者向队列中添加数据,如果队列满了则会等待阻塞线程,消费者从队列中取数据,如果队列为空也会等待并阻塞线程。Channel 与之类似,它有两个主要的方法:

public suspend fun send(element: E)

public suspend fun receive(): E

分别代表发数据和取数据,这 2 个方法都是 suspend 函数,表示它们是可以挂起的,功能与 BlockingQueue 是类似的,但不同的是它们可能会挂起协程,但不会阻塞线程。初次使用时,很容易犯这样的错误,举个例子如下:

class MyViewModel: ViewModel() {
    //定义 channel1
    private val _testChannel1 = Channel()
    val testFlow1 = _testChannel1.receiveAsFlow()
    //定义 channel2
    private val _testChannel2 = Channel()
    val testFlow2 = _testChannel2.receiveAsFlow()

    fun test() {
        viewModelScope.launch {
            //channel2 先发送一个数据
            _testChannel2.send(2)
            //channel1 再发送一个数据
            _testChannel1.send(1)
        }
    }
}
//在 Activity 中收集数据
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            //收集 channel1 的数据
            viewModel.testFlow1.collect {
                println("test flow ---- $it")
            }
            //收集 channel2 的数据
            viewModel.testFlow2.collect {
                println("test flow ---- $it")
            }
        }
    }
}

上面的测试代码运行时,你会发现啥数据也收集不到,但如果你只使用一个 Channel 就貌似没问题,原因何在呢?Channel 有多种构造函数,默认构造的 Channel ,调用其 send 方法时,如果没有消费者接收数据则会挂起协程,如果消费者接收数据时,对应 Activity 中调用 flow 的 collect 方法时,如果 Channel 中没有数据,则也会挂起函数。

上面这个例子中,在 Activity 中 testFlow1.collect 先执行,这个时候 channel1 中还没发送数据,所以协程挂起,后面的代码也不执行。在 ViewModel 中,先调用 _testChannel2.send 方法,由于 Activity 中的协程已经挂起,导致 testFlow2.collect 方法没有调用,所以 channel2 也就没有接收者了,同样这里也会挂起协程,后面的代码也不会执行,有点死锁那味了。

那么怎么处理呢,我们可以在 Activity 中可以单独启动一个协程来来收集数据,如下所示:

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.testFlow1.collect {
                    println("test flow ---- $it")
                }
            }
        }
        lifecycleScope.launch{
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.testFlow2.collect {
                    println("test flow ---- $it")
                }
            }
        }

在 ViewModel 中一个协程里,有多个 Channel 来发送数据时,需要特别注意,如果某个 Channel 因为某种原因导致协程挂起了,那么会导致后面的流程中断不执行,出现一些莫名其妙的结果。

你可能感兴趣的:(Android 利用 Kotlin Flow 实现事件通知)