Android 分页高级 Codelab

1. 简介

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

您将学到什么

  • 分页 3 的主要组件是什么。
  • 如何在项目中添加分页 3。
  • 如何使用分页 3 API 在列表中添加页眉或页脚。
  • 如何使用分页 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。因为我们希望确保在配置更改(例如旋转)时不会多次请求数据,所以在 ViewModel 中使用 liveData() 构建器方法将 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 服务协同工作的存储库,提供统一的数据接口。
  • 一个为 UI 提供特定数据的 ViewModel
  • UI,它显示了 ViewModel 中数据的视觉表示形式。

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

这个 Codelab 向您介绍分页库及其主要组件

  • 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 - 帮助从网络和数据库实现分页。

在本代码实验室中,您将实现上述每个组件的示例。

5. 定义数据源

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

目前,GithubRepository 具有数据源的大量职责,这些职责将在我们完成添加分页库后由分页库处理。

  • 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() 函数将由分页库调用,以异步获取更多数据,这些数据将在用户滚动时显示。 LoadParams 对象保留与加载操作相关的信息,包括以下内容

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

加载函数返回一个 LoadResult。这将替换我们在应用程序中使用 RepoSearchResult,因为 LoadResult 可以采用以下类型之一

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

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

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

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

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 加载内容的选项,例如,加载多远,初始加载的大小请求等。您必须定义的唯一强制参数是页面大小——每页应加载多少个项目。默认情况下,分页将保留您加载的所有页面。为了确保在用户滚动时不会浪费内存,请在 PagingConfig 中设置 maxSize 参数。默认情况下,如果分页可以计算未加载的项目,并且如果 enablePlaceholders 配置标志为 true,则分页将返回 null 项目作为未加载内容的占位符。这样,您将能够在适配器中显示占位符视图。为了简化本代码实验室的工作,让我们通过传递 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。因此,PagingDataFlows 应该独立于其他 Flows 公开。

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

为此,让我们更新 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,同时还添加一个字段以公开 PagingDataFlow

   /**
     * 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() 方法,它允许我们缓存 Flow<PagingData> 的内容在一个 CoroutineScope 中。由于我们是在 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,并根据它收到的新流开始工作。在我们的例子中,这将导致我们丢失用户滚动的最后一个查询的值。因此,我们使用 Flow 操作符和 replay 值 1 来缓存最后一个值,这样当新的查询进来时它不会丢失。
  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. 使适配器使用 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. 触发网络更新

用 Flow 替换 LiveData

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

幸运的是,我们不需要做太多更改。与其 observe() 一个 LiveData,我们改为 launch() 一个 coroutinecollect() 一个 FlowUiState 将与 PagingAdapter LoadState 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() 方法中,让我们将 setupScrollListener() 替换为内联 RecyclerView.OnScrollListener。我们还完全删除了 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 布尔标志。完成此操作后,我们有两个可以从 collect 的流: PagingData 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 中显示的内容同步,因此在布尔标志发出为真时立即调用 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 状态导致项目显示,并在根据 LoadState 移除、插入或更改项目时通知 RecyclerView

对于 **重试机制**,我们使用 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 设置每个视图的可见性。使用 ViewBinding 实现的 ReposLoadStateViewHolder 看起来像这样

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. 在活动中显示加载状态

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

  • 在迁移到 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 对象的表,以及一个 DAO,我们将使用它来处理 Repo 对象。
  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 数据库

  • 创建一个扩展 RoomDatabase 的抽象类 RepoDatabase
  • 使用 @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. 请求和保存数据 - 概述

分页库使用数据库作为需要在 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 中的 GITHUB_STARTING_PAGE_INDEX 声明复制到我们的 GithubRemoteMediator 中,并删除 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 为空,则表示刷新结果尚未在数据库中。我们可以返回带有 endOfPaginationReached = false 的 Success,因为 Paging 将在 RemoteKeys 变为非空时再次调用此方法。如果 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 的 Success,因为 Paging 将在 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 的创建

现在我们已经在 GithubRemoteMediatorReposDao 中实现了 PagingSource,我们需要更新 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 构建器,使用 GithubRemoteMediatorpagingSourceFactory。由于 Pager 是一个实验性 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 来获得准确的加载信息。特别地,我们目前在 source LoadStateNotLoading 时,会触发滚动到列表顶部以进行新的查询。我们还必须确保新添加的 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 字段更新。