從 LiveData 遷移到 Kotlin 數據流

image

LiveData 的歷史要追溯到 2017 年。彼時,觀察者模式有效簡化了開發,但諸如 RxJava 一類的庫對新手而言有些太過復雜。為此,架構組件團隊打造了 LiveData: 一個專用于 Android 的具備自主生命周期感知能力的可觀察的數據存儲器類。LiveData 被有意簡化設計,這使得開發者很容易上手;而對于較為復雜的交互數據流場景,建議您使用 RxJava,這樣兩者結合的優勢就發揮出來了。

DeadData?

LiveData 對于 Java 開發者、初學者或是一些簡單場景而言仍是可行的解決方案。而對于一些其他的場景,更好的選擇是使用 Kotlin 數據流 (Kotlin Flow)。雖說數據流 (相較 LiveData) 有更陡峭的學習曲線,但由于它是 JetBrains 力挺的 Kotlin 語言的一部分,且 Jetpack Compose 正式版即將發布,故兩者配合更能發揮出 Kotlin 數據流中響應式模型的潛力。

此前一段時間,我們探討了 如何使用 Kotlin 數據流 來連接您的應用當中除了視圖和 View Model 以外的其他部分。而現在我們有了 一種更安全的方式來從 Android 的界面中獲得數據流,已經可以創作一份完整的遷移指南了。

在這篇文章中,您將學到如何把數據流暴露給視圖、如何收集數據流,以及如何通過調優來適應不同的需求。

數據流: 把簡單復雜化,又把復雜變簡單

LiveData 就做了一件事并且做得不錯: 它在 緩存最新的數據 和感知 Android 中的生命周期的同時將數據暴露了出來。稍后我們會了解到 LiveData 還可以 啟動協程創建復雜的數據轉換,這可能會需要花點時間。

接下來我們一起比較 LiveData 和 Kotlin 數據流中相對應的寫法吧:

#1: 使用可變數據存儲器暴露一次性操作的結果

這是一個經典的操作模式,其中您會使用協程的結果來改變狀態容器:

△ 將一次性操作的結果暴露給可變的數據容器 (LiveData)
<!-- Copyright 2020 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 -->

class MyViewModel {
    private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading)
    val myUiState: LiveData<Result<UiState>> = _myUiState

// 從掛起函數和可變狀態中加載數據
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

如果要在 Kotlin 數據流中執行相同的操作,我們需要使用 (可變的) StateFlow (狀態容器式可觀察數據流):

△ 使用可變數據存儲器 (StateFlow) 暴露一次性操作的結果
class MyViewModel {
    private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading)
    val myUiState: StateFlow<Result<UiState>> = _myUiState

    // 從掛起函數和可變狀態中加載數據
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

StateFlowSharedFlow 的一個比較特殊的變種,而 SharedFlow 又是 Kotlin 數據流當中比較特殊的一種類型。StateFlow 與 LiveData 是最接近的,因為:

  • 它始終是有值的。
  • 它的值是唯一的。
  • 它允許被多個觀察者共用 (因此是共享的數據流)。
  • 它永遠只會把最新的值重現給訂閱者,這與活躍觀察者的數量是無關的。

當暴露 UI 的狀態給視圖時,應該使用 StateFlow。這是一種安全和高效的觀察者,專門用于容納 UI 狀態。

#2: 把一次性操作的結果暴露出來

這個例子與上面代碼片段的效果一致,只是這里暴露協程調用的結果而無需使用可變屬性。

如果使用 LiveData,我們需要使用 LiveData 協程構建器:

[圖片上傳失敗...(image-8fdf8-1624927141051)]

class MyViewModel(...) : ViewModel() {
    val result: LiveData<Result<UiState>> = liveData {
        emit(Result.Loading)
        emit(repository.fetchItem())
    }
}

由于狀態容器總是有值的,那么我們就可以通過某種 Result 類來把 UI 狀態封裝起來,比如加載中、成功、錯誤等狀態。

