實戰 | 使用 Kotlin Flow 構建資料流 "管道"

Android開發者發表於2022-03-26

Flow 是一種基於流的程式設計模型,本文我們將向大家介紹響應式程式設計以及其在 Android 開發中的實踐,您將瞭解到如何將生命週期、旋轉及切換到後臺等狀態繫結到 Flow 中,並且測試它們是否能按照預期執行。

如果您更喜歡通過視訊瞭解此內容,請在此處檢視:

https://www.bilibili.com/vide...

△ 使用 Kotlin Flow 構建資料流 "管道"

單向資料流

△ 載入資料流的過程

△ 載入資料流的過程

每款 Android 應用都需要以某種方式收發資料,比如從資料庫獲取使用者名稱、從伺服器載入文件,以及對使用者進行身份驗證等。接下來,我們將介紹如何將資料載入到 Flow,然後經過轉換後暴露給檢視進行展示。

為了大家更方便地理解 Flow,我們以 Pancho (潘喬) 的故事來展開。當住在山上的 Pancho 想從湖中獲取淡水時,會像大多數新手一開始一樣,拿個水桶走到湖邊取水,然後再走回來。

△ 山上的 Pancho

△ 山上的 Pancho

但有時 Pahcho 不走運,走到湖邊時發現湖水已經乾涸,於是就不得不再去別處尋找水源。發生了幾次這種情況後,Pancho 意識到,搭建一些基礎設施可以解決這個問題。於是他在湖邊安裝了一些管道,當湖中有水時,只用擰開水龍頭就能取到水。知道了如何安裝管道,就能很自然地想到從多個水源地把管道組合,這樣一來 Pancho 就不必再檢查湖水是否已經乾涸。

△ 鋪設管道

△ 鋪設管道

在 Android 應用中您可以簡單地在每次需要時請求資料,例如我們可以使用掛起函式來實現在每次檢視啟動時向 ViewModel 請求資料,而後 ViewModel 又向資料層請求資料,接下來這一切又在相反的方向上發生。不過這樣過了一段時間之後,像 Pancho 這樣的開發者們往往會想到,其實有必要投入一些成本來構建一些基礎設施,我們就可以不再請求資料而改為觀察資料。觀察資料就像安裝取水管道一樣,部署完成後對資料來源的任何更新都將自動向下流動到檢視中,Pancho 再也不用走到湖邊去了。

△ 傳統的請求資料與單向資料流

△ 傳統的請求資料與單向資料流

響應式程式設計

我們將這類觀察者會自動對被觀察者物件的變化而作出反應的系統稱之為響應式程式設計,它的另一個設計要點是保持資料只在一個方向上流動,因為這樣更容易管理且不易出錯。

某個示例應用介面的 "資料流動" 如下圖所示,身份認證管理器會告訴資料庫使用者已登入,而資料庫又必須告訴遠端資料來源來載入一組不同的資料;與此同時這些操作在獲取新資料時都會告訴檢視顯示一個轉圈的載入圖示。對此我想說這雖然是可行的,但容易出現錯誤。

△ 錯綜複雜的 "資料流動"

△ 錯綜複雜的 "資料流動"

更好的方式則是讓資料只在一個方向上流動,並建立一些基礎設施 (像 Pancho 鋪設管道那樣) 來組合和轉換這些資料流,這些管道可以隨著狀態的變化而修改,比如在使用者退出登入時重新安裝管道。

△ 單向資料繫結

△ 單向資料繫結

使用 Flow

可以想象對於這些組合和轉換來說,我們需要一個成熟的工具來完成這些操作。在本文中我們將使用 Kotlin Flow 來實現。Flow 並不是唯一的資料流構建器,不過得益於它是協程的一部分並且得到了很好的支援。我們剛才一直用作比喻的水流,在協程庫裡稱之為 Flow 型別,我們用泛形 T 來指代資料流承載的使用者資料或者頁面狀態等任何型別。

△ 生產者和消費者

△ 生產者和消費者

