Android 分页高级 Codelab

1. 简介

这是一个演示分页库高级用法的 Codelab。如果您不了解分页的概念,或不了解分页库,请查看分页基础 Codelab

您将学到什么

  • Paging 3 的主要组件是什么。
  • 如何将 Paging 3 添加到您的项目中。
  • 如何使用 Paging 3 API 向列表添加标题或页脚。
  • 如何使用 Paging 3 API 添加列表分隔符。
  • 如何从网络和数据库分页。

您将构建什么

在这个 Codelab 中,您将从一个示例应用程序开始,该应用程序已经显示了 GitHub 存储库的列表。每当用户滚动到显示列表的末尾时,就会触发一个新的网络请求,其结果会显示在屏幕上。

您将通过一系列步骤添加代码,以实现以下目标

  • 迁移到分页库组件。
  • 向您的列表添加加载状态标题和页脚。
  • 在每次新的存储库搜索之间显示加载进度。
  • 在您的列表中添加分隔符。
  • 添加数据库支持,以便从网络和数据库分页。

以下是您的应用程序最终的样子

23643514cb9cf43e.png

您需要什么

有关架构组件的介绍,请查看带视图的 Room Codelab。有关 Flow 的介绍,请查看使用 Kotlin Flow 和 LiveData 的高级协程 Codelab

2. 设置您的环境

在此步骤中,您将下载整个 Codelab 的代码,然后运行一个简单的示例应用程序。

为了让您尽快开始,我们为您准备了一个入门项目,供您构建。

如果您安装了 Git,您可以简单地运行以下命令。(您可以通过在终端/命令行中键入git --version并验证其是否正确执行来进行检查。)

 git clone https://github.com/android/codelab-android-paging

代码位于/advanced文件夹内。打开start项目。

end项目包含您应该结束的代码,因此如果您遇到困难,可以随时查看。

如果您没有 Git,您可以点击以下按钮下载此 Codelab 的所有代码

  1. 解压缩代码,然后在 Android Studio 中打开项目。
  2. 在设备或模拟器上运行app运行配置。

89af884fa2d4e709.png

应用程序运行并显示类似于以下内容的 GitHub 存储库列表

50d1d2aa6e79e473.png

3. 项目概述

该应用程序允许您在 GitHub 上搜索名称或描述包含特定单词的存储库。存储库列表按星数降序排列,然后按名称字母顺序排列。

该应用程序遵循“应用架构指南”中推荐的架构。以下是您将在每个包中找到的内容

  • api - 使用 Retrofit 进行 Github API 调用。
  • data - 存储库类,负责触发 API 请求并将响应缓存在内存中。
  • model - Repo数据模型,它也是 Room 数据库中的一个表;以及RepoSearchResult,UI 用于观察搜索结果数据和网络错误的类。
  • ui - 与显示带有RecyclerViewActivity相关的类。

每当用户滚动到列表末尾或搜索新存储库时,GithubRepository类都会从网络检索存储库名称列表。查询的结果列表保存在GithubRepository中的ConflatedBroadcastChannel中,并作为Flow公开。

SearchRepositoriesViewModelGithubRepository请求数据并将其公开给SearchRepositoriesActivity。因为我们希望确保在配置更改(例如旋转)时不会多次请求数据,所以我们使用liveData()构建器方法在ViewModel中将Flow转换为LiveData。这样,LiveData就会在内存中缓存最新的结果列表,并且当SearchRepositoriesActivity重新创建时,LiveData的内容将显示在屏幕上。ViewModel公开

  1. 一个LiveData<UiState>
  2. 一个函数(UiAction) -> Unit

UiState是呈现应用程序 UI 所需的所有内容的表示,不同的字段对应于不同的 UI 组件。它是一个不可变对象,这意味着它不能更改;但是,可以生成它的新版本并由 UI 观察。在我们的例子中,它的新版本是作为用户操作的结果生成的:要么搜索新的查询,要么滚动列表以获取更多内容。

用户操作由UiAction类型恰当地表示。将与ViewModel交互的 API 封装在单个类型中具有以下好处

  • 较小的 API 表面:可以添加、删除或更改操作,但ViewModel的方法签名永远不会改变。这使得重构本地化,并且不太可能泄漏抽象或接口实现。
  • 更轻松的并发管理:正如您将在后面的 Codelab 中看到的那样,能够保证某些请求的执行顺序非常重要。通过使用UiAction对 API 进行强类型化,我们可以编写对可以发生什么以及何时可以发生有严格要求的代码。

从可用性的角度来看,我们存在以下问题

  • 用户没有关于列表加载状态的信息:当他们搜索新的存储库时,他们会看到一个空屏幕,或者在加载同一查询的更多结果时,列表会突然结束。
  • 用户无法重试失败的查询。
  • 列表在方向更改或进程死亡后始终滚动到顶部。

从实现的角度来看,我们存在以下问题

  • 列表在内存中无限增长,浪费内存,因为用户会滚动。
  • 我们必须将结果从Flow转换为LiveData才能缓存它们,这增加了代码的复杂性。
  • 如果我们的应用程序需要显示多个列表,我们会发现每个列表都需要编写大量样板代码。

让我们了解分页库如何帮助我们解决这些问题以及它包含哪些组件。

4. 分页库组件

分页库使您能够更轻松地在应用程序的 UI 中增量且优雅地加载数据。分页 API 为许多功能提供了支持,否则在需要按页加载数据时,您需要手动实现这些功能

  • 跟踪用于检索下一页和上一页的键。
  • 当用户滚动到列表末尾时,会自动请求正确的页面。
  • 确保不会同时触发多个请求。
  • 允许您缓存数据:如果您使用 Kotlin,则在 CoroutineScope 中完成;如果您使用 Java,则可以使用 LiveData 完成。
  • 跟踪加载状态,并允许您在 RecyclerView 列表项或 UI 的其他位置显示它,并轻松重试加载失败。
  • 允许您对将要显示的列表执行常见的操作,例如 mapfilter,而无论您是否使用 FlowLiveData 或 RxJava FlowableObservable
  • 提供了一种轻松实现列表分隔符的方法。

应用架构指南》建议使用以下主要组件的架构

  • 一个本地数据库,作为向用户呈现和用户操作的数据的唯一数据源。
  • 一个 Web API 服务。
  • 一个与数据库和 Web API 服务协作的存储库,提供统一的数据接口。
  • 一个 ViewModel,提供特定于 UI 的数据。
  • UI,它显示 ViewModel 中数据的视觉表示。

Paging 库与所有这些组件协作,并协调它们之间的交互,以便它可以从数据源加载内容的“页面”并在 UI 中显示这些内容。

本 Codelab 将向您介绍 Paging 库及其主要组件

  • PagingData - 分页数据的容器。每次数据刷新都将有一个单独的对应 PagingData
  • PagingSource - PagingSource 是将数据快照加载到 PagingData 流中的基类。
  • Pager.flow - 基于 PagingConfig 和一个定义如何构建已实现的 PagingSource 的函数,构建一个 Flow<PagingData>
  • PagingDataAdapter - 一个 RecyclerView.Adapter,用于在 RecyclerView 中呈现 PagingDataPagingDataAdapter 可以连接到 Kotlin FlowLiveData、RxJava Flowable 或 RxJava ObservablePagingDataAdapter 侦听页面加载时的内部 PagingData 加载事件,并在后台线程上使用 DiffUtil 计算细粒度的更新,因为更新的内容以新 PagingData 对象的形式接收。
  • RemoteMediator - 帮助实现来自网络和数据库的分页。

在本 Codelab 中,您将实现上面描述的每个组件的示例。

