協(xié)程 Flow 最佳實踐 | 基于 Android 開發(fā)者峰會應(yīng)用

本文介紹了我們在開發(fā) 2019 Android 開發(fā)者峰會 (ADS) 應(yīng)用時總結(jié)整理的 Flow 最佳實踐 (應(yīng)用源碼已開源),我們將和大家共同探討應(yīng)用中的每個層級將如何處理數(shù)據(jù)流。

ADS 應(yīng)用的架構(gòu)遵守 Android 官方的推薦架構(gòu)指南,我們在其中引入了 Domain 層 (用以囊括各種 UseCases 類) 來幫助分離焦點,進而保持代碼的精簡、復(fù)用性、可測試性。

2019 ADS 應(yīng)用的架構(gòu)

更多關(guān)于應(yīng)用架構(gòu)指南的分層設(shè)計 (Data 層、Domain 層、UI 層),請參考示例應(yīng)用 | Plaid 2.0 重構(gòu)

如同許多 Android 應(yīng)用一樣,ADS 應(yīng)用從網(wǎng)絡(luò)或緩存懶加載數(shù)據(jù)。我們發(fā)現(xiàn),這種場景非常適合 Flow。掛起函數(shù) (suspend functions) 更適合于一次性操作。為了使用協(xié)程,我們將重構(gòu)分為兩次 commit 提交: 第一次遷移了一次性操作,第二次將其遷移至數(shù)據(jù)流。

在本文中,您將看到我們把應(yīng)用從 "在所有層級使用 LiveData",重構(gòu)為 "只在 View 和 ViewModel 間使用 LiveData 進行通訊,并在應(yīng)用的底層和 UserCase 層架構(gòu)中使用協(xié)程"。

優(yōu)先使用 Flow 來暴露數(shù)據(jù)流 (而不是 Channel)

您有兩種方法在協(xié)程中處理數(shù)據(jù)流: 一種是 Flow API,另一種是 Channel API。Channels 是一種同步原語,而 Flows 是為數(shù)據(jù)流模型所設(shè)計的: 它是訂閱數(shù)據(jù)流的工廠。不過我們可以使用 Channels 來支持 Flows,這一點我們稍后再說。

相較于 Channel,F(xiàn)low 更靈活,并提供了更明確的約束和更多操作符。

由于末端操作符 (terminal operator) 會觸發(fā)數(shù)據(jù)流的執(zhí)行,同時會根據(jù)生產(chǎn)者一側(cè)流操作來決定是成功完成操作還是拋出異常,因此 Flows 會自動地關(guān)閉數(shù)據(jù)流,您基本不會在生產(chǎn)者一側(cè)泄漏資源;而一旦 Channel 沒有正確關(guān)閉,生產(chǎn)者可能不會清理大型資源,因此 Channels 更容易造成資源泄漏。

應(yīng)用數(shù)據(jù)層負責(zé)提供數(shù)據(jù),通常是從數(shù)據(jù)庫中讀取,或從網(wǎng)絡(luò)獲取數(shù)據(jù),例如,示例是一個數(shù)據(jù)源接口,它提供了一個用戶事件數(shù)據(jù)流:

interface UserEventDataSource {
  fun getObservableUserEvent(userId: String): Flow<UserEventResult>
}

如何將 Flow 應(yīng)用在您的 Android 應(yīng)用架構(gòu)中

1. UseCase 層和 Repository 層
介于 View/ViewModel 和數(shù)據(jù)源之間的層 (在我們的例子中是 UseCase 和 Repository) 通常需要合并來自多個查詢的數(shù)據(jù),或在 ViewModel 層使用之前轉(zhuǎn)化數(shù)據(jù)。就像 Kotlin sequences 一樣,F(xiàn)low 支持大量操作符來轉(zhuǎn)換數(shù)據(jù)。目前已經(jīng)有大量的可用的操作符,同時您也可以創(chuàng)建您自己的轉(zhuǎn)換器 (比如,使用 transform 操作符)。不過 Flow 在許多的操作符中暴露了 suspend lambda 表達式,因此在大多數(shù)情況下沒有必要通過自定義轉(zhuǎn)換來完成復(fù)雜任務(wù),可以直接在 Flow 中調(diào)用掛起函數(shù)。

在 ADS 應(yīng)用中,我們想將 UserEventResult 和 Repository 層中的會話數(shù)據(jù)進行綁定。我們利用 map 操作符來將一個 suspend lambda 表達式應(yīng)用在從數(shù)據(jù)源接收到的每一個 Flow 的值上:


/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
class DefaultSessionAndUserEventRepository(
    private val userEventDataSource: UserEventDataSource,
    private val sessionRepository: SessionRepository
) : SessionAndUserEventRepository {

    override fun getObservableUserEvent(
        userId: String?,
        eventId: SessionId
    ): Flow<Result<LoadUserSessionUseCaseResult>> {
        // 處理 userId

        // 監(jiān)聽用戶事件,并將其與 Session 數(shù)據(jù)進行合并
        return userEventDataSource.getObservableUserEvent(userId, eventId).map { userEventResult ->
            val event = sessionRepository.getSession(eventId)

           // 將 Session 和用戶數(shù)據(jù)進行合并,并傳遞結(jié)果
            val userSession = UserSession(
                event,
                userEventResult.userEvent ?: createDefaultUserEvent(event)
            )
            Result.Success(LoadUserSessionUseCaseResult(userSession))
        }
    }
}

2. ViewModel
在利用 LiveData 執(zhí)行 UI ? ViewModel 通信時,ViewModel 層應(yīng)該利用末端操作符來消費來自數(shù)據(jù)層的數(shù)據(jù)流 (比如: collectfirst 或者是 toList) 。


/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
// 真實代碼的簡化版
class SessionDetailViewModel(
    private val loadUserSessionUseCase: LoadUserSessionUseCase,
    ...
): ViewModel() {

    private fun listenForUserSessionChanges(sessionId: SessionId) {
        viewModelScope.launch {
            loadUserSessionUseCase(sessionId).collect { loadResult ->
            }
        }
    }
}

如果您需要將 Flow 轉(zhuǎn)化為 LiveData,則可以使用 AndroidX lifecycle library 提供的 Flow.asLiveData() 擴展函數(shù) (extension function)。這個擴展函數(shù)非常便于使用,因為它共享了 Flow 的底層訂閱,同時根據(jù)觀察者的生命周期管理訂閱。此外,LiveData 可以為后續(xù)添加的觀察者提供最新的數(shù)據(jù),其訂閱在配置發(fā)生變更的時候依舊能夠生效。下面利用一段簡單的代碼來演示如何使用這個擴展函數(shù):

class SimplifiedSessionDetailViewModel(
  private val loadUserSessionUseCase: LoadUserSessionUseCase,
  ...
): ViewModel() {
  val sessions = loadUserSessionUseCase(sessionId).asLiveData()
}

特別說明: 這段代碼不是 ADS 應(yīng)用的,它只是用來演示如何使用 Flow.asLiveData()。

具體實現(xiàn)時,該在何時使用 BroadcastChannel 或者 Flow

回到數(shù)據(jù)源的實現(xiàn),要怎樣去實現(xiàn)之前暴露的 getObservableUserEvent 函數(shù)?我們考慮了兩種實現(xiàn): flow 構(gòu)造器,或 BroadcastChannel 接口,這兩種實現(xiàn)應(yīng)用于不同的場景。

1. 什么時候使用 Flow ?
Flow 是一種 "冷流"(Cold Stream)。"冷流" 是一種數(shù)據(jù)源,該類數(shù)據(jù)源的生產(chǎn)者會在每個監(jiān)聽者開始消費事件的時候執(zhí)行,從而在每個訂閱上創(chuàng)建新的數(shù)據(jù)流。一旦消費者停止監(jiān)聽或者生產(chǎn)者的阻塞結(jié)束,數(shù)據(jù)流將會被自動關(guān)閉。

Flow 非常適合需要開始/停止數(shù)據(jù)的產(chǎn)生來匹配觀察者的場景.

您可以利用 flow 構(gòu)造器來發(fā)送有限個/無限個元素。


val oneElementFlow: Flow<Int> = flow {
  // 生產(chǎn)者代碼開始執(zhí)行,流被打開
  emit(1)
 // 生產(chǎn)者代碼結(jié)束,流將被關(guān)閉
}
val unlimitedElementFlow: Flow<Int> = flow {
 // 生產(chǎn)者代碼開始執(zhí)行,流被打開
  while(true) {
    // 執(zhí)行計算
    emit(result)
    delay(100)
  }
 // 生產(chǎn)者代碼結(jié)束,流將被關(guān)閉
}

Flow 通過協(xié)程取消功能提供自動清理功能,因此傾向于執(zhí)行一些重型任務(wù)。請注意,這里提到的取消是有條件的,一個永不掛起的 Flow 是永不會被取消的: 在我們的例子中,由于 delay 是一個掛起函數(shù),用于檢查取消狀態(tài),當訂閱者停止監(jiān)聽時,F(xiàn)low 將會停止并清理資源。