生產者會將資料 emit (傳送) 到資料流中,而消費者則從資料流中 collect (收集) 這些資料。在 Android 中資料來源或儲存區通常是應用資料的生產者;消費者則是檢視,它會把資料顯示在螢幕上。

大多數情況下您都無需自行建立資料流,因為資料來源中依賴的庫,例如 DataStore、Retrofit、Room 或 WorkManager 等常見的庫都已經與協程及 Flow 整合在一起了。這些庫就像是水壩,它們使用 Flow 來提供資料,您無需瞭解資料是如何生成的,只需 "接入管道" 即可。

△ 提供 Flow 支援的庫

△ 提供 Flow 支援的庫

我們來看一個 Room 的例子。您可以通過匯出指定型別的資料流來獲取資料庫中發生變更的通知。在本例中,Room 庫是生產者,它會在每次查詢後發現有更新時傳送內容。

@DAO
interface CodelabsDAO {
 
    @Query("SELECT * FROM codelabs")
    fun getAllCodelabs(): Flow<List<Codelab>>
}

建立 Flow

如果您要自己建立資料流,有一些方案可供選擇,比如資料流構建器。假設我們處於 UserMessagesDataSource 中,當您希望頻繁地在應用內檢查新訊息時,可以將使用者訊息暴露為訊息列表型別的資料流。我們使用資料流構建器來建立資料流,因為 Flow 是在協程上下文環境中執行的,它以掛起程式碼塊作為引數,這也意味著它能夠呼叫掛起函式,我們可以在程式碼塊中使用 while(true)來迴圈執行我們的邏輯。

在示例程式碼中,我們首先從 API 獲取訊息,然後使用 emit 掛起函式將結果新增到 Flow 中,這將掛起協程直到收集器接收到資料項,最後我們將協程掛起一段時間。在 Flow 中,操作會在同一個協程中順序執行,使用 while(true) 迴圈可以讓 Flow 持續獲取新訊息直到觀察者停止收集資料。傳遞給資料流構建器的掛起程式碼塊通常被稱為 "生產者程式碼塊"。

class UserMessagesDataSource(
    private val messagesApi: MessagesApi,
    private val refreshIntervalMs: Long = 5000
) {
    val latestMessages: Floa<List<Message>> = flow {
        white(true) {
            val userMessages = messagesApi.fetchLatestMessages()
            emit(userMessages) // 將結果傳送給 Flow
            delay(refreshIntervalMs) // ⏰ 掛起一段時間
        }
    }
}

轉換 Flow

在 Android 中,生產者和消費者之間的層可以使用中間運算子修改資料流來適應下一層的要求。

在本例中,我們將 latestMessages 流作為資料流的起點,則可以使用 map 運算子將資料轉換為不同的型別,例如我們可以使用 map lambda 表示式將來自資料來源的原始訊息轉換為 MessagesUiModel,這一操作可以更好地抽象當前層級,每個運算子都應根據其功能建立一個新的 Flow 來傳送資料。我們還可以使用 filter 運算子過濾資料流來獲得包含重要通知的資料流。而 catch 運算子則可以捕獲上游資料流中發生的異常,上游資料流是指在生產者程式碼塊和當前運算子之間呼叫的運算子產生的資料流,而在當前運算子之後生成的資料流則被稱為下游資料流。catch 運算子還可以在有需要的時候再次丟擲異常或者傳送新值,我們在示例程式碼中可以看到其在捕獲到 IllegalArgumentExceptions 時將其重新丟擲,並且在發生其他異常時傳送一個空列表:

val importantUserMessages: Flow<MessageUiModel> = 
    userMessageDataSource.latestMessages
        .map { userMessage ->
            userMessages.toUiModel()
        }
        .filter { messageUiModel ->
            messagesUiModel.containsImportantNotifications()
        }
        .catch { e ->
            analytics.log("Error loading reserved event")
            if (e is IllegalArgumentException) throw e
            else emit(emptyList())
        }

收集 Flow

現在我們已經瞭解過如何生成和修改資料流,接下來了解一下如何收集資料流。收集資料流通常發生在檢視層,因為這是我們想要在螢幕上顯示資料的地方。

