获取数据并绑定到 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

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!