Android Paging 基础知识

Android Paging 基础知识

เกี่ยวกับ Codelab นี้

subjectอัปเดตล่าสุดเมื่อ พ.ย. 13, 2023
account_circleเขียนโดย Googler

1 简介

学习内容

  • Paging 库有哪些主要组件。
  • 如何将 Paging 库添加到项目中。

构建内容

在此 Codelab 中,您将从一个示例应用开始构建,该应用中会显示一个报道列表。该列表是静态的,其中包含 500 篇报道,并且所有报道都保存在手机内存中:

7d256d9c74e3b3f5.png

学完此 Codelab 后,您将:

  • 了解分页的概念。
  • 了解 Paging 库的核心组件。
  • 了解如何使用 Paging 库实现分页。

完成后,您将得到一款具备以下特征的应用:

  • 可成功实现分页。
  • 当提取更多数据时,能够有效地向用户传达相关信息。

以下是最终界面的简要预览:

6277154193f7580.gif

所需条件

建议条件

2 设置您的环境

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

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

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

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

如果您未安装 git,可以点击下方按钮下载此 Codelab 的全部代码:

代码分为两个文件夹,即 basicadvanced。对于此 Codelab,我们只关注 basic 文件夹。

basic 文件夹中还有另外两个文件夹:startend。我们将开始处理 start 文件夹中的代码,在 Codelab 结束时,start 文件夹中的代码应该与 end 文件夹中的代码相同。

  1. 在 Android Studio 中的 basic/start 目录中打开项目。
  2. 在设备或模拟器上运行 app 运行配置。

89af884fa2d4e709.png

我们应该可以看到一个报道列表!滚动到列表底部,确认列表是静态的,换句话说,当我们到达列表末尾时,系统不会提取更多项。滚动回顶部,验证是否所有项仍在列表中。

3 分页简介

要向用户显示信息,最常用的方式是使用列表。不过,有时这些列表只会为用户提供一个小窗口,供他们查看其中的所有内容。当用户滚动浏览可用信息时,他们往往会想提取更多数据来补充已经看到的信息。每次提取数据时,都必须高效且流畅,以免增量加载对用户体验造成负面影响。但增量加载在性能方面也有好处,因为应用不需要一次性在内存中保存大量数据。

这种增量提取信息的过程称为“分页”,其中每个“页面”对应一个要提取的数据块。如需请求页面,要分页的数据源通常需要进行查询,从而定义所需信息。此 Codelab 的其余部分将介绍 Paging 库,并展示如何借助该库在应用中快速、高效地实现分页。

Paging 库的核心组件

Paging 库的核心组件如下:

  • PagingSource - 用于为特定页面查询加载数据块的基类。它是数据层的一部分,通常从 DataSource 类公开,随后由 Repository 公开以在 ViewModel 中使用。
  • PagingConfig - 用于定义确定分页行为的形参的类。这包括页面大小、是否启用占位符等。
  • Pager - 负责生成 PagingData 流的类。这取决于 PagingSource,因此应在 ViewModel 中创建。
  • PagingData - 用于存储分页数据的容器。每次数据刷新都对应一个单独的 PagingData 发送,并由其自己的 PagingSource 提供支持。
  • PagingDataAdapter - 用于在 RecyclerView 中呈现 PagingDataRecyclerView.Adapter 子类。PagingDataAdapter 可以使用工厂方法连接到 Kotlin FlowLiveData、RxJava Flowable、RxJava Observable 甚至静态列表。PagingDataAdapter 会监听内部 PagingData 加载事件,并在网页加载时高效更新界面。

566d0f6506f39480.jpeg

在以下部分中,您将实现上述每个组件的示例。

4 项目概览

在当前形式下,应用显示的是一个静态报道列表。每篇报道都有一个标题、说明和创建日期。静态列表适用于项的数量较少的情况,但随着数据集的扩大,这种列表不能很好地进行扩展。为解决此问题,我们将使用 Paging 库来实现分页,但我们首先要介绍一下应用中已有的组件。

应用遵循应用架构指南中推荐的架构。每个软件包都包含以下内容:

数据层

  • ArticleRepository:负责提供报道列表并将其保存在内存中。
  • Article:代表数据模型的类,用于表示从数据层提取的信息。

界面层

  • ActivityRecyclerView.AdapterRecyclerView.ViewHolder:负责在界面中显示列表的类。
  • ViewModel:负责创建界面需要显示的状态的状态容器。

