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 是个什么东西,官方文档对其的定义主要要点有:
- Chanel 用于在一个 sender(发送者) 与一个 receiver(接收者) 之间进行通信,并且它是非阻塞的,也就是说它不会阻塞线程;
- 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 因为某种原因导致协程挂起了,那么会导致后面的流程中断不执行,出现一些莫名其妙的结果。