深入探索 Paging 3.0: 分頁載入來自網路和資料庫的資料 | MAD Skills

Android開發者發表於2021-12-23

歡迎回到 MAD Skills 系列之 Paging 3.0!在上一篇文章《獲取資料並繫結到 UI | MAD Skills》中,我們在 ViewModel 中整合了 Pager,並利用配合 PagingDataAdapter 向 UI 填充資料,我們也新增了載入狀態指示器,並在出現錯誤時重新載入。

這次,我們把難度提升一個檔次。目前為止,我們都是直接通過網路載入資料,而這樣的操作只適用於理想環境。我們有時候可能遇到網路連線緩慢,或者完全斷網的情況。同時,即使網路狀況良好,我們也不會希望自己的應用成為資料黑洞——在導航到每個介面時都拉取資料是一種十分浪費的行為。

解決這一問題的方法便是從 本地快取 載入資料,並且只在必要的時候進行重新整理。對快取資料的更新必須先到達本地快取,再傳播至 ViewModel。這樣一來,本地快取便可成為唯一可信的資料來源。對我們來說十分方便的是 Paging 庫在 Room 庫一些小小的幫助下已經可以應對這種場景。下面就讓我們開始吧!點選這裡 檢視 Paging: 顯示資料及其載入狀態視訊,瞭解更多詳情。

使用 Room 建立 PagingSource

由於我們將要分頁的資料來源會來自本地而不是直接依賴 API,那麼我們要做的第一件事便是更新 PagingSource。好訊息是,我們要做的工作很少。是因為我前面提到的 "來自 Room 的小小幫助" 嗎?事實上這裡的幫助遠不止於一點: 只需要在 Room 的 DAO 中為 PagingSource 新增宣告,便可通過 DAO 獲取 PagingSource

@Dao
interface RepoDao {
    @Query(
        "SELECT * FROM repos WHERE " +
            "name LIKE :queryString"
    )
    fun reposByName(queryString: String): PagingSource<Int, Repo>
}

我們現在可以在 GitHubRepository 中更新 Pager 的建構函式來使用新的 PagingSource 了:

fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        
        …
        val pagingSourceFactory = { database.reposDao().reposByName(dbQuery) }

        @OptIn(ExperimentalPagingApi::class)
        return Pager(
           config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
            ),
            pagingSourceFactory = pagingSourceFactory,
            remoteMediator = …,
        ).flow
    }

RemoteMediator

目前為止一切順利……不過我們好像忘記了什麼。本地的資料庫要如何填充資料呢?來看看 RemoteMediator,當資料庫中的資料載入完畢時,它負責從網路載入更多資料。讓我們看看它是如何工作的。

瞭解 RemoteMediator 的關鍵在於認識到它是一個回撥。RemoteMediator 的結果永遠不會展示在 UI 上,因為它只是 Paging 用於通知作為開發者的我們: PagingSource 的資料已經耗盡。更新資料庫並通知 Paging,這是我們自己的工作。與 PagingSource 類似,RemoteMediator 有兩個泛型引數: 查詢引數型別和返回值型別。

@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
    …
) : RemoteMediator<Int, Repo>() {
    …
}

讓我們來仔細觀察下 RemoteMediator 中的抽象方法。第一個方法是 initialize(),它是在所有載入開始前,RemoteMediator 呼叫的第一個方法,它的返回值為 InitializeActionInitializeAction 可以是 LAUNCH_INITIAL_REFRESH,也可以是 SKIP_INITIAL_REFRESH。前者表示在呼叫 load() 方法時攜帶的載入型別為 refresh,後者意味著只有在 UI 明確發起請求時才會使用 RemoteMediator 執行重新整理操作。在我們的用例中,由於倉庫狀態可能更新得頗為頻繁,所以我們返回 LAUNCH_INITIAL_REFRESH

  override suspend fun initialize(): InitializeAction {
        return InitializeAction.LAUNCH_INITIAL_REFRESH
    }

