[譯]帶有SnackBar、Navigation和其他事件的LiveData

ronaldong發表於2019-02-17

檢視(Activity或Fragment)與ViewModel進行通訊的一種便捷的方式是使用LiveData,檢視可以訂閱LiveData中的資料變化並對其作出反饋。這適用於那些需要一直在螢幕上顯示的資料。

[譯]帶有SnackBar、Navigation和其他事件的LiveData

但是,有些資料只應該被消費一次,比如顯示Snackbar訊息、導航事件或者彈出對話方塊。

[譯]帶有SnackBar、Navigation和其他事件的LiveData

不要試圖使用第三方庫或者是擴充套件Architecture來解決這個問題,你應當把它看作是一個設計問題。We recommend you treat your events as part of your state。本文將列出一些解決這個問題的錯誤方法,並給出我們推薦的方法。

❌ Bad: 1. Using LiveData for events

這種方法在LiveData物件內部直接持有Snackbar訊息或導航訊號。原則上它看起來像一個普通的LiveData物件,但是它存在一些問題。

在一個列表/詳情的應用中,列表介面的ViewModel如下:

// Do not use this for events
class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Boolean>()

    val navigateToDetails : LiveData<Boolean>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.value = true
    }
}
複製程式碼

在檢視(Activity或Fragment)裡:

listViewModel.navigateToDetails.observe(this, Observer {
    if (it) startActivity(DetailsActivity...)
})
複製程式碼

這種方法的問題在於,_navigateToDetails的值會一直為true,導致無法返回到列表介面。詳細情況如下:

  1. 使用者在列表介面中點選按鈕進入詳情介面。
  2. 使用者按下返回鍵返回到列表介面。
  3. 列表介面的觀察者觀察到_navigateToDetails的值為true,會再次錯誤的跳轉到詳情介面。

一種解決方法是在_navigateToDetails的值被設定為true之後,立即修改它的值為flase

fun userClicksOnButton() {
    _navigateToDetails.value = true
    _navigateToDetails.value = false // Don`t do this
}
複製程式碼

上面的方法是錯誤的。因為LiveData中雖然可以儲存資料,但不保證發出它接收到的每個值。例如:在沒有觀察者處於活動狀態時設定一個值,這時候如果再給它設定一個新的值,那麼新的值將直接替換舊的值。此外,在不同執行緒中設定值可能會導致衝突,使得它只會向觀察者發出一次變化通知。

但是這種方法的主要問題在於如何確保導航事件發生後LiveData中的值一定會被被重置?

❌ Better: 2. Using LiveData for events, resetting event values in observer

上面的方法還可以衍生出另一種解決方法:在檢視中告訴ViewModel你已經處理了該導航事件,並且希望它重置該事件,即修改_navigateToDetails的值為flase

只需對方法1的程式碼做簡單的修改:

listViewModel.navigateToDetails.observe(this, Observer {
    if (it) {
        myViewModel.navigateToDetailsHandled()
        startActivity(DetailsActivity...)
    }
})
複製程式碼
class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Boolean>()

    val navigateToDetails : LiveData<Boolean>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.value = true
    }

    fun navigateToDetailsHandled() {
        _navigateToDetails.value = false
    }
}
複製程式碼

這種方法的問題是增加了許多冗餘的程式碼,對於每一個事件都需要在ViewModel中新增對應的方法,並且很容易忘記呼叫ViewModel中的這些方法。

✔️ OK: Use SingleLiveEvent

SingleLiveEvent類就是為解決上述問題而建立的,它是一個只會傳送一次資料更新的LiveData。

用法

class ListViewModel : ViewModel {
    private val _navigateToDetails = SingleLiveEvent<Any>()

    val navigateToDetails : LiveData<Any>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.call()
    }
}
複製程式碼
listViewModel.navigateToDetails.observe(this, Observer {
    startActivity(DetailsActivity...)
})
複製程式碼

SingleLiveEvent的問題在於它僅限於一個觀察者。如果您無意中新增了多個觀察者,那麼只會有一個觀察者會對它作出反饋,並且不能保證是哪一個。

[譯]帶有SnackBar、Navigation和其他事件的LiveData

✔️ Recommended: Use an Event wrapper

使用這種方法您能明確地知道事件是否已被處理,從而減少錯誤。

用法

/**
 * Used as a wrapper for data that is exposed via a LiveData that represents an event.
 */
open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set // Allow external read but not write

    /**
     * Returns the content and prevents its use again.
     */
    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    /**
     * Returns the content, even if it`s already been handled.
     */
    fun peekContent(): T = content
}
複製程式碼
class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Event<String>>()

    val navigateToDetails : LiveData<Event<String>>
        get() = _navigateToDetails


    fun userClicksOnButton(itemId: String) {
        _navigateToDetails.value = Event(itemId)  // Trigger the event by setting a new Event as a new value
    }
}
複製程式碼
listViewModell.navigateToDetails.observe(this, Observer {
    it.getContentIfNotHandled()?.let { // Only proceed if the event has never been handled
        startActivity(DetailsActivity...)
    }
})
複製程式碼

這種方法的好處是使用者可以通過呼叫getContentIfNotHandled()方法來將導航事件與目標觀察者關聯起來。它把導航事件視為一種狀態:consumed 或not consumed。

[譯]帶有SnackBar、Navigation和其他事件的LiveData

總而言之:你應當把事件設計為一種狀態。你可以自定義一個事件包裝器以滿足您的需求。

如果你的應用中有許多類似的事件,建議使用EventObserver類來刪除一些重複性程式碼。

相關文章