使用 Paging 3 实现分页加载

作者 / Florina Muntenescu

Paging 库可以帮助您优雅地渐进加载大型数据集合,同时也可以减少网络的使用和系统资源的消耗。基于您的反馈我们得知,Paging 2.0 API 还不能满足开发者们的需求——开发者们希望以更简便的方式处理错误;以更灵活的方式实现列表数据的转换操作,例如 map 和 filter;以及支持分割符、页眉和页脚。基于以上反馈,我们推出了 Paging 3.0。这是一个完全使用 Kotlin 协程重写的库 (依然支持 Java 用户),它将为您提供您所要求的功能。

Paging 3 亮点

Paging 3 的 API 对分页加载时可能需要实现的常见功能提供了支持:

  • 跟踪获取前一页或后一页所需要的参数;
  • 当用户滚动到现有数据的末尾时,自动请求正确的下一页;
  • 确保不会同时触发多个请求;
  • 跟踪加载状态,并支持您在 RecyclerView 的列表项或者界面中的其他地方展示它。为失败的加载提供简便的重试功能;
  • 无论您是否使用 Flow、LiveData、RxJava Flowable 或 Observable,都可以对需要展示的列表使用 map 或 filter 这类常见的操作;
  • 提供实现列表分隔符的简便方法;
  • 简化了数据缓存,确保不会让您在每次配置更改时都执行数据转换。

我们还让 Paging 3 的一些组件向后兼容 Paging 2.0。因此,如果您已经在应用中使用了 Paging,则可以逐步 迁移至 Paging 3

在您的应用中使用 Paging 3

假设我们正在实现一个展示所有狗狗的应用。狗狗的数据从 GoodDoggos API 获得,该 API 支持基于索引的分页。让我们研究下需要实现的 Paging 组件,以及如何将 Paging 集成到现有的应用架构。接下来的例子将使用 Kotlin 及其协程功能编写,如果您需要使用 LiveData/RxJava 实现的 Java 编程语言示例,请参阅 Android 开发者文档 | Paging 3 库概述

下图为您应用的各个层级中推荐直接接入 Paging 的 Android 应用架构:

[图片上传失败...(image-252749-1601046343560)]

定义数据源

数据源的定义取决于您从哪里加载数据。您仅需实现 PagingSource 或者 PagingSource 与 RemoteMediator 的组合:

  • 如果您从 单个源 加载数据,例如网络、本地数据、文件等,实现 PagingSource 即可,如果您使用了 Room,从 2.3.0-alpha 开始,它将默认为您实现 Paging Source,请参见: Android 开发文档|使用 Room DAO 访问数据
  • 如果您从一个 多层级数据源 加载数据,就像带有本地数据库缓存的网络数据源那样。那么您需要实现 RemoteMediator 来合并两个数据源到一个本地数据库缓存的 PagingSource 中。

PagingSource

PagingSource 可以定义一个分页数据的数据源,以及从该数据源获取数据的方式。PagingSource 应当为资源库层的一部分。您可以实现 load() 函数来从数据源获取分页数据,并返回加载好的数据和加载前后页的参数信息。load() 是一个挂起函数,您可以在这里调用其他的 挂起函数,例如网络请求:

class DoggosRemotePagingSource(
    val backend: GoodDoggosService
) : PagingSource<Int, Dog>() {
  override suspend fun load(
    params: LoadParams<Int>
  ): LoadResult<Int, Dog> {
    try {
      // 未定义时加载第 1 页
      val nextPageNumber = params.key ?: 1
      val response = backend.getDoggos(nextPageNumber)
      return LoadResult.Page(
        data = response.doggos,
        prevKey = null, // 仅向后翻页
        nextKey = response.nextPageNumber + 1
      )
    } catch (e: Exception) {
        // 在此块中处理错误
        return LoadResult.Error(exception)
    }
  }
}

PagingData 与 Pager

分页数据的容器被称为 PagingData,每次刷新数据时,都会创建一个 PagingData 的实例。如果要创建 PagingData 数据流,您需要创建一个 Pager 实例,并提供一个 PagingConfig 配置对象和一个可以告诉 Pager 如何获取您实现的 PagerSource 的实例的函数,以供 Pager 使用。

您要在 ViewModel 中构造 Pager 对象并向 UI 暴露一个 Flow<PagingData>。Flow<PagingData> 有一个方便的 cachedIn() 方法,该方法使得数据流可以被共享,也让您可以在 CoroutineScope 中缓存 Flow<PagingData> 的内容。这样一来,如果您在数据流中实现了任何转换操作,当 Activity 被重建并使得您从 flow 中获取数据时,不会再次触发这些操作。由于我们希望数据在配置产生变化后仍然存在,缓存应当尽可能靠近 UI 层,但又不能在 UI 层中,那么最好的位置便是在 ViewModel 中,并使用 viewModelScope:

val doggosPagingFlow = Pager(PagingConfig(pageSize = 10)) {
  DogRemotePagingSource(goodDoggosService)
}.flow.cachedIn(viewModelScope)

PagingDataAdapter

