深入探索 Paging 3.0: 分頁加載來自網絡和數據庫的數據 | MAD Skills

歡迎回到 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。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章