歡迎回到 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
調用的第一個方法,它的返回值爲 InitializeAction
。InitializeAction
可以是 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
方法在 loadType
與 PagingState
所定義的邊界處調用,加載類型可以是 refresh
、append
或 prepend
。這一方法負責獲取數據,將其持久化在磁盤上並通知處理結果,其結果可以是 Error
或 Success
。如果結果是 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
通常來講,prepend
與 append
加載狀態會用於響應額外的數據獲取,而 refresh 加載狀態則用來響應初始加載、刷新和重試。
由於 Pager
可能會從 PagingSource
或者 RemoteMediator
加載數據,所以 CombinedLoadStates
有兩個 LoadState
字段。其中名爲 source
的字段用於 PagingSource
,而名爲 mediator
的字段用於 RemoteMediator
。
方便起見,CombinedLoadStates
與 LoadStates
相似,同樣含有 refresh
、append
和 prepend
字段,它們會基於 Paging
的配置和其他語義反映 RemoteMediator
或 PagingSource
的 LoadState
。請務必查看相關文檔以確定這些字段在不同場景下的行爲。
使用這些信息更新我們的 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。
感謝您的閱讀,下一篇文章將是 本系列 的最後一篇,敬請期待。