歡迎回到 MAD Skills 系列 課程之 Paging 3.0!在上一篇 Paging 3.0 簡介 的文章中,我們討論了 Paging 庫,瞭解瞭如何將它融入到應用架構中,並將其整合進了應用的資料層。我們使用了 PagingSource
來為我們的應用獲取並使用資料,以及用 PagingConfig
來建立能夠提供 Flow<PagingData>
給 UI 消費的 Pager
物件。在本文中我將介紹如何在您的 UI 中實際使用 Flow<PagingData>
。
為 UI 準備 PagingData
應用現有的 ViewModel
暴露了能夠提供渲染 UI 所需資訊的 UiState
資料類,它包含一個 searchResult
欄位,用於將搜尋結果快取在記憶體中,可在配置變更後提供資料。
data class UiState(
val query: String,
val searchResult: RepoSearchResult
)
sealed class RepoSearchResult {
data class Success(val data: List<Repo>) : RepoSearchResult()
data class Error(val error: Exception) : RepoSearchResult()
}
△ 初始 UiState 定義
現在接入 Paging 3.0,我們移除了 UiState
中的 searchResult
,並選擇在 UiState
之外單獨暴露出一個 PagingData<Repo>
的 Flow
來代替它。這個新的 Flow
功能與 searchResult
相同: 提供一個讓 UI 渲染的專案列表。
ViewModel
中新增了一個私有的 "searchRepo()"
方法,它呼叫 Repository
來提供 Pager
中的 PagingData Flow
。我們可以呼叫該方法來建立基於使用者輸入搜尋詞的 Flow<PagingData<Repo>>
。我們還在生成的 PagingData Flow
上使用了 cachedIn
操作符,使其能夠通過 ViewModelScope
快速複用。
class SearchRepositoriesViewModel(
private val repository: GithubRepository,
…
) : ViewModel() {
…
private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
repository.getSearchResultStream(queryString)
}
△ 為倉庫整合 PagingData Flow
暴露一個獨立於其它 Flow 的 PagingData Flow 這一點非常重要 。因為 PagingData
自身是一個可變型別,它內部維護了自己的資料流並且會隨著時間的變化而更新。
隨著組成 UiState
欄位的 Flow
全部被定義,我們可以將其組合成 UiState
的 StateFlow
,並和 PagingData
的 Flow
一起暴露出來給 UI 消費。完成這些之後,現在我們可以開始在 UI 中消費我們的 Flow
了。
class SearchRepositoriesViewModel(
…
) : ViewModel() {
val state: StateFlow<UiState>
val pagingDataFlow: Flow<PagingData<Repo>>
init {
…
pagingDataFlow = searches
.flatMapLatest { searchRepo(queryString = it.query) }
.cachedIn(viewModelScope)
state = combine(...)
}
}
△ 暴露 PagingData Flow 給 UI 注意 cachedIn 運算子的使用
在 UI 中消費 PagingData
首先我們要做的就是將 RecyclerView Adapter
從 ListAdapter 切換到 PagingDataAdapter
。PagingDataAdapter
是為比較 PagingData
的差異並聚合更新而優化的 RecyclerView Adapter
,用以確保後臺資料集的變化能夠儘可能高效地傳遞。
// 之前
// class ReposAdapter : ListAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// …
// }
// 之後
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
…
}
view raw
△ 從 ListAdapter 切換到 PagingDataAdapter
接下來,我們開始從 PagingData Flow
中收集資料,我們可以這樣使用 submitData
掛起函式將它的發射繫結到 PagingDataAdapter
。
private fun ActivitySearchRepositoriesBinding.bindList(
…
pagingData: Flow<PagingData<Repo>>,
) {
…
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
}
△ 使用 PagingDataAdapter 消費 PagingData 注意 colletLatest 的使用
此外,為了使用者體驗著想,我們希望確保當使用者搜尋新內容時,將回到 列表的頂部 以展示第一條搜尋結果。我們期望在 我們載入完成並已將資料展示到 UI 時做到這一點。我們通過利用 PagingDataAdapter
暴露的 loadStateFlow
和 UiState
中的 "hasNotScrolledForCurrentSearch"
欄位來跟蹤使用者是否手動滾動列表。結合這兩者可以建立一個標記讓我們知道是否應該觸發自動滾動。
由於 loadStateFlow
提供的載入狀態與 UI 顯示的內容同步,我們可以有把握地在每次 loadStateFlow
通知我們新的查詢處於 NotLoading
狀態時滾動到列表頂部。
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
…
) {
…
val notLoading = repoAdapter.loadStateFlow
// 僅當 PagingSource 的 refresh (LoadState 型別) 發生改變時發射
.distinctUntilChangedBy { it.source.refresh }
// 僅響應 refresh 完成,也就是 NotLoading。
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
△ 實現有新查詢時自動滾動到頂部
新增頭部和尾部
Paging 庫的另一個優點是在 LoadStateAdapter
的幫助下,能夠在頁面的頂部或底部顯示進度指示器。RecyclerView.Adapter
的這一實現能夠在 Pager
載入資料時自動對其進行通知,使其可以根據需要在列表頂部或底部插入專案。
而它的精髓是您甚至不需要改變現有的 PagingDataAdapter
。withLoadStateHeaderAndFooter
擴充套件函式可以很方便地使用頭部和尾部包裹您已有的 PagingDataAdapter
。
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { repoAdapter.retry() },
footer = ReposLoadStateAdapter { repoAdapter.retry() }
)
}
△ 頭部和尾部
withLoadStateHeaderAndFooter
函式的引數中為頭部和尾部都定義了 LoadStateAdapter
。這些 LoadStateAdapter
相應地託管了自身的 ViewHolder
,這些 ViewHolder
與最新的載入狀態繫結,因此很容易定義檢視行為。我們還可以傳入引數實現當出現錯誤時重試載入,我將會在下一篇文章中詳細介紹。
後續
我們已經將 PagingData 繫結到了 UI 上!來快速回顧一下:
- 使用 PagingDataAdapter 將我們的 Paging 整合到 UI 上
- 使用 PagingDataAdapter 暴露的 LoadStateFlow 來保證僅當 Pager 結束載入時滾動到列表的頂部
- 使用 withLoadStateHeaderAndFooter() 實現當獲取資料時將載入欄新增到 UI 上
感謝您的閱讀!敬請關注下一篇文章,我們將探討用 Paging 實現以資料庫作為單一來源,並詳細討論 LoadStateFlow
!
歡迎您 點選這裡 向我們提交反饋,或分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支援!