报道库使用 articleStream 字段在 Flow 中公开其所有报道。进而,界面层中的 ArticleViewModel 会读取相应信息,然后让其做好准备,以供 ArticleActivity 中的界面通过 state 字段(即 StateFlow)使用。

在报道库中将报道作为 Flow 公开后,当所呈现的报道随着时间推移而发生更改时,报道库可以对报道进行更新。例如,如果报道的标题发生更改,系统可以轻松地将相应更改传达给 articleStream 的收集器。通过在 ViewModel 中对界面状态使用 StateFlow,我们可以确保,即使停止收集界面状态(例如,在配置更改期间重新创建 Activity 时),当我们重新开始收集界面状态时,依然可以从上次中断的地方直接恢复操作。

如前所述,报道库中的当前 articleStream 仅呈现当天的新闻。这对有些用户来说可能已经足够了,但对其他人来说,在滚动浏览当天提供的所有报道后,他们可能还会想查看旧报道。用户的这种期望使得报道非常适合通过分页进行显示。我们应该通过报道来探索分页的其他原因如下:

  • ViewModel 将所有加载到内存中的项保存在 items StateFlow 中。当数据集变得非常大时,这是一个主要问题,因为这可能会影响性能。
  • 报道列表越大,当其中一篇或多篇报道发生更改时,更新相应报道的成本就越高。

Paging 库可帮助您解决这些问题,同时还会提供一致的 API,用于在应用中增量提取数据(分页)。

5 定义数据源

在实现分页时,我们需要确保满足以下条件:

  • 正确处理来自界面的数据请求,确保不会针对同一查询同时触发多个请求。
  • 在内存中保留合理数量的检索数据。
  • 触发提取更多数据的请求,对我们已经获取的数据进行补充。

我们可以通过 PagingSource 来实现所有这些目的。PagingSource 会指定如何以增量数据块的形式来检索数据,从而定义数据源。然后,PagingData 对象会从 PagingSource 中提取数据,以响应用户在 RecyclerView 中滚动生成的加载提示。

我们的 PagingSource 将会加载报道。在 data/Article.kt 中,您会发现定义如下所示的模型:

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

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

  • 分页键的类型 - 用于请求更多数据的网页查询类型的定义。在本例中,我们会提取特定报道 ID 之后或之前的报道,因为这两个 ID 肯定是有序且递增的。
  • 已加载数据的类型 - 每个页面都返回一个报道 List,因此类型为 Article
  • 检索数据的位置 - 通常为数据库、网络资源或分页数据的任何其他来源。不过,在此 Codelab 中,我们将使用本地生成的数据。

data 软件包中,我们将在名为 ArticlePagingSource.kt 的新文件中创建一个 PagingSource 实现:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource
import androidx.paging.PagingState

class ArticlePagingSource : PagingSource<Int, Article>() {
   
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
       
TODO("Not yet implemented")
   
}
   
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
       
TODO("Not yet implemented")
   
}
}

PagingSource 需要我们实现两个函数:load()getRefreshKey()

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

  • 要加载的页面的键 - 如果这是第一次调用 load()LoadParams.key 将为 null。在这种情况下,必须定义初始页面键。对于我们的项目,我们将报道 ID 用作键。此外,我们还要在初始页面键的 ArticlePagingSource 文件顶部添加一个为 0STARTING_KEY 常量。
  • 加载大小 - 请求加载内容的数量。

load() 函数会返回一个 LoadResultLoadResult 可以是以下类型之一:

  • LoadResult.Page(如果结果返回成功)。
  • LoadResult.Error(如果发生错误)。
  • LoadResult.Invalid(如果 PagingSource 因无法再保证其结果的完整性而应失效)。

LoadResult.Page 有三个必需的实参:

  • data:所提取的项的 List
  • prevKey:如果 load() 方法需要提取先于当前页面显示的项,它会使用这个键。
  • nextKey:如果 load() 方法需要提取晚于当前页面显示的项,它会使用这个键。

…以及两个可选实参:

  • itemsBefore:要在加载的数据前面显示的占位符的数量。
  • itemsAfter:要在加载的数据后面显示的占位符的数量。

我们的加载键是 Article.id 字段。我们可以将其用作键,因为每增加一篇报道,Article ID 就会加 1;也就是说,报道 ID 是单调递增的整数。

如果相应方向没有更多数据要加载,则 nextKeyprevKeynull。在本例中,对于 prevKey

  • 如果 startKeySTARTING_KEY 相同,我们将返回 null,因为我们无法在此键后面加载更多项。
  • 否则,我们会获取列表中的第一个项并在它后面加载 LoadParams.loadSize,以确保永远不会返回小于 STARTING_KEY 的键。为此,我们要定义 ensureValidKey() 方法。

