Android 利用 Kotlin Flow 實(shí)現(xiàn)事件通知

1. 背景

在基于 Lifecycle+LiveData+ViewModel 等的 MVVM 架構(gòu)中窥妇,常規(guī)做法是把數(shù)據(jù)定義在 ViewModel 中符匾,在 Activity 或 Fragment 中監(jiān)聽數(shù)據(jù)的變化,從而更新 UI铡买。你肯定會碰到這方便的場景延刘,執(zhí)行某個耗時操作時需要顯示一個加載對話框匹层,或者操作成功/失敗時分別 Toast 對應(yīng)的信息魂那。以 Toast 為例蛾号,采用 LiveData 一般會這樣來寫:

ViewModel 里定義關(guān)于 toast 信息的 LiveData數(shù)據(jù):

class MyViewModel: ViewModel() {

    private val _toastLiveData = MutableLiveData<String>(null)
    val toastLiveData: LiveData<String> = _toastLiveData

    fun toastInfo() {
        //......
        _toastLiveData.postValue("數(shù)據(jù)加載成功...")
    }
    
}

在 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 使用方法,正常情況下是沒有問題的涯雅,但是當(dāng)我們進(jìn)行橫豎屏切換時就會出問題了鲜结。假設(shè)你已經(jīng) Toast 過一個信息,那么 toastLiveData 持有的就是最新的數(shù)據(jù)活逆,當(dāng)橫豎屏切換時精刷,Activity 會進(jìn)行重建,但是 ViewModel 并不會變化蔗候,Activity 里再次觀察 toastLiveData時怒允,toastLiveData 會將之前最新的數(shù)據(jù)分發(fā)給觀察者,那么立馬就又會 Toast 一個信息出來锈遥。用戶會發(fā)現(xiàn)他就進(jìn)行了一個橫豎屏切換纫事,怎么突然冒出一個 Toast 來,非常令人困惑所灸,而實(shí)際上這個 Toast 就是橫豎屏切換之前最近的一次 Toast 信息丽惶。

2. 分析問題

類似的問題還有很多,比方說有一個頁面爬立,當(dāng)數(shù)據(jù)為某種狀態(tài)時顯示一個動畫然后就結(jié)束钾唬,當(dāng)切換到另一種狀態(tài)時再顯示一個相應(yīng)的動畫。如果采用上面的方法侠驯,橫豎屏切換操作時抡秆,必然會有一些奇怪的動作。我們總結(jié)一下這種現(xiàn)象陵霉,它們都是一種“事件”琅轧,對不同的事件有不同的響應(yīng),并且“事件”大多是一次性消費(fèi)的踊挠。LiveData 適合用來表示“狀態(tài)”乍桂,但“事件”就不太適合用“狀態(tài)”來表示了。
那么在 MVVM 架構(gòu)下效床,我們怎么實(shí)現(xiàn)這種需求呢睹酌,也就是事件通知。在 MVVM 下 View 與 ViewModel 層是解耦的剩檀,ViewModel 層代碼是無法直接調(diào)用 View 層代碼的憋沿,當(dāng)然你可以通過 EventBus 來解決,這是很傳統(tǒng)的解決方案沪猴,我們有更好的解決方案辐啄。

3. 解決方案一:SingleLiveEvent

前面 Toast 的例子中采章,我們對觀察過的數(shù)據(jù)不想再次接收變化了,可以對此做個標(biāo)記壶辜,只有數(shù)據(jù)更新時悯舟,觀察者才能收到數(shù)據(jù)更新。

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) {
            //如果已經(jīng)觀察過了砸民,就不再分發(fā)
            if (mPending.compareAndSet(true, false)) {
                observer.onChanged(it)
            }
        }
    }

}

在 ViewModel 中改成如下即可:

    private val _toastLiveData = SingleLiveData("")
    val toastLiveData: LiveData<String> = _toastLiveData

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

上面這種方法勉強(qiáng)可以解決我們的問題抵怎,但 LiveData 的設(shè)計(jì)初衷并不是如此,總感覺有點(diǎn)別扭岭参。它還有一個問題反惕,如果在一個刷新周期內(nèi)多次更新數(shù)據(jù),LiveData 會將最新的一個數(shù)據(jù)通知給觀察者演侯,而中間的則可能會丟失姿染。因此我們有另一種方案 Kotlin Flow/Channel,它天然支持 Kotlin Coroutine秒际,兩者結(jié)合起來盔粹,可以有效解決我們的問題。
關(guān)于 Kotlin Flow 的基礎(chǔ)知識我不在這里贅述了程癌,熟悉 RxJava 的同學(xué)會發(fā)現(xiàn)它就是其替代品,并且更加簡潔好用轴猎。同樣 Flow 也有冷流(Cold Stream)和熱流(Hot Stream)之分嵌莉,冷流的意思是只有當(dāng)數(shù)據(jù)流被收集(或者說被訂閱時)才會發(fā)射數(shù)據(jù),而熱流則并不一定需要有訂閱者才會發(fā)射數(shù)據(jù)捻脖,沒有時數(shù)據(jù)可以緩存下來锐峭。Channel 是一種熱流,它可以幫助我們解決這種事件通知的問題可婶。

