Android Paging Advanced Codelab

1. 简介

学习内容

  • Paging 3 有哪些主要组件。
  • 如何将 Paging 3 添加到您的项目中。
  • 如何使用 Paging 3 API 将页眉或页脚添加到列表中。
  • 如何使用 Paging 3 API 添加列表分隔符。
  • 如何从网络和数据库加载分页数据。

构建内容

在此 Codelab 中,您将从一个示例应用开始构建,该应用中会显示 GitHub 代码库列表。每当用户滚动到所显示列表的末尾时,系统就会触发新的网络请求,并将结果显示在屏幕上。

您将逐步添加代码,完成以下学习内容:

  • 迁移到 Paging 库组件。
  • 将加载状态页眉和页脚添加到列表中。
  • 每次搜索新的代码库时显示加载进度。
  • 在列表中添加分隔符。
  • 添加对数据库的支持,以从网络与数据库加载分页数据。

您的应用最终显示效果如下:

23643514cb9cf43e.png

所需条件

有关架构组件的介绍,请查看“Room with a View”Codelab。有关 Flow 的说明,请查看“带 Kotlin Flow 和 LiveData 的高级协程”Codelab

2. 设置您的环境

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

为帮助您尽快入门,我们准备了一个入门级项目,您可以在此项目的基础上进行构建。

如果您已安装 git,只需运行以下命令即可。(您可以在终端/命令行中输入 git --version 进行检查,验证其是否正确执行。)

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

初始状态代码位于 master 分支中。对于某些步骤,您可以参阅以下解决方案:

  • 分支 step5-9_paging_3.0 - 您可以找到第 5 步至第 9 步(为项目添加最新版本的 Paging)的解决方案。
  • 分支 step10_loading_state_footer - 您可以找到第 10 步(添加一个显示加载状态的页脚)的解决方案。
  • 分支 step11_loading_state - 您可以找到第 11 步(在查询之间添加加载状态显示)的解决方案。
  • 分支 step12_separators - 您可以找到第 12 步(为应用添加分隔符)的解决方案。
  • 分支 step13-19_network_and_database - 您可以找到第 13 步至第 19 步(为应用添加离线支持)的解决方案。

如果您未安装 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 - 与使用 RecyclerView 显示 Activity 有关的类。

每当用户滚动到代码库名称列表末尾或搜索新代码库时,GithubRepository 类都会从网络中检索此列表。查询的结果列表使用 GithubRepository 中的 ConflatedBroadcastChannel 保存在内存中,并以 Flow 向外提供数据。

SearchRepositoriesViewModelGithubRepository 请求数据,并把数据传递给 SearchRepositoriesActivity。我们希望确保在配置变更(例如旋转)时不会多次请求数据,因此会使用 liveData() 构建器方法,在 ViewModel 中将 Flow 转换为 LiveData。这样,LiveData 会在内存中缓存最新结果列表,当 SearchRepositoriesActivity 重新创建完毕后,屏幕上会显示 LiveData 的内容。ViewModel 公开了以下两项:

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

UiState 表示呈现应用界面所需的一切内容,其中不同的字段对应不同的界面组件。它是一个不可变对象,也就是说无法对其进行更改;不过,界面可以生成并观察此对象的新版本。在我们的示例中,此对象的新版本因用户的操作(搜索新查询或滚动列表以获取更多内容)而生成。

用户操作由 UiAction 类型适当表示。将用于与 ViewModel 互动的 API 封装在单一类型中具有以下优势:

  • 小型 API Surface:可添加、移除或更改操作,但 ViewModel 的方法签名始终保持不变。这使得重构操作在本地完成,且不太可能会泄露抽象或接口实现。
  • 更轻松地管理并发:能够保证特定请求的执行顺序非常重要,本 Codelab 中稍后会对此进行介绍。通过使用 UiAction 强行输入 API,我们可以编写对可能发生的情况及其发生时间有严格要求的代码。

从易用性的角度来看,存在以下问题:

  • 用户对列表加载状态一无所知:他们会在搜索新代码库时看到空白屏幕;或者会突然拉到列表末尾,同时会加载针对同一查询的更多结果。
  • 用户无法重试失败的查询。
  • 在屏幕方向发生变化或进程终止后,列表始终会滚动到顶部。

从实现角度来看,存在以下问题:

  • 内存中列表的长度一直增长,当用户滚动屏幕时很浪费内存。
  • 为了缓存数据,需要将结果从 Flow 转换为 LiveData,这增加了代码的复杂性。
  • 如果需要应用显示多个列表,就需要为每个列表写入大量样板文件。

下面介绍 Paging 库如何解决这些问题,并介绍了使用的组件。

4. Paging 库组件