在本例中,我們希望列表中能夠顯示最新訊息以便 Pancho 能夠了解最新動態。我們可以使用終端運算子 collect 來監聽資料流傳送的所有值,collect 接收一個函式作為引數,每個新值都會呼叫該引數,並且由於它是一個掛起函式,因此需要在協程中執行。

userMessages.collect { messages ->
    listAdapter.submitList(messages)
}

在 Flow 中使用終端運算子將按需建立資料流並開始傳送值,而相反的是中間操作符只是設定了一個操作鏈,其會在資料被髮送到資料流時延遲執行。每次對 userMessages 呼叫 collect 時都會建立一個新的資料流,其生產者程式碼塊將根據自己的時間間隔開始重新整理來自 API 的訊息。在協程中我們將這種按需建立並且只有在被觀察時才會傳送資料的資料流稱之為 冷流 (Cold Stream)。

在 Android 檢視上收集資料流

在 Android 的檢視中收集資料流要注意兩點,第一是在後臺執行時不應浪費資源,第二是配置變更。

安全收集

假設我們在 MessagesActivity 中,如果希望在螢幕上顯示訊息列表,則應該當介面沒有顯示在螢幕上時停止收集,就像是 Pancho 在刷牙或者睡覺時應該關上水龍頭一樣。我們有多種具有生命週期感知能力的方案,來實現當資訊不在螢幕上展示就不從資料流中收集資訊的功能,比如 androidx.lifecycle:lifecycle-runtime-ktx 包中的 Lifecycle.repeatOnLifecycle(state)Flow<T>.flowWithLifecycle(lifecycle, state)。您還可以在 ViewModel 中使用 androidx.lifecycle:lifecycle-livedata-ktx 包裡的 Flow<T>.asLiveData(): LiveData 將資料流轉換為 LiveData,這樣就可以像往常一樣使用 LiveData 來實現這件事情。不過為了簡單起見,這裡推薦使用 repeatOnLifecycle 從介面層收集資料流。

repeatOnLifecycle 是一個接收 Lifecycle.State 作為引數的掛起函式,該 API 具有生命週期感知能力,所以能夠在當生命週期進入響應狀態時自動使用傳遞給它的程式碼塊啟動新的協程,並且在生命週期離開該狀態時取消該協程。在上面的例子中,我們使用了 Activity 的 lifecycleScope 來啟動協程,由於 repeatOnLifecycle 是掛起函式,所以它需要在協程中被呼叫。最佳實踐是在生命週期初始化時呼叫該函式,就像上面的例子中我們在 Activity 的 onCreate 中呼叫一樣:

import androidx.lifecycle.repeatOnLifecycle
 
class MessagesActivity : AppCompatActivity() {
 
    val viewModel: MessagesViewModel by viewModels()
 
    override fun onCreate(savedInstanceState: Bundle?) {
           
            lifecycleScope.launch {
                repeatOnLifecycle(Lifecycle.State.STARTED)
                    viewModel.userMessages.collect { messages ->
                        listAdapter.submitList(messages)
                    }
                }
                // 協程將會在 lifecycle 進入 DESTROYED 後被恢復
            }
    }
}

repeatOnLifecycle 的可重啟行為充分考慮了介面的生命週期,不過需要注意的是,直到生命週期進入 DESTROYED,呼叫 repeatOnLifecycle 的協程都不會恢復執行,因此如果您需要從多個資料流中進行收集,則應在 repeatOnLifecycle 程式碼塊內多次使用 launch 來建立協程:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
 
            launch {
                viewModel.userMessages.collect { … }
            }
 
            launch {
                otherFlow.collect { … }
            }
    }
}

如果只需從一個資料流中進行收集,則可使用 flowWithLifecycle 來收集資料,它能夠在生命週期進入目標狀態時傳送資料,並在離開目標狀態時取消內部的生產者:

lifecycleScope.launch {
    viewModel.userMessages
        .flowWithLifecycle(lifecycle, State.STARTED)
        .collect { messages ->
            listAdapter.submitList(messages)
        }
}