5. 定义数据源

PagingSource 实现定义了数据源以及如何从该源检索数据。 PagingData 对象根据用户在 RecyclerView 中滚动时生成的加载提示从 PagingSource 查询数据。

目前,GithubRepository 承担了许多数据源的职责,一旦我们完成添加 Paging 库,这些职责将由 Paging 库处理。

  • GithubService 加载数据,确保不会同时触发多个请求。
  • 保留检索到的数据的内存缓存。
  • 跟踪要请求的页面。

要构建 PagingSource,您需要定义以下内容

  • 分页键的类型 - 在我们的例子中,Github API 使用基于 1 的索引号表示页面,因此类型为 Int
  • 加载的数据类型 - 在我们的例子中,我们正在加载 Repo 项。
  • 从哪里检索数据 - 我们从 GithubService 获取数据。我们的数据源将特定于某个查询,因此我们需要确保我们也向 GithubService 传递查询信息。

因此,在 data 包中,让我们创建一个名为 GithubPagingSourcePagingSource 实现。

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() { 
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Repo>): Int? { 
        TODO("Not yet implemented")
    }

}

我们将看到 PagingSource 要求我们实现两个函数:load()getRefreshKey()

load() 函数将由 Paging 库调用,以异步获取更多数据,以便在用户滚动时显示。 LoadParams 对象保留与加载操作相关的信息,包括以下内容

  • 要加载的页面的键。如果这是第一次调用 load,则 LoadParams.key 将为 null。在这种情况下,您将必须定义初始页面键。对于我们的项目,您必须将 GITHUB_STARTING_PAGE_INDEX 常量从 GithubRepository 移动到您的 PagingSource 实现中,因为这是初始页面键。
  • 加载大小 - 要加载的项目数。

load 函数返回 LoadResult。这将替换我们应用中 RepoSearchResult 的用法,因为 LoadResult 可以采用以下类型之一

  • LoadResult.Page,如果结果成功。
  • LoadResult.Error,如果发生错误。

在构造 LoadResult.Page 时,如果列表无法在相应方向加载,则为 nextKeyprevKey 传递 null。例如,在我们的例子中,我们可以认为如果网络响应成功但列表为空,则我们没有其他数据可以加载;因此,nextKey 可以为 null

基于所有这些信息,我们应该能够实现 load() 函数!

接下来我们需要实现 getRefreshKey()。刷新键用于后续对 PagingSource.load() 的刷新调用(第一次调用是初始加载,它使用 Pager 提供的 initialKey)。每当 Paging 库想要加载新数据以替换当前列表时,就会发生刷新,例如,在向上滑动刷新或由于数据库更新、配置更改、进程死亡等导致失效时。通常,后续的刷新调用将希望重新开始加载以 PagingState.anchorPosition 为中心的数据,它表示最近访问的索引。

GithubPagingSource 实现如下所示

// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
        val apiQuery = query + IN_QUALIFIER
        return try {
            val response = service.searchRepos(apiQuery, position, params.loadSize)
            val repos = response.items
            val nextKey = if (repos.isEmpty()) {
                null
            } else {
                // initial load size = 3 * NETWORK_PAGE_SIZE
                // ensure we're not requesting duplicating items, at the 2nd request
                position + (params.loadSize / NETWORK_PAGE_SIZE)
            }
            LoadResult.Page(
                    data = repos,
                    prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                    nextKey = nextKey
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
    // The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        // We need to get the previous key (or next key if previous is null) of the page
        // that was closest to the most recently accessed index.
        // Anchor position is the most recently accessed index
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

}

6. 构建和配置 PagingData

在我们当前的实现中,我们在 GitHubRepository 中使用 Flow<RepoSearchResult> 从网络获取数据并将其传递给 ViewModel。然后,ViewModel 将其转换为 LiveData 并将其公开给 UI。每当我们到达显示列表的末尾并从网络加载更多数据时,Flow<RepoSearchResult> 将包含该查询之前检索到的所有数据的完整列表以及最新数据。

RepoSearchResult 封装了成功和错误两种情况。成功情况包含存储库数据。错误情况包含 Exception 原因。使用 Paging 3,我们不再需要 RepoSearchResult,因为库使用 LoadResult 对成功和错误情况进行建模。您可以随意删除 RepoSearchResult,因为在接下来的几个步骤中,我们将替换它。

要构造 PagingData,我们首先需要确定要使用哪个 API 将 PagingData 传递到应用程序的其他层

  • Kotlin Flow - 使用 Pager.flow
  • LiveData - 使用 Pager.liveData
  • RxJava Flowable - 使用 Pager.flowable
  • RxJava Observable - 使用 Pager.observable

由于我们已经在应用程序中使用了 Flow,因此我们将继续使用这种方法;但我们将使用 Flow<PagingData<Repo>> 而不是 Flow<RepoSearchResult>

无论您使用哪种 PagingData 构建器,您都必须传递以下参数

  • PagingConfig。此类设置有关如何从 PagingSource 加载内容的选项,例如提前加载多远、初始加载的大小请求等。您必须定义的唯一必填参数是页面大小——每页应加载多少项。默认情况下,Paging 将保留您加载的所有页面。为了确保在用户滚动时不会浪费内存,请在 PagingConfig 中设置 maxSize 参数。如果 Paging 可以计算未加载的项目并且 enablePlaceholders 配置标志为 true,则默认情况下 Paging 将返回空项目作为尚未加载的内容的占位符。这样,您就可以在适配器中显示占位符视图。为了简化本 Codelab 中的工作,让我们通过传递 enablePlaceholders = false 来禁用占位符。
  • 一个定义如何创建PagingSource的函数。在我们的例子中,我们将为每个新查询创建一个新的 GithubPagingSource

让我们修改我们的 GithubRepository

更新 GithubRepository.getSearchResultStream

  • 移除 suspend 修饰符。
  • 返回 Flow<PagingData<Repo>>
  • 构建 Pager
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
    return Pager(
          config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
          pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

清理 GithubRepository

Paging 3 为我们做了很多事情

  • 处理内存缓存。
  • 当用户接近列表末尾时请求数据。

这意味着我们可以移除 GithubRepository 中的所有其他内容,除了 getSearchResultStream 和我们在其中定义了 NETWORK_PAGE_SIZE 的伴生对象。您的 GithubRepository 现在应该如下所示

class GithubRepository(private val service: GithubService) {

    fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        return Pager(
                config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
             ),
                pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow
    }

    companion object {
        const val NETWORK_PAGE_SIZE = 50
    }
}

您现在应该在 SearchRepositoriesViewModel 中遇到编译错误。让我们看看在那里需要进行哪些更改!

7. 在 ViewModel 中请求和缓存 PagingData

在解决编译错误之前,让我们回顾一下 ViewModel 中的类型

sealed class UiAction {
    data class Search(val query: String) : UiAction()
    data class Scroll(
        val visibleItemCount: Int,
        val lastVisibleItemPosition: Int,
        val totalItemCount: Int
    ) : UiAction()
}

data class UiState(
    val query: String,
    val searchResult: RepoSearchResult
)

在我们的 UiState 中,我们公开了一个 searchResultsearchResult 的作用是作为结果搜索的内存缓存,它可以在配置更改后继续存在。使用 Paging 3,我们不再需要将我们的 Flow 转换为 LiveData。相反,SearchRepositoriesViewModel 现在将公开一个 StateFlow<UiState>。此外,我们完全删除了 searchResult val,而是选择公开一个单独的 Flow<PagingData<Repo>>,它具有与 searchResult 相同的目的。

PagingData 是一种自包含的类型,其中包含要显示在 RecyclerView 中的数据的更新的可变流。每次发射 PagingData 都是完全独立的,并且对于单个查询可能会发射多个 PagingData。因此,应独立于其他 Flow 公开 PagingDataFlow

此外,作为用户体验的福利,对于用户输入的每个新查询,我们都希望滚动到列表顶部以显示第一个搜索结果。但是,由于分页数据可能会多次发出,因此我们仅希望在用户**尚未**开始滚动时滚动到列表顶部。

为此,让我们更新 UiState 并为 lastQueryScrolledhasNotScrolledForCurrentSearch 添加字段。这些标志将阻止我们在不应该时滚动到列表顶部

data class UiState(
    val query: String = DEFAULT_QUERY,
    val lastQueryScrolled: String = DEFAULT_QUERY,
    val hasNotScrolledForCurrentSearch: Boolean = false
)

让我们重新审视一下我们的架构。因为所有对 ViewModel 的请求都通过一个单一的入口点 - 定义为 (UiAction) -> Unitaccept 字段 - 我们需要执行以下操作

  • 将该入口点转换为包含我们感兴趣的类型的流。
  • 转换这些流。
  • 将流组合回 StateFlow<UiState>

用更函数化的术语来说,我们将 reduce UiAction 的发射到 UiState。这有点像一个装配线:UiAction 类型是输入的原材料,它们会导致影响(有时称为突变),而 UiState 是准备绑定到 UI 的成品输出。这有时被称为使 UI 成为 UiState 的函数。

让我们重写 ViewModel 以在两个不同的流中处理每个 UiAction 类型,然后使用一些 Kotlin Flow 运算符将它们转换为 StateFlow<UiState>

首先,我们更新 ViewModelstate 的定义以使用 StateFlow 而不是 LiveData,同时还添加了一个用于公开 PagingData 流的字段

   /**
     * Stream of immutable states representative of the UI.
     */
    val state: StateFlow<UiState>

    val pagingDataFlow: Flow<PagingData<Repo>>

接下来,我们更新 UiAction.Scroll 子类的定义

sealed class UiAction {
    ...
    data class Scroll(val currentQuery: String) : UiAction()
}

请注意,我们删除了 UiAction.Scroll 数据类中的所有字段,并用单个 currentQuery 字符串替换了它们。这使我们可以将滚动操作与特定查询关联起来。我们还删除了 shouldFetchMore 扩展,因为它不再使用。这也是在进程死亡后需要恢复的内容,因此我们确保更新 SearchRepositoriesViewModel 中的 onCleared() 方法

class SearchRepositoriesViewModel{
  ...
   override fun onCleared() {
        savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
        savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
        super.onCleared()
    }
}

// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"

这也是介绍实际从 GithubRepository 创建 pagingData Flow 的方法的好时机

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {

    override fun onCleared() {
        ...
    }

    private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
        repository.getSearchResultStream(queryString)
}

Flow<PagingData> 有一个方便的 cachedIn() 方法,它允许我们在 CoroutineScope 中缓存 Flow<PagingData> 的内容。由于我们位于 ViewModel 中,因此我们将使用 androidx.lifecycle.viewModelScope

现在,我们可以开始将 ViewModel 中的 accept 字段转换为 UiAction 流。将 SearchRepositoriesViewModelinit 块替换为以下内容

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {
    ...
    init {
        val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
        val actionStateFlow = MutableSharedFlow<UiAction>()
        val searches = actionStateFlow
            .filterIsInstance<UiAction.Search>()
            .distinctUntilChanged()
            .onStart { emit(UiAction.Search(query = initialQuery)) }
        val queriesScrolled = actionStateFlow
            .filterIsInstance<UiAction.Scroll>()
            .distinctUntilChanged()
            // This is shared to keep the flow "hot" while caching the last query scrolled,
            // otherwise each flatMapLatest invocation would lose the last query scrolled,
            .shareIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                replay = 1
            )
            .onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
   }
}