使用 Paging 库,您可以更加轻松地在应用的界面中逐步、流畅地加载数据。Paging API 可为许多功能提供支持;如果没有 Paging API,您就需要在页面中加载数据时动手来实现这些功能:

  • 跟踪要用于检索下一页和上一页的键。
  • 当用户滚动浏览到列表末尾时,自动请求正确的页面。
  • 确保多个请求不会同时触发。
  • 可让您缓存数据:如果您使用的是 Kotlin,就可以在 CoroutineScope 中执行此操作;如果您使用的是 Java,就可以使用 LiveData 来实现。
  • 跟踪加载状态,可在 RecyclerView 列表内容中或界面的其他位置上显示加载状态;如果有失败的加载,可轻松再次重试。
  • 允许您对将要显示的列表执行常见的操作(例如 mapfilter),无论您使用的是 FlowLiveData、RxJava Flowable,还是 Observable,都是如此。
  • 提供一种实现列表分隔符的简单方法。

应用架构指南提出了一个包含以下主要组件的架构提议:

  • 本地数据库,用作向用户呈现并由用户操控的数据的单一可信来源。
  • Web API 服务。
  • 代码库,与数据库和 Web API 服务配合使用,提供统一的数据界面。
  • ViewModel,提供界面专用数据。
  • 界面,直观地呈现 ViewModel 中的数据。

Paging 库使用上述所有的组件,并协调这些组件之间的交互,以便从数据源加载内容“页面”,并在界面中显示相应内容。

此 Codelab 向您介绍 Paging 库及其主要组件:

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

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

5. 定义数据源

PagingSource 实现定义了数据源以及如何从这里检索数据。PagingData 对象会查询来自 PagingSource 的数据,响应用户在 RecyclerView 中滚动生成的加载提示。

目前,数据源处理工作很多是由 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()

Paging 库将调用 load() 函数,以异步方式提取更多数据,用于在用户滚动过程中显示。LoadParams 对象保存有与加载操作相关的信息,包括以下信息:

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

加载函数会返回一个 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 并传递给界面。每当我们浏览到列表的末尾,并且有更多数据从网络加载时,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<RepoSearchResult>,而是用 Flow<PagingData<Repo>>

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

  • PagingConfig。该类用于设置关于如何从 PagingSource 加载内容的选项,例如提前多久加载、初始加载请求的大小,等等。您必须定义的唯一必需参数是页面大小,即应在每个页面中加载的项数。默认情况下,Paging 会将您加载的所有页面保存在内存中。为确保系统在用户滚动时不会浪费内存,请在 PagingConfig 中设置 maxSize 参数。默认情况下,如果 Paging 可以统计未加载项的数量以及enablePlaceholders 配置标志为 true,那么 Paging 将返回 null 作为尚未加载内容的占位符。如此,您可以在适配器中显示占位符视图。为了简化此 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 可以为我们做很多事情:

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

这意味着,除了 getSearchResultStream 和我们在其中定义了 NETWORK_PAGE_SIZE 的伴生对象之外,GithubRepository 中的所有其他对象均可移除。现在您的 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 值,而是选择公开一个单独的 Flow<PagingData<Repo>>,其用途与 searchResult 相同。

PagingData 是一个独立的类型,包含要在 RecyclerView 中显示的数据的可变更新流。每次发出的 PagingData 都是完全独立的,并且可以针对单个查询发出多个 PagingData。因此,应独立于其他 Flows 公开 PagingDataFlows

此外,对于输入的每个新查询,我们都希望滚动到列表顶部以显示第一个搜索结果,从而提供出色的用户体验。不过,由于系统可能会多次发出分页数据,因此我们只希望在用户尚未开始滚动的情况下滚动到列表顶部。

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

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

我们来回顾一下架构。由于向 ViewModel 发出的所有请求都会经过一个单一的入口点(即定义为 (UiAction) -> Unitaccept 字段),因此我们需要执行以下操作:

  • 将该入口点转换为包含所需类型的流。
  • 转换这些流。
  • 将这些流重新合并到一个 StateFlow<UiState> 中。

从更实用的角度来看,我们将 reduceUiState 发出的 UiAction。这有点像组装线:UiAction 类型是传入的原材料,它们会产生影响(有时称为转变),UiState 是可绑定到界面的已完成的输出。这一过程有时称为使界面成为 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() 方法,让我们能够在 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. 将适配器与 PagingData 配合使用

如需将 PagingData 绑定到 RecyclerView,请使用 PagingDataAdapter。每当系统加载 PagingData 内容时,PagingDataAdapter 就会收到通知,然后它会通知 RecyclerView 进行更新。

更新 ui.ReposAdapter 以便与 PagingData 流配合使用:

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

到目前为止,我们已执行很多变更,现在只需再执行一步操作就可以运行应用了,那就是连接界面!

