LiveData 遷移到 Kotlin Flow詳解

發表於2024-02-11

LiveData ,是Android 2017推出的一個東西,配合MVVM使用。觀察者模式,的確簡化了我們的工作方式,但 RxJava 等選項,對於當時的初學者來說實在是太複雜了。因此 Architecture Components 團隊建立了 LiveData :這是個非常 “有主見的” 可觀察資料持有者類,並且是專門為 Android 設計的。它保持簡單明瞭,這讓它易於上手,建議是將 RxJava 用於更復雜的 響應流 案例,以充分利用這兩者之間的整合。

一、死資料

LiveData 仍然是我們 針對 Java 開發人員、初學者和簡單情況的解決方案。對於其餘部分,一個不錯的選擇是遷移到 Kotlin Flows。Flows 仍然有一個陡峭的學習曲線,但它們是 Kotlin 語言的一部分,由 Jetbrains 提供支援;Compose 即將到來(已到來),它非常適合響應式模型。

一段時間以來,我們一直在討論使用 Flows 連線 app 的不同部分,但 view 和 ViewModel 除外。現在我們有了更安全的方法從 Android UI 收集 flows,我們可以建立一個完整的遷移指南。

在這篇文章中,您將學習如何將 Flows 暴露給view、如何收集它們,以及如何對其進行微調,以滿足特定需求。

二、Flow

支援佈局動態化和邏輯動態化開源社群活躍LiveData 做了一件很漂亮的事兒:它 公開資料,同時快取最新值,並知曉 Android 的生命週期。後來我們瞭解到它也可以 啟動協程,並 建立複雜的轉換,但這就有點複雜了。

讓我們看一些 LiveData 模式及其 Flow 等效程式碼:

2.1 使用Mutable,公開 一次性操作 結果

使用 Mutable(可變)資料持有者,公開一次性操作的結果。這是經典模式,在這種模式中,你能用 協程的結果 來改變 狀態持有者,工作示意圖如下。

image.png

使用 Mutable(可變)資料持有者 (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


    // 從 suspend fun 載入資料並轉變狀態
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

為了對 Flows 執行同樣的操作,我們可以使用(Mutable 可變的)StateFlow。

image.png

然後,使用 可變資料容器(StateFlow),公開 一次性操作 的結果。

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


    // 從 suspend fun 載入資料並轉變狀態
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

StateFlow 是一種特殊型別的SharedFlow(這是一種特定型別的 Flow),最接近 LiveData:

  • 它總有一個值。
  • 它只有一個值。
  • 它支援多個觀察者(因此 flow 是 共享的)。
  • 它總是在訂閱時,replay 最新的值,與 活躍觀察者 的數量無關。

需要說明的是,向 view 公開 UI 狀態時,請使用 StateFlow。它是一個安全高效的觀察者,旨在持有 UI 狀態。

2.2 公開 一次性操作 的結果

這等效於前面的程式碼段,在沒有可變的 後備屬性 的情況下,公開協程呼叫的結果。對於 LiveData,我們使用了 liveData 協程 builder:

image.png

公開 一次性操作 的結果(LiveData)示例程式碼:

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

由於狀態持有者總是有一個值,所以最好將 UI狀態 封裝在某種支援 Loading、Success 和 Error 等狀態的 Result 類中。由於必須進行一些 配置,因此等效的 Flow 程式碼涉及的內容會更多:

image.png

公開 一次性操作 的結果 (StateFlow)示例程式碼:

class MyViewModel(...) : ViewModel() {
    val result: StateFlow<Result<UiState>> = flow {
        emit(repository.fetchItem())
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), // 或者 Lazily,因為它是一次性的
        initialValue = Result.Loading
    )
}

stateIn 是一個 Flow 運算子,它將 Flow 轉換為 StateFlow。讓我們暫時完全信任這些引數,因為我們需要更多的複雜性(知識),才能在以後正確解釋它。

2.3: 帶參一次性資料載入

假設,你想載入一些依賴於使用者 ID 的資料,並且你從公開 Flow 的 AuthManager 獲得這些資訊:

image.png