與之對應的數據流方式則需要您多做一點配置:

[圖片上傳失敗...(image-bb42f-1624927141051)]

class MyViewModel(...) : ViewModel() {
    val result: StateFlow<Result<UiState>> = flow {
        emit(repository.fetchItem())
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), //由于是一次性操作,也可以使用 Lazily 
        initialValue = Result.Loading
    )
}

stateIn 是專門將數據流轉換為 StateFlow 的運算符。由于需要通過更復雜的示例才能更好地解釋它,所以這里暫且把這些參數放在一邊。

#3: 帶參數的一次性數據加載

比方說您想要加載一些依賴用戶 ID 的數據,而信息來自一個提供數據流的 AuthManager:

△ 帶參數的一次性數據加載 (LiveData)

使用 LiveData 時,您可以用類似這樣的代碼:

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result: LiveData<Result<Item>> = userId.switchMap { newUserId ->
        liveData { emit(repository.fetchItem(newUserId)) }
    }
}

switchMap 是數據變換中的一種,它訂閱了 userId 的變化,并且其代碼體會在感知到 userId 變化時執行。

如非必須要將 userId 作為 LiveData 使用,那么更好的方案是將流式數據和 Flow 結合,并將最終的結果 (result) 轉化為 LiveData。

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: LiveData<Result<Item>> = userId.mapLatest { newUserId ->
       repository.fetchItem(newUserId)
    }.asLiveData()
}

如果改用 Kotlin Flow 來編寫,代碼其實似曾相識:

△ 帶參數的一次性數據加載 (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId ->
        repository.fetchItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )
}

假如說您想要更高的靈活性,可以考慮顯式調用 transformLatest 和 emit 方法:

val result = userId.transformLatest { newUserId ->
        emit(Result.LoadingData)
        emit(repository.fetchItem(newUserId))
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser //注意此處不同的加載狀態
    )

#4: 觀察帶參數的數據流

接下來我們讓剛才的案例變得更具交互性。數據不再被讀取,而是被觀察,因此我們對數據源的改動會直接被傳遞到 UI 界面中。

繼續剛才的例子: 我們不再對源數據調用 fetchItem 方法,而是通過假定的 observeItem 方法獲取一個 Kotlin 數據流。

若使用 LiveData,可以將數據流轉換為 LiveData 實例,然后通過 emitSource 傳遞數據的變化。

△ 觀察帶參數的數據流 (LiveData)
class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result = userId.switchMap { newUserId ->
        repository.observeItem(newUserId).asLiveData()
    }
}

或者采用更推薦的方式,把兩個流通過 flatMapLatest 結合起來,并且僅將最后的輸出轉換為 LiveData:

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.asLiveData()
}

使用 Kotlin 數據流的實現方式非常相似,但是省下了 LiveData 的轉換過程:

△ 觀察帶參數的數據流 (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser
    )
}

每當用戶實例變化,或者是存儲區 (repository) 中用戶的數據發生變化時,上面代碼中暴露出來的 StateFlow 都會收到相應的更新信息。

#5: 結合多種源: MediatorLiveData -> Flow.combine

MediatorLiveData 允許您觀察一個或多個數據源的變化情況,并根據得到的新數據進行相應的操作。通常可以按照下面的方式更新 MediatorLiveData 的值:

val liveData1: LiveData<Int> = ...
val liveData2: LiveData<Int> = ...

val result = MediatorLiveData<Int>()

result.addSource(liveData1) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}
result.addSource(liveData2) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}

同樣的功能使用 Kotlin 數據流來操作會更加直接:

val flow1: Flow<Int> = ...
val flow2: Flow<Int> = ...

val result = combine(flow1, flow2) { a, b -> a + b }

此處也可以使用 combineTransform 或者 zip 函數。

通過 stateIn 配置對外暴露的 StateFlow

早前我們使用 stateIn 中間運算符來把普通的流轉換成 StateFlow,但轉換之后還需要一些配置工作。如果現在不想了解太多細節,只是想知道怎么用,那么可以使用下面的推薦配置:

val result: StateFlow<Result<UiState>> = someFlow
    .stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )

不過,如果您想知道為什么會使用這個看似隨機的 5 秒的 started 參數,請繼續往下讀。

根據文檔,stateIn 有三個參數:

@param scope 共享開始時所在的協程作用域范圍

@param started 控制共享的開始和結束的策略

@param initialValue 狀態流的初始值

當使用 [SharingStarted.WhileSubscribed] 并帶有 `replayExpirationMillis` 參數重置狀態流時,也會用到 initialValue。

started 接受以下的三個值:

  • Lazily: 當首個訂閱者出現時開始,在 scope 指定的作用域被結束時終止。
  • Eagerly: 立即開始,而在 scope 指定的作用域被結束時終止。
  • WhileSubscribed: 這種情況有些復雜 (后文詳聊)。

對于那些只執行一次的操作,您可以使用 Lazily 或者 Eagerly。然而,如果您需要觀察其他的流,就應該使用 WhileSubscribed 來實現細微但又重要的優化工作,參見后文的解答。

WhileSubscribed 策略

WhileSubscribed 策略會在沒有收集器的情況下取消上游數據流。通過 stateIn 運算符創建的 StateFlow 會把數據暴露給視圖 (View),同時也會觀察來自其他層級或者是上游應用的數據流。讓這些流持續活躍可能會引起不必要的資源浪費,例如一直通過從數據庫連接、硬件傳感器中讀取數據等等。當您的應用轉而在后臺運行時,您應當保持克制并中止這些協程。

WhileSubscribed 接受兩個參數:

public fun WhileSubscribed(
   stopTimeoutMillis: Long = 0,
   replayExpirationMillis: Long = Long.MAX_VALUE
)

超時停止

根據其文檔:

stopTimeoutMillis 控制一個以毫秒為單位的延遲值,指的是最后一個訂閱者結束訂閱與停止上游流的時間差。默認值是 0 (立即停止)。

這個值非常有用,因為您可能并不想因為視圖有幾秒鐘不再監聽就結束上游流。這種情況非常常見——比如當用戶旋轉設備時,原來的視圖會先被銷毀,然后數秒鐘內重建。

liveData 協程構建器所使用的方法是 添加一個 5 秒鐘的延遲,即如果等待 5 秒后仍然沒有訂閱者存在就終止協程。前文代碼中的 WhileSubscribed (5000) 正是實現這樣的功能:

class MyViewModel(...) : ViewModel() {
    val result = userId.mapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )
}

這種方法會在以下場景得到體現:

  • 用戶將您的應用轉至后臺運行,5 秒鐘后所有來自其他層的數據更新會停止,這樣可以節省電量。
  • 最新的數據仍然會被緩存,所以當用戶切換回應用時,視圖立即就可以得到數據進行渲染。
  • 訂閱將被重啟,新數據會填充進來,當數據可用時更新視圖。

數據重現的過期時間

如果用戶離開應用太久,此時您不想讓用戶看到陳舊的數據,并且希望顯示數據正在加載中,那么就應該在 WhileSubscribed 策略中使用 replayExpirationMillis 參數。在這種情況下此參數非常適合,由于緩存的數據都恢復成了 stateIn 中定義的初始值,因此可以有效節省內存。雖然用戶切回應用時可能沒那么快顯示有效數據,但至少不會把過期的信息顯示出來。

replayExpirationMillis 配置了以毫秒為單位的延遲時間,定義了從停止共享協程到重置緩存 (恢復到 stateIn 運算符中定義的初始值 initialValue) 所需要等待的時間。它的默認值是長整型的最大值 Long.MAX_VALUE (表示永遠不將其重置)。如果設置為 0,可以在符合條件時立即重置緩存的數據。

從視圖中觀察 StateFlow

我們此前已經談到,ViewModel 中的 StateFlow 需要知道它們已經不再需要監聽。然而,當所有的這些內容都與生命周期 (lifecycle) 結合起來,事情就沒那么簡單了。