让我们来看一下上面的代码片段。我们从两项开始,从保存的状态或默认值中提取的 initialQuery String,以及 lastQueryScrolled,一个表示用户与列表交互的最后搜索词的 String。接下来,我们开始将 Flow 分割成特定的 UiAction 类型

  1. 每次用户输入特定查询时,都会使用 UiAction.Search
  2. 每次用户在关注特定查询时滚动列表时,都会使用 UiAction.Scroll

UiAction.Scroll Flow 应用了一些额外的转换。让我们来看一下它们

  1. shareIn:这是必需的,因为当此 Flow 最终被使用时,它是使用 flatmapLatest 运算符使用的。每次上游发出时,flatmapLatest 将取消它正在操作的最后一个 Flow,并开始根据它收到的新流进行工作。在我们的例子中,这将导致我们丢失用户最后滚动浏览的查询的值。因此,我们使用带有 replay 值为 1 的 Flow 运算符来缓存最后一个值,以便在新的查询到来时不会丢失它。
  2. onStart:也用于缓存。如果应用程序被杀死,但用户已经滚动浏览了一个查询,我们不希望将列表滚动到顶部,从而导致他们再次失去位置。

仍然应该存在编译错误,因为我们尚未定义 statepagingDataFlowaccept 字段。让我们修复它。使用应用于每个 UiAction 的转换,我们现在可以使用它们为我们的 PagingDataUiState 创建流。

init {
        ...
        pagingDataFlow = searches
            .flatMapLatest { searchRepo(queryString = it.query) }
            .cachedIn(viewModelScope)

        state = combine(
            searches,
            queriesScrolled,
            ::Pair
        ).map { (search, scroll) ->
            UiState(
                query = search.query,
                lastQueryScrolled = scroll.currentQuery,
                // If the search query matches the scroll query, the user has scrolled
                hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
            )
        }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                initialValue = UiState()
            )

        accept = { action ->
            viewModelScope.launch { actionStateFlow.emit(action) }
        }
    }
}

我们在 searches 流上使用 flatmapLatest 运算符,因为每个新的搜索查询都需要创建一个新的 Pager。接下来,我们将 cachedIn 运算符应用于 PagingData 流以使其在 viewModelScope 内保持活动状态,并将结果分配给 pagingDataFlow 字段。在 UiState 方面,我们使用 combine 运算符填充所需的 UiState 字段,并将生成的 Flow 分配给公开的 state 字段。我们还将 accept 定义为一个 lambda,它启动一个挂起函数,该函数为我们的状态机提供数据。

就是这样!我们现在从字面意义和反应式编程的角度来看,都有了一个功能完善的 ViewModel

8. 使 Adapter 能够使用 PagingData

要将 PagingData 绑定到 RecyclerView,请使用 PagingDataAdapter。每当加载 PagingData 内容时,PagingDataAdapter 都会收到通知,然后它会向 RecyclerView 发出更新信号。

更新 ui.ReposAdapter 以使用 PagingData

  • 目前,ReposAdapter 实现了 ListAdapter。将其改为实现 PagingDataAdapter。类的其余主体保持不变。
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}

到目前为止,我们已经做了很多更改,但现在距离能够运行应用程序只有一步之遥——我们只需要连接 UI!

9. 触发网络更新

将 LiveData 替换为 Flow

让我们更新 SearchRepositoriesActivity 以与 Paging 3 配合使用。为了能够使用 Flow<PagingData>,我们需要启动一个新的协程。我们将在 lifecycleScope 中执行此操作,它负责在活动重新创建时取消请求。

幸运的是,我们不需要做太多更改。我们不再 observe() 一个 LiveData,而是 launch() 一个 coroutinecollect() 一个 FlowUiState 将与 PagingAdapterLoadState Flow 结合使用,以确保如果用户已经滚动过,我们不会在新发出的 PagingData 中将列表向上滚动到顶部。