帶參的 一次性資料 載入(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 成為 LiveData,那麼更好的替代方案是將 streams 與 Flow 結合起來,並最終將公開的結果,轉換為 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()
}

使用 Flows 執行此操作,看起來非常相似:

image.png

帶參的 一次性資料 載入 (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 // 注意不同的 Loading 狀態
    )

2.4: 觀察帶有引數的資料流

現在讓我們讓這個例子,更具 響應性。資料不是被獲取的,而是 被觀察的,因此我們將資料來源中的更改,自動傳播到 UI。

繼續我們的示例:我們不在資料來源上呼叫 fetchItem,而是使用一個假設的 observeItem 運算子來返回 Flow。使用 LiveData,您可以將流轉換為 LiveData,並 emitSource 所有更新:

image.png

觀察帶有引數的 stream(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 組合兩個 flow,並僅將輸出轉換為 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()
}

Flow 實現是類似的,但沒有 LiveData 轉換:

觀察帶有引數的 stream(StateFlow)

image.png

觀察帶有引數的 stream(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
    )
}

每當使用者更改,或儲存庫中的使用者資料更改時,公開的 StateFlow 都將收到更新。

2.5 合併多個資料來源

MediatorLiveData 讓您可以觀察一個或多個更新源(LiveData 可觀察物件)並在它們獲得新資料時做一些事情。 通常,我們可以使用下面的方式更新 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))
}

不過,改用Flow 後就要簡單許多,等效程式碼如下:

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


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

當然,我們還可以使用combineTransform 函式或 zip。

三、配置公開的 StateFlow

我們之前使用 stateIn 將常規 flow 轉換為 StateFlow,但它需要一些配置。如果你現在不想深入細節,只想複製貼上,那麼我推薦這種組合:

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

可以看到,stateIn 有 3 個引數(來自文件):

  • @param scope :開啟共享的 協程作用域。
  • @param started :控制共享 何時開始 和 何時停止 的策略。
  • @param initialValue :state Flow 的初始值。當使用帶有 replayExpirationMillis 引數的 [SharingStarted.WhileSubscribed] 策略,重置 state flow 時,也會使用此值。

而started也有3個取值:

  • Lazily:當第一個訂閱者出現時開始,當 scope 被取消時停止。
  • Eagerly:立即開始,並在 scope 被取消時停止
  • WhileSubscribed: 比較複雜了

 

對於 一次性操作,您可以使用 Lazily 或 Eagerly。但是,如果您正在觀察其他 flow,則應該使用 WhileSubscribed 來進行小而重要的最佳化,如下所述。

四、WhileSubscribed 策略

WhileSubscribed 在沒有收集者時,會取消 上游 flow。使用 stateIn 建立的 StateFlow 將資料公開給 view,但它也會觀察來自 其他層 或 app(上游) 的 flow。保持這些 flow 處於活躍的狀態,可能會導致資源浪費,例如,假如它們持續從資料庫連線、硬體感測器等其他來源讀取資料(的話,就會導致資源浪費)。當您的 app 進入後臺時,您應該做個良好市民,停止這些協程。

WhileSubscribed 需要兩個引數:

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

4.1 Stop 超時

stopTimeoutMillis 是用來配置 最後一個訂閱者消失 和 上游flow停止 之間的延遲(以毫秒為單位)的。它預設為零(也就是 立即停止)。

 

事實上,stopTimeoutMillis在Android開發中的用處很大,因為如果 view 在幾分之一秒內就停止監聽的話,你肯定不想取消上游 flow。這種情況總是發生——例如,當使用者旋轉裝置時,view 會被快速連續地銷燬和重新建立。

liveData 協程 builder 中的解決方案,是 新增 5 秒的延遲,之後如果沒有訂閱者,那麼協程將停止。 WhileSubscribed(5000) 就是這麼幹的:

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

這種方法包含下面這幾條內容:

  • 當使用者將您的 app 傳送到後臺時,來自其他層的更新,將在五秒後停止,以節省電池電量。
  • 最新的值仍將被快取,以便當使用者返回它時,view 立即就能有一些資料。
  • 訂閱將重新啟動,新值將出現,並在可用時重新整理螢幕。