我們先定義事件如下:

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

以常見的請求網(wǎng)絡(luò)接口為例沿癞,在 ViewModel 中定義 Channel,通過 Channel 來發(fā)射數(shù)據(jù):

class MyViewModel: ViewModel() {

    private val _eventChannel = Channel<Event>()
    //Channel 轉(zhuǎn)換為 Flow
    val eventFlow = _eventChannel.receiveAsFlow()

    fun loadDataAsync() {
        viewModelScope.launch {
            //耗時操作之前顯示一個加載彈窗
            _eventChannel.send(Event.LoadingEvent("數(shù)據(jù)正在加載中矛渴,請稍后..."))
            flow {
                //Retrofit api 請求
                var response = RetrofitClient.apiService.getBanners()
                if (response.errorCode == 0) {       
                    //正常獲取到結(jié)果             
                    emit(response.data)
                } else {
                    //手動拋出異常椎扬,后面 catch { } 可以捕捉到進(jìn)行異常統(tǒng)一處理
                    throw ApiException(response.errorCode, response.errorMsg)
                }
            }.flowOn(Dispatchers.IO)
                .catch { e ->
                    e.printStackTrace()
                    //出現(xiàn)異常,通知 Toast 錯誤信息
                    _eventChannel.send(Event.ToastEvent("數(shù)據(jù)獲取失敗..."))
                }.onCompletion {
                    //執(zhí)行完畢具温,關(guān)閉加載彈窗
                    _eventChannel.send(Event.LoadingEvent(""))
                }.collect {
                    //成功得到數(shù)據(jù)
                    _eventChannel.send(Event.ToastEvent("數(shù)據(jù)獲取成功..."))
                }
        }
    }

}

在 Activity 中這樣處理:

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

    //對 Flow 的收集必須運(yùn)行在協(xié)程里
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.eventFlow.collect { event ->
                when (event) {
                    is Event.LoadingEvent -> {
                        if (event.text.isNullOrEmpty()) {
                            //關(guān)閉加載彈窗
                        } else {
                            //顯示加載彈窗
                        }
                    }
                    is Event.ToastEvent -> {
                        //Toast 信息
                    }
                }
            }
        }
    }
}

5. Kotlin Channel 注意事項(xiàng)

初次使用 Channel 的時候蚕涤,很容易出現(xiàn)問題,比如定義了多個 Channel铣猩,怎么 Flow 在收集的時候發(fā)現(xiàn)只有一個生效揖铜,還有就是發(fā)現(xiàn)代碼不執(zhí)行等等。首先我們先了解下 Channel 是個什么東西达皿,官方文檔對其的定義主要要點(diǎn)有:

  1. Chanel 用于在一個 sender(發(fā)送者) 與一個 receiver(接收者) 之間進(jìn)行通信天吓,并且它是非阻塞的贿肩,也就是說它不會阻塞線程;
  2. Channel 類似 Java 里的 BlockingQueue(阻塞隊(duì)列)龄寞;

在 Java 中的 BlockingQueue 是一個隊(duì)列汰规,它通常用于生產(chǎn)者與消費(fèi)者之間的這種場景,生產(chǎn)者向隊(duì)列中添加數(shù)據(jù)萄焦,如果隊(duì)列滿了則會等待阻塞線程控轿,消費(fèi)者從隊(duì)列中取數(shù)據(jù),如果隊(duì)列為空也會等待并阻塞線程拂封。Channel 與之類似茬射,它有兩個主要的方法:

public suspend fun send(element: E)

public suspend fun receive(): E

分別代表發(fā)數(shù)據(jù)和取數(shù)據(jù),這 2 個方法都是 suspend 函數(shù)冒签,表示它們是可以掛起的在抛,功能與 BlockingQueue 是類似的,但不同的是它們可能會掛起協(xié)程萧恕,但不會阻塞線程刚梭。初次使用時,很容易犯這樣的錯誤票唆,舉個例子如下:

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 先發(fā)送一個數(shù)據(jù)
            _testChannel2.send(2)
            //channel1 再發(fā)送一個數(shù)據(jù)
            _testChannel1.send(1)
        }
    }
}
//在 Activity 中收集數(shù)據(jù)
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            //收集 channel1 的數(shù)據(jù)
            viewModel.testFlow1.collect {
                println("test flow ---- $it")
            }
            //收集 channel2 的數(shù)據(jù)
            viewModel.testFlow2.collect {
                println("test flow ---- $it")
            }
        }
    }
}