接下來我們來看 load 方法。load 方法在 loadTypePagingState 所定義的邊界處呼叫,載入型別可以是 refreshappendprepend。這一方法負責獲取資料,將其持久化在磁碟上並通知處理結果,其結果可以是 ErrorSuccess。如果結果是 Error,載入狀態將會反映這一結果,並可能重試載入。如果載入成功,需要通知 Pager 是否可以載入更多資料。

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {

        val page = when (loadType) {
            LoadType.REFRESH -> …
            LoadType.PREPEND -> …
            LoadType.APPEND -> …
        }

        val apiQuery = query + IN_QUALIFIER

        try {
            val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)

            val repos = apiResponse.items
            val endOfPaginationReached = repos.isEmpty()
            repoDatabase.withTransaction {
                …
                repoDatabase.reposDao().insertAll(repos)
            }
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (exception: IOException) {
            return MediatorResult.Error(exception)
        } catch (exception: HttpException) {
            return MediatorResult.Error(exception)
        }
    }

由於 load 方法是一個有返回值的掛起函式,所以 UI 可以精確地反映載入完成的狀態。在上一篇文章中,我們簡要介紹了 withLoadStateHeaderAndFooter 擴充套件函式,並瞭解瞭如何使用它來載入頭部和底部。我們可以觀察到,該擴充套件函式的名字中包含了一個型別: LoadState。讓我們進一步瞭解這一型別。

LoadState、LoadStates 以及 CombinedLoadStates

由於分頁是一系列非同步事件,所以通過 UI 反映載入資料的當前狀態十分重要。在分頁操作中,Pager 的載入狀態是通過 CombinedLoadStates 型別表示的。

顧名思義,這個型別是其他表示載入資訊的型別的組合。這些型別包括:

LoadState 是一個完整描述下列載入狀態的密封類:

  • Loading
  • NotLoading
  • Error

LoadStates 是包含以下三種 LoadState 值的資料類:

  • append
  • prepend
  • refresh

通常來講,prependappend 載入狀態會用於響應額外的資料獲取,而 refresh 載入狀態則用來響應初始載入、重新整理和重試。

由於 Pager 可能會從 PagingSource 或者 RemoteMediator 載入資料,所以 CombinedLoadStates 有兩個 LoadState 欄位。其中名為 source 的欄位用於 PagingSource,而名為 mediator 的欄位用於 RemoteMediator

方便起見,CombinedLoadStatesLoadStates 相似,同樣含有 refreshappendprepend 欄位,它們會基於 Paging 的配置和其他語義反映 RemoteMediatorPagingSourceLoadState。請務必檢視相關文件以確定這些欄位在不同場景下的行為。

使用這些資訊更新我們的 UI 就像從 PagingAdapter 暴露的 loadStateFlow 中獲取資料一樣簡單。在我們的應用中,我們可以在第一次載入時使用這些資訊顯示一個載入指示器:

lifecycleScope.launch {
    repoAdapter.loadStateFlow.collect { loadState ->
        // 在重新整理出錯時顯示重試頭部,並且展示之前快取的狀態或者展示預設的 prepend 狀態
        header.loadState = loadState.mediator
            ?.refresh
            ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
            ?: loadState.prepend

        val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
        // 顯示空列表
        emptyList.isVisible = isListEmpty
        // 無論資料來自本地資料庫還是遠端資料,僅在重新整理成功時顯示列表。
        list.isVisible =  loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
        // 在初始載入或重新整理時顯示載入指示器
        progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
        // 如果初始載入或重新整理失敗,顯示重試狀態
        retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
    }
}

我們開始從 Flow 收集資料,並在 Pager 尚未載入且現存列表為空時,使用 CombinedLoadStates.refresh 欄位展示進度條。我們之所以使用 refresh 欄位,是因為我們只希望在第一次啟動應用、或者明確觸發了重新整理時才展示大進度條。我們還可以檢查是否有載入狀態出錯並通知使用者。

回顧

在本文中,我們實現了以下功能:

  • 使用資料庫作為唯一可信資料來源,並對資料進行分頁;
  • 使用 RemoteMediator 填充基於 Room 的 PagingSource;
  • 使用來自 PagingAdapter 的 LoadStateFlow 更新帶有進度條的 UI。

感謝您的閱讀,下一篇文章將是 本系列 的最後一篇,敬請期待。

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

相關文章