首先,由于我们现在将状态作为 StateFlow 而不是 LiveData 返回,因此 Activity 中所有对 LiveData 的引用都应替换为 StateFlow,并确保为 pagingData Flow 添加一个参数。第一个位置是在 bindState 方法中。

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        ...
    }

此更改具有级联效应,因为我们现在必须更新 bindSearch()bindList()bindSearch() 的更改最小,所以让我们从这里开始。

   private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener {...}
        searchRepo.setOnKeyListener {...}

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

这里的主要更改是需要启动一个协程,并从 UiState Flow 收集查询更改。

解决滚动问题并绑定数据

现在是滚动部分。首先,与前两次更改一样,我们将 LiveData 替换为 StateFlow,并为 pagingData Flow 添加一个参数。完成此操作后,我们可以继续进行滚动监听器。请注意,以前,我们使用附加到 RecyclerViewOnScrollListener 来了解何时触发更多数据。Paging 库为我们处理列表滚动,但我们仍然需要 OnScrollListener 作为用户是否已为当前查询滚动列表的信号。在 bindList() 方法中,让我们用内联 RecyclerView.OnScrollListener 替换 setupScrollListener()。我们还完全删除了 setupScrollListener() 方法。

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        // the rest of the code is unchanged
    }

接下来,我们设置管道以创建 shouldScrollToTop 布尔标志。完成此操作后,我们可以从两个流中 collectPagingData FlowshouldScrollToTop Flow

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(...)
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., 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 {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }

在上面,我们在 pagingData Flow 上使用 collectLatest,以便我们可以在 pagingData 的新发射上取消对先前 pagingData 发射的收集。对于 shouldScrollToTop 标志,PagingDataAdapter.loadStateFlow 的发射与 UI 中显示的内容同步,因此在布尔标志发出的值为 true 时立即调用 list.scrollToPosition(0) 是安全的。

LoadStateFlow 中的类型是 CombinedLoadStates 对象。

CombinedLoadStates 允许我们获取三种不同类型的加载操作的加载状态。

  • CombinedLoadStates.refresh - 表示首次加载 PagingData 的加载状态。
  • CombinedLoadStates.prepend - 表示在列表开头加载数据的加载状态。
  • CombinedLoadStates.append - 表示在列表末尾加载数据的加载状态。

在我们的例子中,我们只想在刷新完成后重置滚动位置,即 LoadStaterefreshNotLoading

我们现在可以从 updateRepoListFromInput() 中删除 binding.list.scrollToPosition(0)

完成所有这些操作后,您的活动应如下所示

class SearchRepositoriesActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // get the view model
        val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this))
            .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        // bind the state
        binding.bindState(
            uiState = viewModel.state,
            pagingData = viewModel.pagingDataFlow,
            uiActions = viewModel.accept
        )
    }

    /**
     * Binds the [UiState] provided  by the [SearchRepositoriesViewModel] to the UI,
     * and allows the UI to feed back user actions to it.
     */
    private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter

        bindSearch(
            uiState = uiState,
            onQueryChanged = uiActions
        )
        bindList(
            repoAdapter = repoAdapter,
            uiState = uiState,
            pagingData = pagingData,
            onScrollChanged = uiActions
        )
    }

    private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }
        searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

    private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
        searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                list.scrollToPosition(0)
                onQueryChanged(UiAction.Search(query = it.toString()))
            }
        }
    }

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., 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 {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }
}

我们的应用程序应该可以编译和运行,但没有加载状态页脚和显示错误的 Toast。在下一步中,我们将了解如何显示加载状态页脚。

10. 在页脚中显示加载状态

在我们的应用程序中,我们希望能够根据加载状态显示页脚:当列表正在加载时,我们希望显示一个进度旋转器。如果发生错误,我们希望显示错误和重试按钮。

3f6f2cd47b55de92.png 661da51b58c32b8c.png

我们需要构建的头部/页脚遵循需要在显示的实际项目列表的开头(作为头部)或结尾(作为页脚)附加的列表的概念。头部/页脚是一个只有一个元素的列表:一个视图,根据 Paging LoadState 显示进度条或带有重试按钮的错误。

由于根据加载状态显示头部/页脚和实现重试机制是常见的任务,因此 Paging 3 API 在这两方面都为我们提供了帮助。

对于**头部/页脚实现**,我们将使用 LoadStateAdapter。此 RecyclerView.Adapter 的实现会自动收到加载状态更改的通知。它确保只有 LoadingError 状态会导致显示项目,并在项目被移除、插入或更改时通知 RecyclerView,具体取决于 LoadState

对于**重试机制**,我们使用 adapter.retry()。在幕后,此方法最终会调用您为正确页面实现的 PagingSource。响应将通过 Flow<PagingData> 自动传播。

让我们看看我们的头部/页脚实现是什么样的!

与任何列表一样,我们需要创建 3 个文件

  • 布局文件包含用于显示进度、错误和重试按钮的 UI 元素
  • ViewHolder** 文件根据 Paging LoadState 使 UI 项目可见
  • 适配器文件定义如何创建和绑定 ViewHolder。我们不会扩展 RecyclerView.Adapter,而是会扩展来自 Paging 3 的LoadStateAdapter

创建视图布局

为我们的仓库加载状态创建 repos_load_state_footer_view_item 布局。它应该有一个 ProgressBar、一个 TextView(显示错误)和一个重试 Button。必要的字符串和尺寸已在项目中声明。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:padding="8dp">
    <TextView
        android:id="@+id/error_msg"
        android:textColor="?android:textColorPrimary"
        android:textSize="@dimen/error_text_size"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAlignment="center"
        tools:text="Timeout"/>
    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"/>
    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/retry"/>
</LinearLayout>

创建ViewHolder

ui 文件夹中创建一个名为 ReposLoadStateViewHolder 的新 ViewHolder**。**它应该接收一个重试函数作为参数,在按下重试按钮时调用。创建一个 bind() 函数,该函数接收 LoadState 作为参数,并根据 LoadState 设置每个视图的可见性。使用 ViewBindingReposLoadStateViewHolder 实现如下所示

class ReposLoadStateViewHolder(
        private val binding: ReposLoadStateFooterViewItemBinding,
        retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.retryButton.setOnClickListener { retry.invoke() }
    }

    fun bind(loadState: LoadState) {
        if (loadState is LoadState.Error) {
            binding.errorMsg.text = loadState.error.localizedMessage
        }
        binding.progressBar.isVisible = loadState is LoadState.Loading
        binding.retryButton.isVisible = loadState is LoadState.Error
        binding.errorMsg.isVisible = loadState is LoadState.Error
    }

    companion object {
        fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.repos_load_state_footer_view_item, parent, false)
            val binding = ReposLoadStateFooterViewItemBinding.bind(view)
            return ReposLoadStateViewHolder(binding, retry)
        }
    }
}

创建 LoadStateAdapter

ui 文件夹中创建一个扩展 LoadStateAdapterReposLoadStateAdapter。适配器应该接收重试函数作为参数,因为在构造时会将重试函数传递给 ViewHolder

与任何 Adapter 一样,我们需要实现 onBind()onCreate() 方法。 LoadStateAdapter 使其更容易,因为它在这两个函数中都传递了 LoadState。在 onBindViewHolder() 中,绑定您的 ViewHolder。在 onCreateViewHolder() 中,定义如何根据父 ViewGroup 和重试函数创建 ReposLoadStateViewHolder

