從 LiveData 遷移到 Kotlin 資料流
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
}
}
}
StateFlow 是 SharedFlow 的一個比較特殊的變種,而 SharedFlow 又是 Kotlin 資料流當中比較特殊的一種型別。StateFlow 與 LiveData 是最接近的,因為:
- 它始終是有值的。
- 它的值是唯一的。
- 它允許被多個觀察者共用 (因此是共享的資料流)。
- 它永遠只會把最新的值重現給訂閱者,這與活躍觀察者的數量是無關的。
當暴露 UI 的狀態給檢視時,應該使用 StateFlow。這是一種安全和高效的觀察者,專門用於容納 UI 狀態。
#2: 把一次性操作的結果暴露出來
這個例子與上面程式碼片段的效果一致,只是這裡暴露協程呼叫的結果而無需使用可變屬性。
如果使用 LiveData,我們需要使用 LiveData 協程構建器:
△ 把一次性操作的結果暴露出來 (LiveData)
class MyViewModel(...) : ViewModel() {
val result: LiveData<Result<UiState>> = liveData {
emit(Result.Loading)
emit(repository.fetchItem())
}
}
由於狀態容器總是有值的,那麼我們就可以通過某種 Result 類來把 UI 狀態封裝起來,比如載入中、成功、錯誤等狀態。
與之對應的資料流方式則需要您多做一點配置:
△ 把一次性操作的結果暴露出來 (StateFlow)
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、Florina 和 Chris 致謝!