歡迎回到 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。
感謝您的閱讀,下一篇文章將是 本系列 的最後一篇,敬請期待。
歡迎您 點選這裡 向我們提交反饋,或分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支援!