class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
    override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
        return ReposLoadStateViewHolder.create(parent, retry)
    }
}

将页脚适配器与列表绑定

现在我们拥有了页脚的所有元素,让我们将它们绑定到我们的列表。为此,PagingDataAdapter 有 3 个有用的方法

  • withLoadStateHeader - 如果我们只想显示一个头部——当您的列表仅支持在列表开头添加项目时应使用此方法。
  • withLoadStateFooter - 如果我们只想显示页脚 - 当你的列表只支持在列表末尾添加项目时,应该使用此方法。
  • withLoadStateHeaderAndFooter - 如果我们想要显示页眉和页脚 - 如果列表可以在两个方向分页。

更新 ActivitySearchRepositoriesBinding.bindState() 方法并调用适配器上的 withLoadStateHeaderAndFooter()。作为重试函数,我们可以调用 adapter.retry()

   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() }
        )
        ...
    }

由于我们有一个无限滚动的列表,所以查看页脚的一种简单方法是将您的手机或模拟器置于飞行模式,并滚动到列表的末尾。

让我们运行应用程序!

11. 在 Activity 中显示加载状态

您可能已经注意到我们目前有两个问题

  • 在迁移到 Paging 3 时,我们失去了在结果列表为空时显示消息的功能。
  • 每当您搜索新查询时,当前查询结果会保留在屏幕上,直到我们获得网络响应。这是糟糕的用户体验!相反,我们应该显示进度条或重试按钮。

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

这两个问题的解决方案是在我们的 SearchRepositoriesActivity 中对加载状态更改做出反应。

显示空列表消息

首先,让我们恢复空列表消息。仅当列表加载完成且列表中的项目数为 0 时,才会显示此消息。为了知道何时加载列表,我们将使用 PagingDataAdapter.loadStateFlow 属性。此 Flow 在每次通过 CombinedLoadStates 对象更改加载状态时都会发出。

CombinedLoadStates 为我们提供了我们定义的 PageSource 或网络和数据库情况下所需的 RemoteMediator 的加载状态(稍后将详细介绍)。

SearchRepositoriesActivity.bindList() 中,我们直接从 loadStateFlow 中收集。当 CombinedLoadStatesrefresh 状态为 NotLoadingadapter.itemCount == 0 时,列表为空。然后,我们分别切换 emptyListlist 的可见性。

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                }
            }
        }
    }

显示加载状态

让我们更新我们的 activity_search_repositories.xml 以包含重试按钮和进度条 UI 元素。

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.SearchRepositoriesActivity">
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/input_layout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginLeft="8dp"
        android:layout_marginRight="8dp"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <EditText
            android:id="@+id/search_repo"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/search_hint"
            android:imeOptions="actionSearch"
            android:inputType="textNoSuggestions"
            android:selectAllOnFocus="true"
            tools:text="Android"/>
    </com.google.android.material.textfield.TextInputLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:paddingVertical="@dimen/row_item_margin_vertical"
        android:scrollbars="vertical"
        app:layoutManager="LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/input_layout"
        tools:ignore="UnusedAttribute"/>

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/retry"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <TextView android:id="@+id/emptyList"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/no_results"
        android:textSize="@dimen/repo_name_size"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

我们的重试按钮应该触发 PagingData 的重新加载。为此,我们在 onClickListener 实现中调用 adapter.retry(),就像我们在页眉/页脚中所做的那样。

// SearchRepositoriesActivity.kt

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        retryButton.setOnClickListener { repoAdapter.retry() }
        ...
}

接下来,让我们在 SearchRepositoriesActivity.bindList 中对加载状态更改做出反应。由于我们只希望在有新查询时显示进度条,因此我们需要依赖于分页源的加载类型,特别是 CombinedLoadStates.source.refreshLoadStateLoadingError。此外,我们在前面步骤中注释掉的功能之一是在出现错误时显示 Toast,因此请确保我们也将其包含在内。为了显示错误消息,我们将必须检查 CombinedLoadStates.prependCombinedLoadStates.append 是否是 LoadState.Error 的实例,并从错误中检索错误消息。

让我们更新 SearchRepositoriesActivity 方法中的 ActivitySearchRepositoriesBinding.bindList 以具有此功能。

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.source.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.source.refresh is LoadState.Error

                // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
                val errorState = loadState.source.append as? LoadState.Error
                    ?: loadState.source.prepend as? LoadState.Error
                    ?: loadState.append as? LoadState.Error
                    ?: loadState.prepend as? LoadState.Error
                errorState?.let {
                    Toast.makeText(
                        this@SearchRepositoriesActivity,
                        "\uD83D\uDE28 Wooops ${it.error}",
                        Toast.LENGTH_LONG
                    ).show()
                }
            }
        }
    }

现在让我们运行应用程序并查看它的工作原理!

就是这样!使用当前设置,Paging 库组件将在正确的时间触发 API 请求、处理内存缓存并显示数据。运行应用程序并尝试搜索存储库。

12. 添加列表分隔符

提高列表可读性的一种方法是添加分隔符。例如,在我们的应用程序中,由于存储库按星数降序排序,因此我们可以在每 10k 星处添加分隔符。为了帮助实现这一点,Paging 3 API 允许将分隔符插入 PagingData 中。

573969750b4c719c.png

PagingData 中添加分隔符将导致我们屏幕上显示的列表发生修改。我们不再只显示 Repo 对象,还显示分隔符对象。因此,我们必须更改我们从 ViewModel 公开的 UI 模型,从 Repo 更改为可以封装这两种类型的另一种类型:RepoItemSeparatorItem。接下来,我们将不得不更新我们的 UI 以支持分隔符。

  • 为分隔符添加布局和 ViewHolder
  • 更新 RepoAdapter 以支持创建和绑定分隔符和存储库。

让我们一步一步地进行,看看实现是什么样的。

更改 UI 模型

目前 SearchRepositoriesViewModel.searchRepo() 返回 Flow<PagingData<Repo>>。为了支持存储库和分隔符,我们将在与 SearchRepositoriesViewModel 相同的文件中创建一个 UiModel 密封类。我们可以有 2 种类型的 UiModel 对象:RepoItemSeparatorItem

sealed class UiModel {
    data class RepoItem(val repo: Repo) : UiModel()
    data class SeparatorItem(val description: String) : UiModel()
}

因为我们想根据 10k 星来分隔存储库,所以让我们在 RepoItem 上创建一个扩展属性,以便为我们四舍五入星数。

private val UiModel.RepoItem.roundedStarCount: Int
    get() = this.repo.stars / 10_000

插入分隔符

SearchRepositoriesViewModel.searchRepo() 现在应该返回 Flow<PagingData<UiModel>>

class SearchRepositoriesViewModel(
    private val repository: GithubRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    ...

    fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
        ... 
    }
}

让我们看看实现是如何变化的!目前,repository.getSearchResultStream(queryString) 返回一个 Flow<PagingData<Repo>>,因此我们需要添加的第一个操作是将每个 Repo 转换为 UiModel.RepoItem。为此,我们可以使用 Flow.map 运算符,然后映射每个 PagingData 以从当前 Repo 项目构建新的 UiModel.Repo,从而产生 Flow<PagingData<UiModel.RepoItem>>

...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
                .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
... 

现在我们可以插入分隔符了!对于 Flow 的每次发射,我们都会调用 PagingData.insertSeparators()。此方法返回一个包含每个原始元素的 PagingData,以及一个可选的分隔符,您将根据之前和之后的元素生成该分隔符。在边界条件(列表的开头或结尾)下,相应的之前或之后的元素将为 null。如果不需要创建分隔符,则返回 null