為了能夠直觀地展示具體的運作過程,我們來探索一下此 Activity 的生命週期,首先是建立完成並向使用者可見;接下來使用者按下了主螢幕按鈕將應用退到後臺,此時 Activity 會收到 onStop 訊號;當重新開啟應用時又會呼叫 onStart。如果您呼叫 repeatOnLifecycle 並傳入 STARTED 狀態,介面就只會在螢幕上顯示時收集資料流發出的訊號,並且在應用轉到後臺時取消收集。

△ Activity 的生命週期

△ Activity 的生命週期

repeatOnLifecycleflowWithLifecycle 是 lifecycle-runtime-ktx 庫在 2.4.0 穩定版中新增的 API,在沒有這些 API 之前您可能已經以其他方式從 Android 介面中收集資料流,例如像上面的程式碼一樣直接從 lifecycleScope.launch 啟動的協程中收集,雖然這樣看起來也能工作但不一定安全,因為這種方式將持續從資料流中收集資料並更新介面元素,即便是應用退出到後臺時也一樣。如果使用 launchWhenStarted 替代它的話,情況會稍微好一些,因為它會在處於後臺時將收集掛起。但這樣會在讓資料流生產者保持活躍狀態,有可能會在後臺持續發出不需要在螢幕上顯示的資料項,從而將記憶體佔滿。由於介面並不知道資料流生產者的實現方式,所以最好謹慎一些,使用 repeatOnLifecycleflowWithLifecycle 來避免介面在處於後臺時收集資料或保持資料流生產者處於活躍狀態。

下面是一段不安全的使用方式示例:

class MessagesActivity : AppCompatActivity() {
 
    val viewModel: MessagesViewModel by viewModels()
 
    override fun onCreate(savedInstanceState: Bundle?) {
 
            // ❌ 危險的操作
            lifecycleScope.launch {
                viewModel.userMessage.collect { messages ->
                    listAdapter.submitList(messages)
                }
            }
 
            // ❌ 危險的操作
            LifecycleCoroutineScope.launchWhenX {
                flow.collect { … }
            }
    }
}

配置變更

當您向檢視暴露資料流時,必須要考慮到您正在嘗試在具有不同生命週期的兩個元素之間傳遞資料,並不是所有生命週期都會出現問題,但在 Activity 和 Fragment 的生命週期裡會比較棘手。當裝置旋轉或者接收到配置變更時,所有的 Activity 都可能會重啟但 ViewModel 卻能被保留,因此您不能把任意資料流都簡單地從 ViewModel 中暴露出來。

△ 旋轉螢幕會重建 Activity 但能夠保留 ViewModel

△ 旋轉螢幕會重建 Activity 但能夠保留 ViewModel

以如下程式碼中的冷流為例,由於每次收集冷流時它都會重啟,所以在裝置旋轉之後會再次呼叫 repository.fetchItem()。我們需要某種緩衝區機制來保障無論重新收集多少次都可以保持資料,並在多個收集器之間共享資料,而 StateFlow 正是為了此用途而設計的。在我們的湖泊比喻中,StateFlow 就好比水箱,即使沒有收集器它也能持有資料。因為它可以多次被收集,所以能夠放心地將其與 Activity 或 Fragment 一起使用。

val result: Flow<Result<UiState>> = flow {
    emit(repository.fetchItem())
}

您可以使用 StateFlow 的可變版本,並隨時根據需要在協程中更新它的值,但這樣做可能不太符合響應式程式設計的風格,如下程式碼所示:

private val _myUiState = MutableStateFlow<MyUiState>()
 
val myUiState: StateFlow<MyUiState> = _myUiState
 
init {
    viewModelScope.launch {
        _muUiState.value = Result.Loading
        _myUiState.value = repository.fetchStuff()
    }
}

Pancho 會建議您將各種型別的資料流都轉換為 StateFlow 來改進這個問題,這樣 StateFlow 將接收來自上游資料流的所有更新並儲存最新的值,並且收集器的數量可以是 0 至任意多個,因此非常適合與 ViewModel 一起使用。當然,除此之外還有一些其他型別的 Flow,但推薦您使用 StateFlow,因為我們可以對它進行非常精確的優化。