2. 什么時候使用 BroadcastChannel
Channel 是一個用于協(xié)程間通信的并發(fā)原語。BroadcastChannel 基于 Channel,并加入了多播功能。

可能在這樣一些場景里,您可能會考慮在數(shù)據(jù)源層中使用 BroadcastChannel:

如果生產(chǎn)者和消費者的生命周期不同或者彼此完全獨立運行時,請使用 BroadcastChannel。

如果您希望生產(chǎn)者有獨立的生命周期,同時向任何存在的監(jiān)聽者發(fā)送當前數(shù)據(jù)的時候,BroadcastChannel API 非常適合這種場景。在這種情況下,當新的監(jiān)聽者開始消費事件時,生產(chǎn)者不需要每次都被執(zhí)行。

您依然可以向調(diào)用者提供 Flow,它們不需要知道具體的實現(xiàn)。您可以使用 BroadcastChannel.asFlow() 這個擴展函數(shù)來將一個 BroadcastChannel 作為一個 Flow 使用。

不過,關(guān)閉這個特殊的 Flow 不會取消訂閱。當使用 BroadcastChannel 的時候,您必須自己管理生命周期。BroadcastChannel 無法感知到當前是否還存在監(jiān)聽者,除非關(guān)閉或取消 BroadcastChannel,否則將會一直持有資源。請確保在不需要 BroadcastChannel 的時候?qū)⑵潢P(guān)閉。同時請注意關(guān)閉后的 BroadcastChannel 無法再次被使用,如果需要,您需要重新創(chuàng)建實例。

3. 特別說明
部分 Flow 和 Channel API 仍處于實驗階段,很可能會發(fā)生變動。在一些情況下,您可能會正在使用 Channel,不過在未來可能會建議您使用 Flow。具體來講,StateFlow 和 Flow 的 share operator 方案可能在未來會減少 Channel 的使用。

將數(shù)據(jù)流中基于回調(diào)的 API 轉(zhuǎn)化為協(xié)程

包含 Room 在內(nèi)的很多庫已經(jīng)支持將協(xié)程用于數(shù)據(jù)流操作。對于那些還不支持的庫,您可以將任何基于回調(diào)的 API 轉(zhuǎn)換為協(xié)程。

1. Flow 的實現(xiàn)

如果您想將一個基于回調(diào)的流 API 轉(zhuǎn)換為使用 Flow,您可以使用 channelFlow 函數(shù) (當然也可以使用 callbackFlow,它們都基于相同的實現(xiàn))。channelFlow 將會創(chuàng)建一個 Flow 的實例,該實例中的元素將傳遞給一個 Channel。這樣可以允許我們在不同的上下文或并發(fā)中提供元素。

以下示例中,我們想要把從回調(diào)中拿到的元素發(fā)送到 Flow 中:

  1. 利用 channelFlow 構(gòu)造器創(chuàng)建一個可以把回調(diào)注冊到第三方庫的流;
  2. 將從回調(diào)接收到的所有數(shù)據(jù)傳遞給 Flow;
  3. 當訂閱者停止監(jiān)聽,我們利用掛起函數(shù) "awaitClose" 來解除 API 的訂閱。

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
override fun getObservableUserEvent(userId: String, eventId: SessionId): Flow<UserEventResult> {
     // 1) 利用 channelFlow 創(chuàng)建一個 Flow
    return channelFlow<UserEventResult> {

        val eventDocument = firestore.collection(USERS_COLLECTION)
            .document(userId)
            .collection(EVENTS_COLLECTION)
            .document(eventId)
    
       // 1) 將回調(diào)注冊到 API 上
        val subscription = eventDocument.addSnapshotListener { snapshot, _ ->
            val userEvent = if (snapshot.exists()) {
                parseUserEvent(snapshot)
            } else { null }
            
            // 2) 將數(shù)據(jù)發(fā)送到 Flow
            channel.offer(UserEventResult(userEvent))
        }
        // 3) 請不要關(guān)閉數(shù)據(jù)流,在消費者關(guān)閉或者 API 調(diào)用 onCompleted/onError 函數(shù)之前,請保證數(shù)據(jù)流
       // 一直處于打開狀態(tài)。
        // 當數(shù)據(jù)流關(guān)閉后,請取消第三方庫的訂閱。
        awaitClose { subscription.remove() }
    }
}

2. BroadcastChannel 實現(xiàn)
對于使用 Firestore 跟蹤用戶身份認證的數(shù)據(jù)流,我們使用了 BroadcastChannel API,因為我們希望注冊一個有獨立生命周期的 Authentication 監(jiān)聽者,同時也希望能向所有正在監(jiān)聽的對象廣播當前的結(jié)果。