因为我们正在将 PagingData 元素的类型从 UiModel.Repo 更改为 UiModel,所以请确保您显式设置了 insertSeparators() 方法的类型参数。

以下是 searchRepo() 方法的样子

   private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
        repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators { before, after ->
                    if (after == null) {
                        // we're at the end of the list
                        return@insertSeparators null
                    }

                    if (before == null) {
                        // we're at the beginning of the list
                        return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                    }
                    // check between 2 items
                    if (before.roundedStarCount > after.roundedStarCount) {
                        if (after.roundedStarCount >= 1) {
                            UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                        } else {
                            UiModel.SeparatorItem("< 10.000+ stars")
                        }
                    } else {
                        // no separator
                        null
                    }
                }
            }

支持多种视图类型

SeparatorItem 对象需要在我们的 RecyclerView 中显示。我们这里只显示一个字符串,所以让我们在 res/layout 文件夹中创建一个带有 TextViewseparator_view_item 布局。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/separatorBackground">

    <TextView
        android:id="@+id/separator_description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:padding="@dimen/row_item_margin_horizontal"
        android:textColor="@color/separatorText"
        android:textSize="@dimen/repo_name_size"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>

让我们在 ui 文件夹中创建一个 SeparatorViewHolder,我们只需将字符串绑定到 TextView 上。

class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    private val description: TextView = view.findViewById(R.id.separator_description)

    fun bind(separatorText: String) {
        description.text = separatorText
    }

    companion object {
        fun create(parent: ViewGroup): SeparatorViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.separator_view_item, parent, false)
            return SeparatorViewHolder(view)
        }
    }
}

更新 ReposAdapter 以支持 UiModel 而不是 Repo

  • PagingDataAdapter 参数从 Repo 更新为 UiModel
  • 实现 UiModel 比较器并用它替换 REPO_COMPARATOR
  • 创建 SeparatorViewHolder 并将其与 UiModel.SeparatorItem 的描述绑定。

由于我们现在需要显示 2 个不同的 ViewHolder,因此用 ViewHolder 替换 RepoViewHolder。

  • 更新 PagingDataAdapter 参数。
  • 更新 onCreateViewHolder 返回类型。
  • 更新 onBindViewHolder holder 参数。

以下是您最终的 ReposAdapter 的样子

class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == R.layout.repo_view_item) {
            RepoViewHolder.create(parent)
        } else {
            SeparatorViewHolder.create(parent)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is UiModel.RepoItem -> R.layout.repo_view_item
            is UiModel.SeparatorItem -> R.layout.separator_view_item
            null -> throw UnsupportedOperationException("Unknown view")
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val uiModel = getItem(position)
        uiModel.let {
            when (uiModel) {
                is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
                is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
            }
        }
    }

    companion object {
        private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
            override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
                return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
                        oldItem.repo.fullName == newItem.repo.fullName) ||
                        (oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
                                oldItem.description == newItem.description)
            }

            override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
                    oldItem == newItem
        }
    }
}

就是这样!运行应用程序时,您应该能够看到分隔符!

13. 来自网络和数据库的分页

让我们通过将数据保存在本地数据库中为我们的应用程序添加脱机支持。这样,数据库将成为我们应用程序的事实来源,我们将始终从那里加载数据。每当我们没有更多数据时,我们都会从网络请求更多数据,然后将其保存到数据库中。由于数据库是事实来源,因此当保存更多数据时,UI 将自动更新。

以下是添加脱机支持所需的操作

  1. 创建 Room 数据库、保存 Repo 对象的表以及我们将用于处理 Repo 对象的 DAO。
  2. 通过实现 RemoteMediator 来定义如何在到达数据库中数据的末尾时从网络加载数据。
  3. 基于 Repos 表作为数据源和 RemoteMediator 来加载和保存数据,构建 Pager

让我们一步一步地进行!

14. 定义 Room 数据库、表和 DAO

我们的 Repo 对象需要保存在数据库中,所以让我们从将 Repo 类设为实体开始,tableName = "repos",其中 Repo.id 是主键。为此,使用 @Entity(tableName = "repos") 注释 Repo 类,并将 @PrimaryKey 注释添加到 id。现在您的 Repo 类应该如下所示

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey @field:SerializedName("id") val id: Long,
    @field:SerializedName("name") val name: String,
    @field:SerializedName("full_name") val fullName: String,
    @field:SerializedName("description") val description: String?,
    @field:SerializedName("html_url") val url: String,
    @field:SerializedName("stargazers_count") val stars: Int,
    @field:SerializedName("forks_count") val forks: Int,
    @field:SerializedName("language") val language: String?
)

创建一个新的 db 包。我们将在其中实现访问数据库中数据的类以及定义数据库的类。

通过创建一个用 @Dao 注释的 RepoDao 接口来实现数据访问对象 (DAO) 以访问 repos 表。我们需要对 Repo 执行以下操作

  • 插入一个 Repo 对象列表。如果 Repo 对象已存在于表中,则替换它们。
 @Insert(onConflict = OnConflictStrategy.REPLACE)
 suspend fun insertAll(repos: List<Repo>)
  • 查询仓库名称或描述中包含查询字符串的仓库,并按星数降序排列,然后按名称字母顺序排列结果。不要返回 List<Repo>,而是返回 PagingSource<Int, Repo>。这样,repos 表就成为分页的数据源。
@Query("SELECT * FROM repos WHERE " +
  "name LIKE :queryString OR description LIKE :queryString " +
  "ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
  • 清除 Repos 表中的所有数据。
@Query("DELETE FROM repos")
suspend fun clearRepos()

以下是你的 RepoDao 应该是什么样子

@Dao
interface RepoDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(repos: List<Repo>)

    @Query("SELECT * FROM repos WHERE " +
   "name LIKE :queryString OR description LIKE :queryString " +
   "ORDER BY stars DESC, name ASC")
    fun reposByName(queryString: String): PagingSource<Int, Repo>

    @Query("DELETE FROM repos")
    suspend fun clearRepos()
}

实现 Repo 数据库

  • 创建一个抽象类 RepoDatabase,它扩展了 RoomDatabase
  • 使用 @Database 注解类,将实体列表设置为包含 Repo 类,并将数据库版本设置为 1。出于本代码实验室的目的,我们不需要导出模式。
  • 定义一个返回 ReposDao 的抽象函数。
  • companion object 中创建一个 getInstance() 函数,如果 RepoDatabase 对象尚不存在,则构建它。

以下是你的 RepoDatabase 的样子

@Database(
    entities = [Repo::class],
    version = 1,
    exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao

    companion object {

        @Volatile
        private var INSTANCE: RepoDatabase? = null

        fun getInstance(context: Context): RepoDatabase =
                INSTANCE ?: synchronized(this) {
                    INSTANCE
                            ?: buildDatabase(context).also { INSTANCE = it }
                }

        private fun buildDatabase(context: Context) =
                Room.databaseBuilder(context.applicationContext,
                        RepoDatabase::class.java, "Github.db")
                        .build()
    }
}

现在我们已经设置了数据库,让我们看看如何从网络请求数据并将其保存在数据库中。

15. 请求和保存数据 - 概述

Paging 库使用数据库作为需要在 UI 中显示的数据的真相来源。每当我们在数据库中没有更多数据时,我们需要从网络请求更多数据。为了帮助解决这个问题,Paging 3 定义了 RemoteMediator 抽象类,其中需要实现一个方法:load()。每当我们需要从网络加载更多数据时,都会调用此方法。此类返回一个 MediatorResult 对象,它可以是

  • Error - 如果我们在从网络请求数据时遇到错误。
  • Success - 如果我们成功地从网络获取了数据。在这里,我们还需要传入一个信号来指示是否可以加载更多数据。例如,如果网络响应成功但我们得到了一个空的仓库列表,则表示没有更多数据可以加载。

