獲取資料並繫結到 UI | MAD Skills

Android開發者發表於2021-11-06

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

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

相關文章