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<String>(null)
    val toastLiveData: LiveData<String> = _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<T>(data: T): MutableLiveData<T>(data) {

    private val mPending = AtomicBoolean(false)

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

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

}

在 ViewModel 中改成如下即可:

    private val _toastLiveData = SingleLiveData("")
    val toastLiveData: LiveData<String> = _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<Event>()
    //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<Int>()
    val testFlow1 = _testChannel1.receiveAsFlow()
    //定义 channel2
    private val _testChannel2 = Channel<Int>()
    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 因为某种原因导致协程挂起了,那么会导致后面的流程中断不执行,出现一些莫名其妙的结果。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容