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)有:
- Chanel 用于在一個 sender(發(fā)送者) 與一個 receiver(接收者) 之間進(jìn)行通信天吓,并且它是非阻塞的贿肩,也就是說它不會阻塞線程;
- 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é)果。