△ 將任意資料流轉換為 StateFlow

△ 將任意資料流轉換為 StateFlow

要將資料流轉換為 StateFlow 可以使用 stateIn 運算子,它需要傳入三個引數: initinalValuescopestarted。其中 initialValue 是因為 StateFlow 必須有值;而協程 scope 則是用於控制何時開始共享,在上面的例子中我們使用了 viewModelScope;最後的 started 是個有趣的引數,我們後面會聊到 WhileSubscribed(5000) 的作用,先看這部分的程式碼:

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

我們來看看這兩個場景: 第一種場景是旋轉,在該場景中 Activity (也就是資料流收集器) 在短時間內被銷燬然後重建;第二個場景是回到主螢幕,這將會使我們的應用進入後臺。在旋轉場景中我們不希望重啟任何資料流以便儘可能快地完成過渡,而在回到主螢幕的場景中我們則希望停止所有資料流以便節省電量和其他資源。

我們可以通過設定超時時間來正確判斷不同的場景,當停止收集 StateFlow時,不會立即停止所有上游資料流,而是會等待一段時間,如果在超時前再次收集資料則不會取消上游資料流,這就是 WhileSubscribed(5000) 的作用。當設定了超時時間後,如果按下主螢幕按鈕會讓檢視立即結束收集,但 StateFlow 會經過我們設定的超時時間之後才會停止其上游資料流,如果使用者再次開啟應用則會自動重啟上游資料流。而在旋轉場景中檢視只停止了很短的時間,無論如何都不會超過 5 秒鐘,因此 StateFlow 並不會重啟,所有的上游資料流都將會保持在活躍狀態,就像什麼都沒有發生一樣可以做到即時向使用者呈現旋轉後的螢幕。

△ 設定超時時間來應對不同的場景

△ 設定超時時間來應對不同的場景

總的來說,建議您使用 StateFlow 來通過 ViewModel 暴露資料流,或者使用 asLiveData 來實現同樣的目的,關於 StateFlow 或其父類 SharedFlow 的更多詳細資訊,請參閱: StateFlow 和 SharedFlow

測試資料流

測試資料流可能會比較複雜,因為要處理的物件是流式資料,這裡介紹在兩個不同的場景中有用的小技巧:

首先是第一個場景,被測單元依賴了資料流,那對此類場景進行測試最簡單的方法就是用模擬生產者替代依賴項。在本例中,您可以對這個模擬源進行程式設計以對不同的測試用例傳送其所需要的內容。您可以像上面的例子一樣實現一個簡單的冷流,測試本身會對受測物件的輸出進行斷言,輸出的內容可以是資料流或其他任何型別。

△ 被測單元依賴資料流的測試技巧

△ 被測單元依賴資料流的測試技巧

模擬被測單元所依賴的資料流:

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

如果受測單元暴露一個資料流,並且您希望驗證該值或一系列值,那麼您可以通過多種方式收集它們。您可以對資料流呼叫 first() 方法以進行收集並在接收到第一個資料項後停止收集。您還可以呼叫 take(5) 並使用 toList 終端操作符來收集恰好 5 條訊息,這種方法可能非常有幫助。

△ 測試資料流的技巧

△ 測試資料流的技巧

測試資料流:

@Test
fun myTest() = runBlocking {
 
    // 收集第一個資料然後停止收集
    val firstItem = repository.counter.first()
 
    // 收集恰好 5 條訊息
    val first = repository.messages.take(5).toList()
}

回顧

感謝閱讀本文,希望您通過本文內容已經瞭解到為什麼響應式架構值得投資,以及如何使用 Kotlin Flow 構建您的基礎設施。文末提供了有關這方面的資料,包括涵蓋基礎知識的指南以及深入探討某些主題的文章。另外您還可以通過 Google I/O 應用瞭解這些內容的詳細資訊,我們在早些時候為其更新了很多有關資料流的內容。

歡迎您 點選這裡 向我們提交反饋,或分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支援!

相關文章