轉(zhuǎn)化回調(diào) API 為 BroadcastChannel 相比轉(zhuǎn)化為 Flow 要略復(fù)雜一點。您可以創(chuàng)建一個類,并設(shè)置將實例化后的 BroadcastChannel 作為變量保存。在初始化期間,注冊回調(diào),像以前一樣將元素發(fā)送到 BroadcastChannel:


/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
class FirebaseAuthStateUserDataSource(...) : AuthStateUserDataSource {

    private val channel = ConflatedBroadcastChannel<Result<AuthenticatedUserInfo>>()

    private val listener: ((FirebaseAuth) -> Unit) = { auth ->
       // 數(shù)據(jù)處理邏輯
       
       // 將當前的用戶 (數(shù)據(jù)) 發(fā)送給消費者
        if (!channel.isClosedForSend) {
            channel.offer(Success(FirebaseUserInfo(auth.currentUser)))
        } else {
            unregisterListener()
        }
    }

    @Synchronized
    override fun getBasicUserInfo(): Flow<Result<AuthenticatedUserInfo>> {
        if (!isListening) {
            firebase.addAuthStateListener(listener)
            isListening = true
        }
        return channel.asFlow()
    }
}

測試小建議

為了測試 Flow 轉(zhuǎn)換 (就像我們在 UseCase 和 Repository 層中所做的那樣),您可以利用 flow 構(gòu)造器返回一個假數(shù)據(jù),例如:


/* Copyright 2019 Google LLC.
   SPDX-License-Identifier: Apache-2.0 */
object FakeUserEventDataSource : UserEventDataSource {
  override fun getObservableUserEvents(userId: String) = flow {
    emit(UserEventsResult(userEvents))
  }
}
class DefaultSessionAndUserEventRepositoryTest {
  @Test
  fun observableUserEvents_areMappedCorrectly() = runBlockingTest {
   // 準備一個 repo
    val userEvents = repository
          .getObservableUserEvents("user", true).first()
   // 對接收到的用戶事件進行斷言
  }
}

為了成功完成測試,一個比較好的做法是使用 take 操作符來從 Flow 中獲取一些數(shù)據(jù),使用 toList 作為末端操作符來從數(shù)組中獲取結(jié)果。示例如下:


class AnotherStreamDataSourceImplTest {
  @Test
  fun `Test happy path`() = runBlockingTest {
   //準備好 subject
    val result = subject.flow.take(1).toList()
   // 斷言結(jié)果和預(yù)期的一致
  }
}

take 操作符非常適合在獲取到數(shù)據(jù)后關(guān)閉 Flow。在測試完畢后不關(guān)閉 Flow 或 BroadcastChannel 將會導(dǎo)致內(nèi)存泄漏以及測試結(jié)果不一致。

注意: 如果在數(shù)據(jù)源的實現(xiàn)是通過 BroadcastChannel 完成的,那么上面的代碼還不夠。您需要自己管理數(shù)據(jù)源的生命周期,并確保 BroadcastChannel 在測試開始之前已經(jīng)啟動,同時需要在測試結(jié)束后將其關(guān)閉,否則將會導(dǎo)致內(nèi)存泄漏。

協(xié)程測試的最佳實踐在這里依然適用。如果您在測試代碼中創(chuàng)建新的協(xié)程,則可能想要在測試線程中執(zhí)行它來確保測試獲得執(zhí)行。

您也可以通過視頻回顧 2019 Android 開發(fā)者峰會演講 —— 在 Android 上測試協(xié)程:.

騰訊視頻鏈接: https://v.qq.com/x/page/d3037jjhkh3.html

總結(jié)

  • 因為 Flow 所提供的更加明確的約束和各種操作符,我們更建議向消費者暴露 Flow 而不是 Channel;
  • 使用 Flow 時,生產(chǎn)者會在每次有新的監(jiān)聽者時被執(zhí)行,同時數(shù)據(jù)流的生命周期將會被自動處理;
  • 使用 BroadcastChannel 時,您可以共享生產(chǎn)者,但需要自己管理它的生命周期;
  • 請考慮將基于回調(diào)的 API 轉(zhuǎn)化為協(xié)程,以便在您的應(yīng)用中更好、更慣用地集成 API;
  • 使用 taketoList 操作符可以簡化 Flow 的相關(guān)代碼測試。

2019 ADS 應(yīng)用在 GitHub 開源,請訪問下方鏈接在 GitHub 上查看更詳細的代碼實現(xiàn): https://github.com/google/iosched/tree/adssched

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。