data 包中,让我们创建一个名为 GithubRemoteMediator 的新类,它扩展了 RemoteMediator。此类将为每个新查询重新创建,因此它将接收以下参数

  • 查询 String
  • GithubService - 这样我们就可以发出网络请求。
  • RepoDatabase - 这样我们就可以保存从网络请求中获取的数据。
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
    private val query: String,
    private val service: GithubService,
    private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {

   }
}

为了能够构建网络请求,load 方法有两个参数应该能提供我们需要的所有信息

  • PagingState - 这为我们提供了有关之前加载的页面的信息、列表中最近访问的索引以及我们在初始化分页流时定义的 PagingConfig
  • LoadType - 这告诉我们是否需要在之前加载的数据的末尾 (LoadType.APPEND) 或开头 (LoadType.PREPEND) 加载数据,或者这是我们第一次加载数据 (LoadType.REFRESH)。

例如,如果加载类型是 LoadType.APPEND,那么我们从 PagingState 中检索最后一个加载的项目。基于此,我们应该能够找出如何加载下一批 Repo 对象,方法是计算要加载的下一页。

在下一节中,您将了解如何计算要加载的下一页和上一页的键。

16. 计算并保存远程页面键

对于 Github API 的目的,我们用来请求仓库页面的页面键只是一个页面索引,在获取下一页时会递增。这意味着,给定一个 Repo 对象,可以根据页面索引 + 1请求下一批 Repo 对象。可以根据页面索引 - 1请求上一批 Repo 对象。在特定页面响应中接收到的所有 Repo 对象都将具有相同的下一页和上一页键。

当我们从 PagingState 获取最后一个加载的项目时,无法知道它所属页面的索引。为了解决这个问题,我们可以添加另一个表来存储每个 Repo 的下一页和上一页键;我们可以将其称为 remote_keys。虽然这可以在 Repo 表中完成,但为与 Repo 关联的下一页和上一页远程键创建一个新表使我们能够更好地关注点分离

db 包中,让我们创建一个名为 RemoteKeys 的新数据类,使用 @Entity 对其进行注释,并添加 3 个属性:仓库 id(这也是主键)以及上一页和下一页键(当我们无法追加或预先加载数据时可以为 null)。

@Entity(tableName = "remote_keys")
data class RemoteKeys(
    @PrimaryKey
    val repoId: Long,
    val prevKey: Int?,
    val nextKey: Int?
)

让我们创建一个 RemoteKeysDao 接口。我们将需要以下功能

  • 插入一个 **RemoteKeys 列表**,因为每当我们从网络获取 Repos 时,我们都会为它们生成远程键。
  • 根据 **Repo id 获取一个 RemoteKey**。
  • 清除 **RemoteKeys**,这将在我们有新查询时使用。
@Dao
interface RemoteKeysDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
    suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

    @Query("DELETE FROM remote_keys")
    suspend fun clearRemoteKeys()
}

让我们将 RemoteKeys 表添加到我们的数据库中并提供对 RemoteKeysDao 的访问。为此,请按如下方式更新 RepoDatabase

  • 将 RemoteKeys 添加到实体列表中。
  • RemoteKeysDao 作为抽象函数公开。
@Database(
        entities = [Repo::class, RemoteKeys::class],
        version = 1,
        exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao
    abstract fun remoteKeysDao(): RemoteKeysDao

    ... 
    // rest of the class doesn't change
}

17. 请求和保存数据 - 实现

现在我们已经保存了远程键,让我们回到 GithubRemoteMediator 并看看如何使用它们。此类将替换我们的 GithubPagingSource。让我们从 GithubPagingSource 中的 GithubRemoteMediator 中复制 GITHUB_STARTING_PAGE_INDEX 声明,并删除 GithubPagingSource 类。

让我们看看如何实现 GithubRemoteMediator.load() 方法

  1. 根据 LoadType 找出我们需要从网络加载哪个页面。
  2. 触发网络请求。
  3. 网络请求完成后,如果接收到的仓库列表不为空,则执行以下操作
  4. 我们为每个 Repo 计算 RemoteKeys
  5. 如果这是一个新的查询 (loadType = REFRESH),则我们清除数据库。
  6. RemoteKeysRepos 保存到数据库中。
  7. 返回 MediatorResult.Success(endOfPaginationReached = false)
  8. 如果仓库列表为空,则返回 MediatorResult.Success(endOfPaginationReached = true)。如果我们在请求数据时遇到错误,则返回 MediatorResult.Error

代码整体看起来像这样。稍后我们将替换 TODO。

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
    val page = when (loadType) {
        LoadType.REFRESH -> {
         // TODO
        }
        LoadType.PREPEND -> {
        // TODO
        }
        LoadType.APPEND -> {
        // TODO
        }
    }
    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 {
            // clear all tables in the database
            if (loadType == LoadType.REFRESH) {
                repoDatabase.remoteKeysDao().clearRemoteKeys()
                repoDatabase.reposDao().clearRepos()
            }
            val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
            val nextKey = if (endOfPaginationReached) null else page + 1
            val keys = repos.map {
                RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
            }
            repoDatabase.remoteKeysDao().insertAll(keys)
            repoDatabase.reposDao().insertAll(repos)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    } catch (exception: IOException) {
        return MediatorResult.Error(exception)
    } catch (exception: HttpException) {
        return MediatorResult.Error(exception)
    }
}

让我们看看如何根据 LoadType 找到要加载的页面。

18. 根据 LoadType 获取页面

现在我们知道了获得页面键后 GithubRemoteMediator.load() 方法中会发生什么,让我们看看如何计算它。这将取决于 LoadType

LoadType.APPEND

当我们需要在当前加载的数据集末尾加载数据时,加载参数为 LoadType.APPEND。因此,现在,根据数据库中的最后一个项目,我们需要计算网络页面键。

  1. 我们需要获取数据库中加载的最后一个 Repo 项的远程键——让我们将其分离到一个函数中
    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
        // Get the last page that was retrieved, that contained items.
        // From that last page, get the last item
        return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
                ?.let { repo ->
                    // Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
                }
    }
  1. 如果 remoteKeys 为 null,则表示刷新结果尚未在数据库中。我们可以返回带有 endOfPaginationReached = false 的 Success,因为如果 RemoteKeys 变为非 null,Paging 将再次调用此方法。如果 remoteKeys null 但其 nextKeynull,则表示我们已达到追加的分页结束。
val page = when (loadType) {
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        // We can return Success with endOfPaginationReached = false because Paging
        // will call this method again if RemoteKeys becomes non-null.
        // If remoteKeys is NOT NULL but its nextKey is null, that means we've reached
        // the end of pagination for append.
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
      ...
  }

LoadType.PREPEND

当我们需要在当前加载的数据集开头加载数据时,加载参数为 LoadType.PREPEND。根据数据库中的第一个项目,我们需要计算网络页面键。

  1. 我们需要获取数据库中加载的第一个 Repo 项的远程键——让我们将其分离到一个函数中
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
    // Get the first page that was retrieved, that contained items.
    // From that first page, get the first item
    return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let { repo ->
                // Get the remote keys of the first items retrieved
                repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
            }
}
  1. 如果 remoteKeys 为空,则表示刷新结果尚未存在于数据库中。我们可以返回成功,并将 endOfPaginationReached 设置为 false,因为如果 RemoteKeys 变为非空,分页将再次调用此方法。如果 remoteKeys **不**为 null,但其 prevKeynull,则表示我们已到达前置分页的末尾。