4.2 Replay 到期時間

如果您不希望使用者在離開太久時,看到過時的資料,並且您更喜歡顯示 loading 畫面,請檢視 WhileSubscribed 中的 replayExpirationMillis 引數。在這種情況下它非常方便並且還節省了一些記憶體,因為快取的值將被恢復為 stateIn 中定義的初始值。返回 app 不會那麼快,但卻不會顯示舊的資料。

replayExpirationMillis -配置 共享協程的停止 和 replay快取的重置 之間的延遲(以毫秒為單位)(這將使 shareIn 運算子的快取為空,並將快取值重置為 stateIn 運算子的原始 initialValue)。它預設為 Long.MAX_VALUE(永遠保留replay快取,從不重置緩衝區)。使用零值可使快取立即過期。

五、從 view 觀察 StateFlow

正如我們到目前為止所看到的,讓 ViewModel 中的 StateFlow 知道他們不再監聽了,對 view 來說是非常重要的。然而,就像所有與生命週期相關的事情一樣,事情並沒有那麼簡單。

為了收集 flow,您需要一個協程。 Activities 和 fragments 提供了一堆協程 builder:

  • Activity.lifecycleScope.launch:立即啟動協程,並在 activity 被銷燬時取消它。
  • Fragment.lifecycleScope.launch:立即啟動協程,並在 fragment 被銷燬時取消它。
  • Fragment.viewLifecycleOwner.lifecycleScope.launch:立即啟動協程,並在 fragment 的 view lifecycle 被銷燬時取消協程。如果你正在修改 UI,你應該使用 view lifecycle。

六、LaunchWhenStarted, launchWhenResumed…

名為 launchWhenX 的,即launch 的特殊版本,將一直等待,直到 lifecycleOwner 處於 X 狀態,並在 lifecycleOwner 低於 X 狀態時,掛起協程。需要注意的是,在它們的生命週期所有者被銷燬之前,它們是不會取消協程的。

image.png

使用 launch/launchWhenX 收集 Flow,是不安全的。在 app 處於後臺時接收更新,可能會導致崩潰,可以透過在檢視中掛起 collection,來解決這個問題。但是,當 app 處於後臺時,上游 flow 仍處於活躍狀態,這可能會浪費資源。

這意味著,到目前為止,我們為配置 StateFlow 所做的一切都將毫無用處;但是,眼下有一個新的 API 登場了。

七、lifecycle.repeatOnLifecycle

這個新的協程 builder(可從 lifecycle-runtime-ktx 2.4.0-alpha01 獲得)正是我們所需要的:它在特定狀態下啟動協程,並在生命週期所有者低於該狀態時停止協程。

image.png

以下是不同的 Flow 收集方法。例如,在 Fragment 中:

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

這將在 Fragment 的 view STARTED 的時候 ,開始收集,並在返回到 STOPPED 時停止。閱讀以更安全的方式從 Android UI 收集 flow 的全部內容。

將 repeatOnLifecycle API,與上述 StateFlow 指南混合使用,可以在充分利用裝置資源的同時,獲得最佳效能。

image.png

StateFlow 透過 WhileSubscribed(5000) 公開,並透過 repeatOnLifecycle(STARTED) 收集。

警告:最近新增到 Data Binding 的 StateFlow 支援 使用 launchWhenCreated 來收集更新,當達到穩定狀態時,將開始使用 repeatOnLifecycle 來代替。

對於 Data Binding 來說,你應該隨處使用 Flow,並簡單地新增 asLiveData(),將其公開給 view。當 lifecycle-runtime-ktx 2.4.0 變得穩定時,Data Binding 也會被更新。

八、總結

從 ViewModel 公開資料,並從 view 中收集資料的最佳方式是:

任何其他組合,都將使上游 flow 保持活躍狀態,從而浪費資源:

  • 使用 WhileSubscribed 公開,並在 lifecycleScope.launch/launchWhenX 中收集
  • 使用 Lazily/Eagerly 公開,並使用repeatOnLifecycle 收集