要收集一個數據流,就需要用到協程。Activity 和 Fragment 提供了若干協程構建器:

  • Activity.lifecycleScope.launch : 立即啟動協程,并且在本 Activity 銷毀時結束協程。
  • Fragment.lifecycleScope.launch : 立即啟動協程,并且在本 Fragment 銷毀時結束協程。
  • Fragment.viewLifecycleOwner.lifecycleScope.launch : 立即啟動協程,并且在本 Fragment 中的視圖生命周期結束時取消協程。

LaunchWhenStarted 和 LaunchWhenResumed

對于一個狀態 X,有專門的 launch 方法稱為 launchWhenX。它會在 lifecycleOwner 進入 X 狀態之前一直等待,又在離開 X 狀態時掛起協程。對此,需要注意對應的協程只有在它們的生命周期所有者被銷毀時才會被取消。

△ 使用 launch/launchWhenX 來收集數據流是不安全的

當應用在后臺運行時接收數據更新可能會引起應用崩潰,但這種情況可以通過將視圖的數據流收集操作掛起來解決。然而,上游數據流會在應用后臺運行期間保持活躍,因此可能浪費一定的資源。

這么說來,目前我們對 StateFlow 所進行的配置都是無用功;不過,現在有了一個新的 API。

lifecycle.repeatOnLifecycle 前來救場

這個新的協程構建器 (自 lifecycle-runtime-ktx 2.4.0-alpha01 后可用) 恰好能滿足我們的需要: 在某個特定的狀態滿足時啟動協程,并且在生命周期所有者退出該狀態時停止協程。

△ 不同數據流收集方法的比較

比如在某個 Fragment 的代碼中:

onCreateView(...) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
            myViewModel.myUiState.collect { ... }
        }
    }
}

當這個 Fragment 處于 STARTED 狀態時會開始收集流,并且在 RESUMED 狀態時保持收集,最終在 Fragment 進入 STOPPED 狀態時結束收集過程。如需獲取更多信息,請參閱: 使用更為安全的方式收集 Android UI 數據流

結合使用 repeatOnLifecycle API 和上面的 StateFlow 示例可以幫助您的應用妥善利用設備資源的同時,發揮最佳性能。

△ 該 StateFlow 通過 WhileSubscribed(5000) 暴露并通過 repeatOnLifecycle(STARTED) 收集

注意: 近期在 Data Binding 中加入的 StateFlow 支持 使用了 launchWhenCreated 來描述收集數據更新,并且它會在進入穩定版后轉而使用 repeatOnLifecyle

對于數據綁定,您應該在各處都使用 Kotlin 數據流并簡單地加上 asLiveData() 來把數據暴露給視圖。數據綁定會在 lifecycle-runtime-ktx 2.4.0 進入穩定版后更新。

總結

通過 ViewModel 暴露數據,并在視圖中獲取的最佳方式是:

  • ?? 使用帶超時參數的 WhileSubscribed 策略暴露 StateFlow。[示例 1]
  • ?? 使用 repeatOnLifecycle 來收集數據更新。[示例 2]

如果采用其他方式,上游數據流會被一直保持活躍,導致資源浪費:

  • ? 通過 WhileSubscribed 暴露 StateFlow,然后在 lifecycleScope.launch/launchWhenX 中收集數據更新。
  • ? 通過 Lazily/Eagerly 策略暴露 StateFlow,并在 repeatOnLifecycle 中收集數據更新。

當然,如果您并不需要使用到 Kotlin 數據流的強大功能,就用 LiveData 好了 :)

Manuel、Wojtek、Yigit、Alex Cook、FlorinaChris 致謝!

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,362評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,013評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 177,346評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,421評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,146評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,534評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,585評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,767評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,318評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,074評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,258評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,828評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,486評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,916評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,156評論 1 290
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,993評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,234評論 2 375

推薦閱讀更多精彩內容