9. 触发网络更新

将 LiveData 替换为 Flow

我们来更新 SearchRepositoriesActivity,以与 Paging 3 配合使用。如要能够使用 Flow<PagingData>,我们需要启动一个新协程。当重新创建 activity 时,lifecycleScope 负责取消请求。我们将通过它来执行上述操作。

幸运的是,我们无需进行太多更改。我们不会对 LiveData 运行 observe(),而是对 coroutine 运行 launch(),并对 Flow 运行 collect()UiState 将与 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 添加一个参数。完成后,我们来继续看看滚动监听器。请注意,之前我们利用 RecyclerView 中附加的 OnScrollListener 来了解何时触发更多数据。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 布尔值标志。完成后,我们就拥有两个可从 PagingData FlowshouldScrollToTop Flow collect 的流。

    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 的与界面中显示的内容同步,因此在发出的布尔值标志为 true 时,可安全地立即调用 list.scrollToPosition(0)

LoadStateFlow 中的类型是一个 CombinedLoadStates 对象。

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

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

在我们的示例中,我们希望仅在刷新完成(即 LoadStaterefreshNotLoading)时重置滚动位置。

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

完成上述所有操作后,您的 activity 应如下所示:

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。在下一步中,我们将了解如何显示加载状态页脚。

您可以在分支 step5-9_paging_3.0 中找到已完成的上述步骤的完整代码。

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

在我们的应用中,我们希望能够根据加载状态显示页脚:即,在列表加载期间,我们希望显示进度旋转图标。发生错误时,我们希望显示错误和重试按钮。

3f6f2cd47b55de92.png 661da51b58c32b8c.png

我们所需构建的页眉/页脚应遵循如下原则:即,将相应列表附加在我们实际显示的列表项开头(作为页眉)或末尾(作为页脚)。页眉/页脚是仅包含一个元素的列表,该元素根据 Paging LoadState,显示进度条或带有重试按钮的错误消息。

由于根据加载状态显示页眉/页脚以及实现重试机制属于常见任务,Paging 3 可以帮助我们完成这两项任务。

对于页眉/页脚的实现,我们将使用 LoadStateAdapterRecyclerView.Adapter 的实现会自动收到关于加载状态变化的通知。它会确保,仅 LoadingError 状态才会导致显示相应项,并且会在项被移除、插入或更改时会通知 RecyclerView,具体取决于 LoadState

对于重试机制,我们使用 adapter.retry()。从本质上讲,此方法最终会为相应页面调用 PagingSource 实现。系统将通过 Flow<PagingData> 自动传递响应。

我们来看一下页眉/页脚的实现。

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

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

创建视图布局

为代码库的加载状态创建 repos_load_state_footer_view_item 布局。它应该包括 ProgressBarTextView(用于显示错误)和重试 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 文件夹 ** 中创建一个名为 ReposLoadStateViewHolderViewHolder 文件。**它应接收一个重试函数作为参数,当您按下“重试”按钮时,系统会调用此函数。创建一个 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() }
        )
        ...
    }

由于我们的列表可以无限滚动,如果您想快速查看页脚,只需将手机或模拟器置于飞行模式,然后滚动屏幕直至列表末尾。

我们来运行应用!

您可以在分支 step10_loading_state_footer 中找到已完成的上述步骤的完整代码。

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,在其中加入重试按钮、进度条这两个界面元素:

<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.refresh)加载类型,还要依赖于 LoadState: LoadingError。此外,在上一步中我们提及了一个功能,可以在出现错误时显示 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 请求,处理内存缓存以及显示数据。运行应用并尝试搜索代码库。

您可以在分支 step11_loading_state 中找到已完成的上述步骤的完整代码。

12. 添加列表分隔符

提高列表可读性的一种方法是添加分隔符。例如,在我们的应用中,由于代码库会按星数降序排序,因此,我们可以每 10,000 颗星提供一个分隔符。为了实现这个功能,可以使用 Paging 3 API 向 PagingData 插入分隔符。

573969750b4c719c.png

PagingData 中添加分隔符后,屏幕上显示的列表会有改变。我们不再只显示 Repo 对象,还会显示分隔符对象。因此,我们必须将我们从 ViewModel 公开的界面模型从 Repo 改为另一种可封装以下两种类型的类型:RepoItemSeparatorItem。接下来,为了支持分隔符,必须更新界面:

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

让我们逐步执行此步骤,看看实现效果。

更改界面模型

目前 SearchRepositoriesViewModel.searchRepo() 会返回 Flow<PagingData<Repo>>。为了同时支持代码库和分隔符,我们将使用 SearchRepositoriesViewModel 在同一个文件中创建一个 UiModel 密封类。UiModel 对象有两种类型:RepoItemSeparatorItem

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