添加以下函数以检查分页键是否有效:

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
   /**
     * Makes sure the paging key is never less than [STARTING_KEY]
     */
    private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
}

对于 nextKey

  • 由于我们支持加载无限的项,因此我们要传入 range.last + 1

此外,由于每篇报道都有一个 created 字段,因此我们还需要为该字段生成一个值。将以下代码添加到文件顶部:

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   
...
}

有了上述所有代码,我们现在就可以实现 load() 函数了:

import kotlin.math.max
...

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
       
// Start paging with the STARTING_KEY if this is the first load
       
val start = params.key ?: STARTING_KEY
       
// Load as many items as hinted by params.loadSize
       
val range = start.until(start + params.loadSize)

       
return LoadResult.Page(
           
data = range.map { number ->
               
Article(
                   
// Generate consecutive increasing numbers as the article id
                   
id = number,
                   
title = "Article $number",
                   
description = "This describes article $number",
                   
created = firstArticleCreatedTime.minusDays(number.toLong())
               
)
           
},

           
// Make sure we don't try to load items behind the STARTING_KEY
           
prevKey = when (start) {
               
STARTING_KEY -> null
               
else -> ensureValidKey(key = range.first - params.loadSize)
           
},
           
nextKey = range.last + 1
       
)
   
}

   
...
}

接下来,我们需要实现 getRefreshKey()。当 Paging 库因其后备 PagingSource 中的数据发生更改而需要重新加载界面项时,系统会调用该方法。这种 PagingSource 底层数据发生更改且需要在界面中进行更新的情况称为“失效”。失效后,Paging 库会创建一个新的 PagingSource 来重新加载数据,并通过发出新的 PagingData 来通知界面。我们将在后面的部分中详细了解失效。

从新的 PagingSource 加载时,系统会调用 getRefreshKey() 来提供新 PagingSource 应开始加载的键,从而确保用户在刷新后不会丢失其在列表中的当前位置。

以下两种原因之一会导致 Paging 库中发生失效:

  • 您对 PagingAdapter 调用了 refresh()
  • 您对 PagingSource 调用了 invalidate()

返回的键(本例中为 Int)将通过 LoadParams 实参传递到新 PagingSourceload() 方法的下一次调用。为防止项在失效后跳动,我们需要确保返回的键会加载足够的项来填充屏幕。这样可提高新的一组项中包含失效数据中存在的项的可能性,从而有助于保持当前的滚动位置。我们来看一下我们应用中的相应实现:

   // The refresh key is used for the initial load of the next PagingSource, after invalidation
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        // In our case we grab the item closest to the anchor position
        // then return its id - (state.config.pageSize / 2) as a buffer
        val anchorPosition = state.anchorPosition ?: return null
        val article = state.closestItemToPosition(anchorPosition) ?: return null
        return ensureValidKey(key = article.id - (state.config.pageSize / 2))
    }

在上面的代码段中,我们使用了 PagingState.anchorPosition。如果您好奇 Paging 库是如何知道要提取更多项的,这就是线索!当界面尝试从 PagingData 读取项时,它会尝试在特定索引处读取。如果读取了数据,相应数据会显示在界面中。不过,如果没有数据,Paging 库就会知道自己需要提取数据以满足失败的读取请求。读取时成功提取数据的最后一个索引是 anchorPosition

刷新时,我们会获取最接近 anchorPositionArticle 键,并将其用作加载键。这样,当我们从新的 PagingSource 再次开始加载时,获取一组项就会包含已加载的项,从而确保流畅且一致的用户体验。

至此,您已经完全定义了 PagingSource。下一步是将其连接到界面。

6 为界面生成 PagingData

在当前实现中,我们在 ArticleRepository 中使用 Flow<List<Article>> 将加载的数据公开给 ViewModelViewModel 进而会使用 stateIn 运算符保持始终可用的数据状态,以便向界面进行公开。

使用 Paging 库时,我们将改为从 ViewModel 公开 Flow<PagingData<Article>>PagingData 是一种类型,用于封装我们已加载的数据,并帮助 Paging 库决定何时提取更多数据,还可确保我们不会对同一页面提出两次请求。

为了构建 PagingData,我们将使用 Pager 类中的几种不同的构建器方法之一,具体取决于我们想要使用哪个 API 将 PagingData 传递到应用的其他层:

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