val page = when (loadType) {
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }

      ...
  }

LoadType.REFRESH

LoadType.REFRESH 在我们第一次加载数据时或调用 PagingDataAdapter.refresh() 时被调用;因此,现在加载数据的参考点是 state.anchorPosition。如果这是第一次加载,则 anchorPositionnull。当调用 PagingDataAdapter.refresh() 时,anchorPosition 是显示列表中第一个可见的位置,因此我们需要加载包含该特定项目的页面。

  1. 根据 state 中的 anchorPosition,我们可以通过调用 state.closestItemToPosition() 获取该位置最接近的 Repo 项目。
  2. 根据 Repo 项目,我们可以从数据库中获取 RemoteKeys
private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Repo>
): RemoteKeys? {
    // The paging library is trying to load data after the anchor position
    // Get the item closest to the anchor position
    return state.anchorPosition?.let { position ->
        state.closestItemToPosition(position)?.id?.let { repoId -> 
   repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
        }
    }
}
  1. 如果 remoteKey 不为空,则可以从中获取 nextKey。在 Github API 中,页面键按顺序递增。因此,要获取包含当前项目的页面,我们只需从 remoteKey.nextKey 中减去 1。
  2. 如果 RemoteKeynull(因为 anchorPositionnull),则我们需要加载的页面是初始页面:GITHUB_STARTING_PAGE_INDEX

现在,完整的页面计算如下所示

val page = when (loadType) {
    LoadType.REFRESH -> {
        val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
        remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
    }
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
}

19. 更新分页 Flow 创建

现在我们在 ReposDao 中实现了 GithubRemoteMediatorPagingSource,我们需要更新 GithubRepository.getSearchResultStream 以使用它们。

为此,GithubRepository 需要访问数据库。让我们在构造函数中将数据库作为参数传递。此外,由于此类将使用 GithubRemoteMediator

class GithubRepository(
        private val service: GithubService,
        private val database: RepoDatabase
) { ... }

更新 Injection 文件

  • provideGithubRepository 方法应获取上下文作为参数,并在 GithubRepository 构造函数中调用 RepoDatabase.getInstance
  • provideViewModelFactory 方法应获取上下文作为参数并将其传递给 provideGithubRepository
object Injection {
    private fun provideGithubRepository(context: Context): GithubRepository {
        return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
    }

    fun provideViewModelFactory(context: Context, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
        return ViewModelFactory(owner, provideGithubRepository(context))
    }
}

更新 SearchRepositoriesActivity.onCreate() 方法并将上下文传递给 Injection.provideViewModelFactory()

       // get the view model
        val viewModel = ViewModelProvider(
            this, Injection.provideViewModelFactory(
                context = this,
                owner = this
            )
        )
            .get(SearchRepositoriesViewModel::class.java)

让我们回到 GithubRepository。首先,为了能够按名称搜索仓库,我们必须在查询字符串的开头和结尾添加 %。然后,当调用 reposDao.reposByName 时,我们得到一个 PagingSource。因为每次我们在数据库中进行更改时,PagingSource 都会失效,我们需要告诉 Paging 如何获取 PagingSource 的新实例。为此,我们只需创建一个调用数据库查询的函数

// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory =  { database.reposDao().reposByName(dbQuery)}

现在我们可以更改 Pager 构建器,以使用 GithubRemoteMediatorpagingSourceFactoryPager 是一个实验性 API,因此我们需要使用 @OptIn 进行注释

@OptIn(ExperimentalPagingApi::class)
return Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
        remoteMediator = GithubRemoteMediator(
                query,
                service,
                database
        ),
        pagingSourceFactory = pagingSourceFactory
).flow

就是这样!让我们运行应用程序!

使用 RemoteMediator 响应加载状态

到目前为止,当从 CombinedLoadStates 读取时,我们始终从 CombinedLoadStates.source 读取。但是,当使用 RemoteMediator 时,只能通过检查 CombinedLoadStates.sourceCombinedLoadStates.mediator 来获取准确的加载信息。特别是,我们目前在 sourceLoadStateNotLoading 时,会在新查询中触发滚动到列表顶部。我们还必须确保我们新添加的 RemoteMediatorLoadState 也为 NotLoading

为此,我们定义一个枚举,它总结了 Pager 获取的列表的呈现状态

enum class RemotePresentationState {
    INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}

有了上述定义,我们就可以比较 CombinedLoadStates 的连续发射,并使用它们来确定列表中项目的精确状态。

@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
    scan(RemotePresentationState.INITIAL) { state, loadState ->
        when (state) {
            RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
                is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
                else -> state
            }
            RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
                is LoadState.NotLoading -> RemotePresentationState.PRESENTED
                else -> state
            }
        }
    }
        .distinctUntilChanged()

以上内容使我们能够更新我们用来检查是否可以滚动到列表顶部的 notLoading Flow 的定义

       val notLoading = repoAdapter.loadStateFlow
            .asRemotePresentationState()
            .map { it == RemotePresentationState.PRESENTED }

同样,在显示初始页面加载期间的加载微调器(在 SearchRepositoriesActivity 中的 bindList 扩展中)时,应用程序仍然依赖于 LoadState.source。我们现在想要的是仅对来自 RemoteMediator 的加载显示加载微调器。其他可见性取决于 LoadStates 的 UI 元素也存在此问题。因此,我们按如下方式更新 LoadStates 到 UI 元素的绑定

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                ...
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds, either from the local db or the remote.
                list.isVisible =  loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
                }
            }
        }
    }

此外,因为我们拥有数据库作为唯一的事实来源,所以有可能在应用程序启动时处于数据库中有数据但 RemoteMediator 的刷新失败的状态。这是一个有趣的极端情况,但我们可以轻松地处理它。为此,我们可以保留对标头 LoadStateAdapter 的引用,并将其 LoadState 覆盖为 RemoteMediator 的 LoadState,当且仅当其刷新状态发生错误时。否则,我们使用默认值。

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                // Show a retry header if there was an error refreshing, and items were previously
                // cached OR default to the default prepend state
                header.loadState = loadState.mediator
                    ?.refresh
                    ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
                    ?: loadState.prepend
                ...
            }
        }
    }

您可以在 end 文件夹中找到此代码实验室中完成的所有步骤的完整代码。

20. 总结

现在我们已经添加了所有组件,让我们回顾一下我们学到了什么!

  • PagingSource 异步地从您定义的源加载数据。
  • Pager.flow 基于配置和一个定义如何实例化 PagingSource 的函数创建 Flow<PagingData>
  • Flow 每当 PagingSource 加载新数据时,都会发出新的 PagingData
  • UI 观察更改的 PagingData 并使用 PagingDataAdapter 更新呈现数据的 RecyclerView
  • 要重试 UI 中失败的加载,请使用 PagingDataAdapter.retry 方法。在后台,Paging 库将触发 PagingSource.load() 方法。
  • 要向列表添加分隔符,请创建一个高级类型,其中分隔符作为支持的类型之一。然后使用 PagingData.insertSeparators() 方法来实现分隔符生成逻辑。
  • 要将加载状态显示为标头或页脚,请使用 PagingDataAdapter.withLoadStateHeaderAndFooter() 方法并实现 LoadStateAdapter。如果要根据加载状态执行其他操作,请使用 PagingDataAdapter.addLoadStateListener() 回调。
  • 要使用网络和数据库,请实现 RemoteMediator
  • 添加 RemoteMediator 会导致 LoadStatesFlow 中的 mediator 字段更新。