因为我们希望按每 10,000 颗星数来分隔代码库,因此需要在 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,因此请将 RepoViewHolder 替换为 ViewHolder:

  • 更新 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
        }
    }
}

大功告成!运行应用时,您应该能看到分隔符!

您可以在分支 step12_separators 中找到已完成的上述步骤的完整代码。

13. 从网络和数据库加载分页数据

让我们将数据保存到本地数据库,从而给应用添加离线支持。这样,该数据库将成为应用的单一可信数据来源,而且我们可以一直从该数据库中加载数据。每当没有更多数据时,就会从网络中请求更多数据,然后将其保存在数据库中。由于该数据库是可靠数据来源,因此当保存更多数据后,界面会自动更新。

以下是添加离线支持所需执行的操作:

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

让我们逐一执行这些步骤!

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

我们的 Repo 对象需要保存在数据库中,所以我们首先应使用 tableName = "repos",将 Repo 类设置为实体,其中 Repo.id 是主键。为此,请给 Repo 类添加 @Entity(tableName = "repos") 注解,为 id 添加 @PrimaryKey 注解。现在 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>)
  • 查询名称或说明中包含查询字符串的 repos,按照星数对那些结果进行降序排序,在星数相同的情况下再按名称的字母顺序排序。返回 PagingSource<Int, Repo>,而不是返回 List<Repo>。这样,repos 表就会成为 Paging 的数据源。
@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。在本 Codelab 中,我们不需要导出架构。
  • 定义一个抽象函数,用于返回 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 库使用数据库作为单一可信数据来源。每当数据库中没有更多的数据时,我们就需要从网络请求更多数据。为了解决此问题,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 {

   }
}

为了能够构建网络请求,加载方法有 2 个参数,这两个参数应该可以提供我们所需的全部信息:

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

例如,如果加载类型为 LoadType.APPEND,我们会从 PagingState 检索上一项内容。据此我们应该能够通过计算出下一个要加载的页面,确定如何加载下一组 Repo 对象。

接下来,您将了解如何计算上一个和下一个加载页面的键。

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

对 GitHub API 而言,用于请求 Repos 页面的页面键只是一个页面索引,每次获得下一页时该索引值便会递增。这意味着,如果有一个 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。从 GithubRemoteMediator 中复制 GithubPagingSourceGITHUB_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 为 null,则表示刷新结果尚未纳入数据库中。我们可以通过 endOfPaginationReached = false 返回“Success”,因为如果 RemoteKeys 变为非 null,Paging 将再次调用此方法。如果 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

首次加载数据时或者调用 PagingDataAdapter.refresh() 时,LoadType.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 不为 null,我们可以从中获取 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.source 读取数据时,我们始终是从 CombinedLoadStates 读取的。不过,在使用 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 }

同样,如果应用要在首次加载网页期间(在 SearchRepositoriesActivitybindList 扩展中)显示一个指示“正在加载”的旋转图标,仍然要依赖 LoadState.source。现在,我们需要仅针对来自 RemoteMediator 的加载操作显示“正在加载”旋转图标。显示状态取决于 LoadStates 的其他界面元素同样也有这个问题。因此,我们将更新 LoadStates 与界面元素的绑定,如下所示:

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 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 的状态(当且仅当前者的刷新状态出现错误时)。否则,使用默认值。

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

您可以在分支 step13-19_network_and_database 中找到已完成的上述步骤的完整代码。

20. 小结

添加完所有组件后,让我们回顾一下所学的内容!

  • PagingSource 用于从您定义的数据源异步加载数据。
  • Pager.flow 用于创建一个基于配置的 Flow<PagingData> 和一个定义如何实例化 PagingSource 的函数。
  • 每当通过 PagingSource 加载新数据时,Flow 都会发送新的 PagingData
  • 界面将观察更改后的 PagingData,并使用 PagingDataAdapter 更新用于呈现数据的 RecyclerView
  • 要从界面加载“失败重试”,请使用 PagingDataAdapter.retry 方法。从本质上讲,Paging 库将触发 PagingSource.load() 方法。
  • 要为列表添加分隔符,请创建高等级受支持类型分隔符。然后,使用 PagingData.insertSeparators() 方法实现您的分隔符生成逻辑。
  • 要显示加载状态的页眉或页脚,请使用 PagingDataAdapter.withLoadStateHeaderAndFooter() 方法并实现 LoadStateAdapter。如果要根据加载状态执行其他操作,请使用 PagingDataAdapter.addLoadStateListener() 回调。
  • 如需处理网络和数据库,请实现 RemoteMediator
  • 添加 RemoteMediator 后,LoadStatesFlow 中的 mediator 字段也会相应更新。