我们在应用中已使用了 Flow,故将继续使用此方法。但不是使用 Flow<List<Article>>,而是用 Flow<PagingData<Article>>

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

  • PagingConfig。该类用于设置关于如何从 PagingSource 加载内容的选项,例如提前多久加载、初始加载请求的大小,等等。您必须定义的唯一必需形参是页面大小,即应在每个页面中加载的项数。默认情况下,Paging 会将您加载的所有页面保存在内存中。为确保系统在用户滚动时不会浪费内存,请在 PagingConfig 中设置 maxSize 形参。默认情况下,如果 Paging 可以统计未加载项的数量以及enablePlaceholders 配置标志为 true,那么 Paging 将返回 null 作为尚未加载内容的占位符。这样,您就可以在适配器中显示占位符视图。为了简化此 Codelab 中的工作,我们通过传递 enablePlaceholders = false 停用占位符。
  • 一个函数,用于定义如何创建 PagingSource。在本例中,我们将创建 ArticlePagingSource,因此我们需要用一个函数来告知 Paging 库该如何操作。

接下来,我们来修改 ArticleRepository

更新 ArticleRepository

  • 删除 articlesStream 字段。
  • 添加一个名为 articlePagingSource() 的方法,用于返回我们刚刚创建的 ArticlePagingSource
class ArticleRepository {

   
fun articlePagingSource() = ArticlePagingSource()
}

清理 ArticleRepository

Paging 库可以为我们做很多事情:

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

这意味着,除了 articlePagingSource() 之外,ArticleRepository 中的所有其他对象均可移除。现在,您的 ArticleRepository 应如下所示:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource

class ArticleRepository {
   
fun articlePagingSource() = ArticlePagingSource()
}

现在,ArticleViewModel 中应包含编译错误。我们来看看需要做出哪些更改!

7 在 ViewModel 中请求并缓存 PagingData

在修正编译错误之前,我们先来看看 ViewModel

class ArticleViewModel(...) : ViewModel() {

    val items: StateFlow<List<Article>> = ...
}

为了在 ViewModel 中集成 Paging 库,我们要将 items 的返回值类型从 StateFlow<List<Article>> 更改为 Flow<PagingData<Article>>。为此,首先要在文件顶部添加一个名为 ITEMS_PER_PAGE 的专用常量:

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel {
    ...
}

接下来,我们将 items 更新为 Pager 实例的输出结果。为此,我们要向 Pager 传递两个形参:

  • 一个 PagingConfig,其 pageSizeITEMS_PER_PAGE 且已停用占位符
  • 一个 PagingSourceFactory,用于提供我们刚刚创建的 ArticlePagingSource 的实例
class ArticleViewModel(...) : ViewModel() {

   val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        ...
}

接下来,为了在配置或导航发生更改时保持分页状态,我们使用 cachedIn() 方法向其传递 androidx.lifecycle.viewModelScope

完成上述更改后,我们的 ViewModel 应如下所示:

package com.example.android.codelabs.paging.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.android.codelabs.paging.data.Article
import com.example.android.codelabs.paging.data.ArticleRepository
import com.example.android.codelabs.paging.data.ITEMS_PER_PAGE
import kotlinx.coroutines.flow.Flow

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel(
   
private val repository: ArticleRepository,
) : ViewModel() {

   
val items: Flow<PagingData<Article>> = Pager(
       
config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
       
pagingSourceFactory = { repository.articlePagingSource() }
   
)
       
.flow
       
.cachedIn(viewModelScope)
}

关于 PagingData 还需要注意的一点是,它是一个独立的类型,包含要在 RecyclerView 中显示的数据的可变更新流。每次发出的 PagingData 都是完全独立的,并且如果后备 PagingSource 因底层数据集发生更改而失效,系统可以针对单个查询发出多个 PagingData 实例。因此,应独立于其他 Flows 公开 PagingDataFlows

大功告成!现在,我们已在 ViewModel 实现分页功能!

8 将适配器与 PagingData 配合使用

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

更新 ArticleAdapter 以便与 PagingData 流配合使用:

  • 目前,ArticleAdapter 实现的是 ListAdapter。请将其改为实现 PagingDataAdapter。类主体的其余部分保持不变:
import androidx.paging.PagingDataAdapter
...

class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
// body is unchanged
}

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

9 在界面中使用 PagingData

在当前实现中,我们有一个名为 binding.setupScrollListener() 的方法,该方法会在满足特定条件时调用 ViewModel 来加载更多数据。Paging 库会自动执行所有相关操作,因此我们可以删除该方法及其用法。