上面的測試代碼運(yùn)行時朴读,你會發(fā)現(xiàn)啥數(shù)據(jù)也收集不到,但如果你只使用一個 Channel 就貌似沒問題走趋,原因何在呢衅金?Channel 有多種構(gòu)造函數(shù),默認(rèn)構(gòu)造的 Channel 簿煌,調(diào)用其 send 方法時氮唯,如果沒有消費(fèi)者接收數(shù)據(jù)則會掛起協(xié)程,如果消費(fèi)者接收數(shù)據(jù)時姨伟,對應(yīng) Activity 中調(diào)用 flow 的 collect 方法時惩琉,如果 Channel 中沒有數(shù)據(jù),則也會掛起函數(shù)夺荒。

上面這個例子中瞒渠,在 Activity 中 testFlow1.collect 先執(zhí)行,這個時候 channel1 中還沒發(fā)送數(shù)據(jù)般堆,所以協(xié)程掛起在孝,后面的代碼也不執(zhí)行。在 ViewModel 中淮摔,先調(diào)用 _testChannel2.send 方法私沮,由于 Activity 中的協(xié)程已經(jīng)掛起,導(dǎo)致 testFlow2.collect 方法沒有調(diào)用,所以 channel2 也就沒有接收者了仔燕,同樣這里也會掛起協(xié)程造垛,后面的代碼也不會執(zhí)行,有點(diǎn)死鎖那味了晰搀。

那么怎么處理呢五辽,我們可以在 Activity 中可以單獨(dú)啟動一個協(xié)程來來收集數(shù)據(jù),如下所示:

        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 中一個協(xié)程里外恕,有多個 Channel 來發(fā)送數(shù)據(jù)時杆逗,需要特別注意,如果某個 Channel 因?yàn)槟撤N原因?qū)е聟f(xié)程掛起了鳞疲,那么會導(dǎo)致后面的流程中斷不執(zhí)行罪郊,出現(xiàn)一些莫名其妙的結(jié)果。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末尚洽,一起剝皮案震驚了整個濱河市悔橄,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌腺毫,老刑警劉巖癣疟,帶你破解...
    沈念sama閱讀 217,185評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異潮酒,居然都是意外死亡睛挚,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,652評論 3 393
  • 文/潘曉璐 我一進(jìn)店門急黎,熙熙樓的掌柜王于貴愁眉苦臉地迎上來竞川,“玉大人,你說我怎么就攤上這事叁熔。” “怎么了床牧?”我有些...
    開封第一講書人閱讀 163,524評論 0 353
  • 文/不壞的土叔 我叫張陵荣回,是天一觀的道長。 經(jīng)常有香客問我戈咳,道長心软,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,339評論 1 293
  • 正文 為了忘掉前任著蛙,我火速辦了婚禮删铃,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘踏堡。我一直安慰自己猎唁,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,387評論 6 391
  • 文/花漫 我一把揭開白布顷蟆。 她就那樣靜靜地躺著诫隅,像睡著了一般腐魂。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上逐纬,一...
    開封第一講書人閱讀 51,287評論 1 301
  • 那天蛔屹,我揣著相機(jī)與錄音,去河邊找鬼豁生。 笑死兔毒,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的甸箱。 我是一名探鬼主播育叁,決...
    沈念sama閱讀 40,130評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼摇肌!你這毒婦竟也來了擂红?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,985評論 0 275
  • 序言:老撾萬榮一對情侶失蹤围小,失蹤者是張志新(化名)和其女友劉穎昵骤,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體肯适,經(jīng)...
    沈念sama閱讀 45,420評論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡变秦,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,617評論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了框舔。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蹦玫。...
    茶點(diǎn)故事閱讀 39,779評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖刘绣,靈堂內(nèi)的尸體忽然破棺而出樱溉,到底是詐尸還是另有隱情,我是刑警寧澤纬凤,帶...
    沈念sama閱讀 35,477評論 5 345
  • 正文 年R本政府宣布福贞,位于F島的核電站,受9級特大地震影響停士,放射性物質(zhì)發(fā)生泄漏挖帘。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,088評論 3 328
  • 文/蒙蒙 一恋技、第九天 我趴在偏房一處隱蔽的房頂上張望拇舀。 院中可真熱鬧,春花似錦蜻底、人聲如沸骄崩。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,716評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽刁赖。三九已至搁痛,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間宇弛,已是汗流浹背鸡典。 一陣腳步聲響...
    開封第一講書人閱讀 32,857評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留枪芒,地道東北人彻况。 一個月前我還...
    沈念sama閱讀 47,876評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像舅踪,于是被迫代替她去往敵國和親纽甘。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,700評論 2 354

推薦閱讀更多精彩內(nèi)容