獲取數據並綁定到 UI | MAD Skills

歡迎回到 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 全部被定義,我們可以將其組合成 UiStateStateFlow,並和 PagingDataFlow 一起暴露出來給 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 切換到 PagingDataAdapterPagingDataAdapter 是爲比較 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 暴露的 loadStateFlowUiState 中的 "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 加載數據時自動對其進行通知,使其可以根據需要在列表頂部或底部插入項目。

而它的精髓是您甚至不需要改變現有的 PagingDataAdapterwithLoadStateHeaderAndFooter 擴展函數可以很方便地使用頭部和尾部包裹您已有的 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

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

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