接下来,由于 ArticleAdapter 不再是 ListAdapter,而是 PagingDataAdapter,因此我们要进行以下两项细微更改:

  • 我们将 Flow 中的终端运算符从 ViewModel 切换为 collectLatest,而不是 collect
  • 我们使用 submitData()(而非 submitList())通知 ArticleAdapter 有更改。

我们对 pagingData Flow 使用 collectLatest,以便在发出新的 pagingData 实例时,取消收集之前发出的 pagingData

完成这些更改后,Activity 应如下所示:

import kotlinx.coroutines.flow.collectLatest

class ArticleActivity : AppCompatActivity() {

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

       
val viewModel by viewModels<ArticleViewModel>(
           
factoryProducer = { Injection.provideViewModelFactory(owner = this) }
       
)

       
val items = viewModel.items
       
val articleAdapter = ArticleAdapter()

       
binding.bindAdapter(articleAdapter = articleAdapter)

       
lifecycleScope.launch {
           
repeatOnLifecycle(Lifecycle.State.STARTED) {
               
items.collectLatest {
                   
articleAdapter.submitData(it)
               
}
           
}
       
}
   
}
}

private fun ActivityArticlesBinding.bindAdapter(
   
articleAdapter: ArticleAdapter
) {
   
list.adapter = articleAdapter
   
list.layoutManager = LinearLayoutManager(list.context)
   
val decoration = DividerItemDecoration(list.context, DividerItemDecoration.VERTICAL)
   
list.addItemDecoration(decoration)
}

现在,应用应能进行编译并运行。您已成功将应用迁移到 Paging 库!

f97136863cfa19a0.gif

10 在界面中显示加载状态

当 Paging 库提取更多项以在界面中显示时,最佳实践是向用户指明更多数据即将显示。幸运的是,Paging 库提供了一种便捷的方式,让您能够使用 CombinedLoadStates 类型来访问其加载状态。

CombinedLoadStates 实例描述了 Paging 库中可加载数据的所有组件的加载状态。在本例中,我们仅关注 ArticlePagingSourceLoadState,因此我们将主要在 CombinedLoadStates.source 字段中使用 LoadStates 类型。您可以通过 PagingDataAdapter.loadStateFlowPagingDataAdapter 访问 CombinedLoadStates

CombinedLoadStates.source 是一种 LoadStates 类型,它具有针对三种不同类型 LoadState 的字段:

  • LoadStates.append:适用于在用户当前位置之后获取的项的 LoadState
  • LoadStates.prepend:适用于在用户当前位置之前获取的项的 LoadState
  • LoadStates.refresh:适用于初始加载的 LoadState

每个 LoadState 本身可以是下列状态之一:

  • LoadState.Loading:正在加载项。
  • LoadState.NotLoading:未加载项。
  • LoadState.Error:加载时发生错误。

在本例中,我们只关心 LoadState 是否为 LoadState.Loading,因为我们的 ArticlePagingSource 不包含错误情况。

首先,我们要向界面顶部和底部添加进度条,用于在任一方向指示提取的加载状态。

activity_articles.xml 中,添加两个 LinearProgressIndicator 栏,如下所示:

<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.ArticleActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scrollbars="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/prepend_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/append_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

接下来,我们通过从 PagingDataAdapter 收集 LoadStatesFlow 来响应 CombinedLoadState。在 ArticleActivity.kt 中收集状态:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                articleAdapter.loadStateFlow.collect {
                    binding.prependProgress.isVisible = it.source.prepend is Loading
                    binding.appendProgress.isVisible = it.source.append is Loading
                }
            }
        }
        lifecycleScope.launch {
        ...
    }

最后,我们在 ArticlePagingSource 中添加一点延迟以模拟加载过程:

private const val LOAD_DELAY_MILLIS = 3_000L

class ArticlePagingSource : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = startKey.until(startKey + params.loadSize)

        if (start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
        return ...

}

再次运行应用并滚动到列表底部。您应该会看到,当 Paging 库提取更多项时,底部进度条会显示;当提取完成时,底部进度条会消失!

6277154193f7580.gif

11 总结

我们来快速回顾一下所学内容。我们:

  • 学习了分页概览以及进行分页的必要性。
  • 通过创建 Pager、定义 PagingSource 和发出 PagingData,在应用中添加了分页。
  • 使用 cachedIn 运算符在 ViewModel 中缓存了 PagingData
  • 利用 PagingDataAdapter 在界面中使用了 PagingData
  • 使用 PagingDataAdapter.loadStateFlow 响应了 CombinedLoadStates

大功告成!如需了解更高级的分页概念,请查看高级分页 Codelab