为了将 RecyclerView 与 PagingData 联系起来,您需要实现一个 PagingDataAdapter:

class DogAdapter(diffCallback: DiffUtil.ItemCallback<Dog>) :
  PagingDataAdapter<Dog, DogViewHolder>(diffCallback) {
  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ): DogViewHolder {
    return DogViewHolder(parent)
  }

  override fun onBindViewHolder(holder: DogViewHolder, position: Int) {
    val item = getItem(position)
    if(item == null) {
      holder.bindPlaceholder()
    } else {
      holder.bind(item)
    }
  }
}

接下来,在您的 Activity/Fragment 中,您需要收集 Flow<PagingData> 并将其提交给 PagingDataAdapter。下面是一个在 ActivityonCreate() 函数中实现该操作的示例:

val viewModel by viewModels<DoggosViewModel>()

val pagingAdapter = DogAdapter(DogComparator)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter

lifecycleScope.launch {
  viewModel.doggosPagingFlow.collectLatest { pagingData ->
    pagingAdapter.submitData(pagingData)
  }
}

分页数据转换

[图片上传失败...(image-531fab-1601046343560)]

转换 PagingData 流与您在其他数据流中所做的同类操作相似。举例来说,如果我们只想要展示 Flow<PagingData<Dog>> 中那些调皮的狗狗,我们可能需要映射 Flow 对象并过滤 PagingData:

doggosPagingFlow.map { pagingData ->
        pagingData.filter { dog -> dog.isPlayful }
    }
有分隔符的列表

向列表中添加 分隔符 同样是分页数据转换,这里我们通过转换 PagingData 向列表中插入分隔对象。举例来说,我们可以为狗狗的名字插入字母分隔符。当使用分隔符时,您需要自己实现 UI 模型类以支持新的分隔项。当您修改 PagingData 并插入分隔符时,您会用到 insertSeparators 转换:

pager.flow.map { pagingData: PagingData<Dog> ->
  pagingData.map { doggo ->
    // 将数据流中的项目转换为 UiModel.DogModel。
    UiModel.DogModel(doggo)
  }
  .insertSeparators<UiModel.DogModel, UiModel> { before: Dog, after: Dog ->
      return if(after == null) {
       // 我们到了列表的末尾
          null
      } else if (before == null || before.breed != after.breed) {
          // 上下品种不同,显示分隔符
          UiModel.SeparatorItem(after.breed)
      } else {
          // 无分隔符
          null
      }
    }
  }
}.cachedIn(viewModelScope)

就像前面一样,我们会在数据到达 UI 层之前使用 cachedIn,这样便可以缓存所有已经加载的数据以及数据转换的结果。当配置发生改变时,这些缓存就会被复用。

使用 RemoteMediator 进行高级分页操作

当您从一个 多层级数据源 加载数据时,应当实现一个 RemoteMediator。举例来说,在此类的实现中,您应当从网络请求数据并存入数据库。每当数据库中没有数据可以被展示时,就会触发 load() 方法。基于 PagingStateLoadType,我们可以构造下一页的数据请求。

由于 Paging 库并不知道您的 API 是怎样的,所以定义如何构造和获取前一页和下一页的远程数据的工作便需要由您自己来完成。举例来说,您可以将您从网络接收到的每个项目与远程关键字关联起来并存入数据库。

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

   val page = ...  // 基于 loadType 和 state 进行计算

   try {
       val doggos = backend.getDoggos(page)
       doggosDatabase.doggosDao().insertAll(doggos)

       val endOfPaginationReached = emails.isEmpty()
       return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
   } catch (exception: Exception) {
       return MediatorResult.Error(exception)
   } 
}

如果您从网络请求数据并存入数据库,那么数据库才是屏幕上所展示数据的真正数据源——这意味着 UI 会展示从数据库获取的数据,所以您需要为您的数据库实现 PagingSource。如果您正在使用 Room,那么您只需要向您的 DAO 添加一个返回 PagingSource 的查询:

@Query("SELECT * FROM doggos")
fun getDoggos(): PagingSource<Int, Dog>

这种情况下 Pager 的实现略有不同,您还需要传入 RemoteMediator 实例:

val pagingSourceFactory = { database.doggosDao().getDoggos() }

return Pager(
     config = PagingConfig(pageSize = NETWORK_PAGE_SIZE),
     remoteMediator = DoggosRemoteMediator(service, database),
     pagingSourceFactory = pagingSourceFactory
).flow

您可以参阅文档了解 使用 RemoteMediator 的详细信息。如果您需要 RemoteMediator 在应用中的完整实现,可以参阅 Paging codelabPaging 相关代码

我们将 Paging 3 设计为一个帮您涵盖简单和复杂情形下的分页加载的库。它可以让您更方便地使用大规模数据集合,无论数据来自网络、数据库、内存缓存还是上述几种情况的组合。Paging 库基于 协程和 Flow 实现,使得它可以很简单地调用挂起函数并且处理数据流。

Paging 3 仍然处于 alpha 版本,我们需要您帮助我们进一步优化!请参阅以下资源开始使用 Paging:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259