🔥🔥🔥 Paging3 ǀ 官方分页库拆解与应用(上)

1,525 阅读5分钟

犹记得首次接触paging时,数据源DataSource,分为positional/ItemKeyed/pageKeyed DataSource,优点是严格把控数据源,减少错误和崩溃,但缺点也是把控太严格,而且很多定制化需求不支持,如header和footer,实现起来太过麻烦,所以抛弃了。 paging3.x目前更新了pagingSourceremoteMediatorinsertSeparators等优秀api,重新定义写法,大大提高了可扩展性。所以重新理解其设计理念和实际应用,向Google工程师学习其优秀的框架设计能力。

Paging3 ǀ 官方分页库拆解与应用🔥🔥🔥

一 前言

Paging是jetpack的一个分页组件,可以使开发者更轻松在 RecyclerView中分页加载来自本地存储或网络端数据。包含一些特性支持:

  • 提供了配套的RecyclerView适配器,会在用户滚动到已加载数据的“末端”时自动请求数据「即自动提前向下/向上加载更多」
  • 本地数据库Room提供了便捷支持扩展
  • 对协程flow,liveData以及rxjava这几个常用的响应式开发api有一流的支持
  • 包含错误重试,刷新等功能;

1.1 一些资料

官方资料永远是最正确的,能保证实时更新。目前的remoteMediator还是实验性,后续更新随时都会改变api调用方式。所以本文也只能从整体结构和设计上给大家指指路,具体api得按最新的来。

个人习惯是,在开始正式学习前,先找好对应的官方及优秀的第三方资料&demo,好的资料支持能事半功倍,甚至有意外收获。

二 快速上手

2.1 库引入

paging本身已经支持协程和flow。

dependencies {
  def paging_version = "3.1.1"

  implementation "androidx.paging:paging-runtime:$paging_version"

  // 可选 - RxJava2 支持
  implementation "androidx.paging:paging-rxjava2:$paging_version"

  // 可选 - RxJava3 支持
  implementation "androidx.paging:paging-rxjava3:$paging_version"

  // 可选 - Room 对paging的扩展支持
  def room_version = "2.5.0"
  implementation "androidx.room:room-paging:$room_version"
	xxx
}

2.2 HelloWorld!

为了减少阅读成本,每个demo的fragment尽可能是独立的完整代码,不加封装;后续功能扩展都是基于这个简易demo,标识step1 2 3方便理解阅读

代码->HelloWorldFragement

2.2.1 定义简单数据模型Message,layout文件就是两个TextView就不写了。

data class Message

//数据模型
data class Message(
    val id: Int,
    val content: String,
)

2.2.2 定义RecyclerView.Adapter

paging3提供了PagingDataAdapter,只需提供继承并提供一个DiffUtil.ItemCallback即可。

DiffUtil.ItemCallback是一个高效辅助工具,大家没用过的话先简单查下,很容易理解

PagingDataAdapter & DiffUtil.ItemCallback

class SimpleAdapter : PagingDataAdapter<Message, SimpleViewHolder>(diffCallback) {

    override fun onBindViewHolder(holder: SimpleViewHolder, position: Int) {
        holder.bindTo(getItem(position))
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SimpleViewHolder =
        SimpleViewHolder(parent)

    companion object {
        private val diffCallback = object : DiffUtil.ItemCallback<Message>() {
            override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean =
                oldItem.id == newItem.id

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

class SimpleViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
    LayoutInflater.from(parent.context).inflate(R.layout.item_message_content, parent, false)) {

    private val contentView get() = itemView.findViewById<TextView>(R.id.tvContent)
    private val idView get() = itemView.findViewById<TextView>(R.id.tvId)

    private var msg: Message? = null

    @SuppressLint("SetTextI18n")
    fun bindTo(msg: Message?) {
        this.msg = msg
        contentView.text = msg?.content
        idView.text = "ID:${msg?.id?.toString() ?: "0"}"
    }
}

有没有留意到,在这里的bindTo方法传入的message是可空类型?,这里留到后面placeHolder是一并讲解。

2.2.3 定义数据源 PagingSource<Key : Any, Value : Any>


class SimplePagingSource(
    private val service: FakeService
) : PagingSource<Int, Message>() {


    override fun getRefreshKey(state: PagingState<Int, Message>): Int? {
        return null
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Message> {
        val key = params.key ?: 0

        val nextKey = key + params.loadSize
        val newData = service.fetchData(key.until(nextKey))

        return LoadResult.Page(
            data = newData,
            prevKey = null,
            nextKey = if (nextKey >=100) null else nextKey
        )
    }
}

这里简单理解我们的分页逻辑是,按item的index来请求, 10-20就是请求第10-20个item的意思,最多设置请求100 因此PagingSource<Key : Any, Value : Any>的key就是Int,value则是我们的数据模型Message

  1. getRefreshKey就是当我们请求刷新时的key,这里HelloWorld先返回null
  2. 重点先理解load这个函数: params.key:就是你自行传递的请求下一页的key,首次请求是null值,自行写初始请求key,这里是0;
  3. params.loadSize:就是你一页请求的个数

首次请求时,loadSize是含有预加载个数的,默认是你后面配的pageSize*3)

Clipboard_2023-05-30-11-00-37.png

  1. 分页成功后返回LoadResult.Page:data就是新的分页items,pre/nextkey是关键。 nextKey是常规列表的向下(即手势向上)是否还能加载更多,null表示没有更多,这里设置最多是100个if (nextKey >=100) null else nextKey; preKey则是向上是否能加载更多,比如我们的微信聊天信息界面,一般是列表向上(即手势向下)看看是否有更多的历史聊天信息。

2.2.4 配置pager

现在有了数据源了,接下来就是简单配置PagingConfig,即你每一页要请求多少个数据pageSize,是否预加载等等; 这里我们在viewModel简单配置一页请求10个数据

PagingConfig

val flow = Pager(
        config = PagingConfig(pageSize = 10,initialLoadSize = 30),
        pagingSourceFactory = { SimplePagingSource(FakeService()) }
    )
        .flow
        .cachedIn(viewModelScope)

这里的initialLoadSize就是第一次加载时的个数,默认是你传入的pageSize的三倍。这里写出来是为了让你知道,前面loadParams.loadSize在第一次请求时是==initialLoadSize,后面的请求才==pageSize。设计上是很正确有意义的,可惜在实际开发中得确认后台逻辑是否可以兼容不同pageSize。防止不统一踩坑。

后台的分页逻辑千奇百怪,见怪不怪。。。

2.2.5 监听数据并提交给adapter

这里最后一步就是监听pager的flow并传给我的定义的PagingDataAdapter即可

监听数据并渲染

simpleViewModel.flow
                .collectLatest {
                    adapter.submitData(it)
                }

ok,到这里就完成了简单的helloWorld。首次尝试,会发现步骤比自己写的繁琐些。但用到实际业务,会发现扩展性强大,写起业务场景很流畅。

Clipboard_2023-05-30-11-21-06.png

三 占位符PlaceHolder

3.1 概念相关

还记得前面讲到的,adapter渲染数据时拿到的数据message是可空值么?就是因为他是返回了占位符,数据传null

class SimpleViewHolder(parent: ViewGroup):xxx {
    ...
    @SuppressLint("SetTextI18n")
    fun bindTo(msg: Message?) { //可空
        ...
    }
}

先上两张大佬的动图理解下区别,

先看下未开启占位符的:

disable_placeholder.png

没有开启占位符的情况下,就更常规的加载更多一样,滑到最底部就阻塞等待加载,即列表展示的是当前所有的数据,注意后侧滚动条,当滚动到列表底部,成功加载下一页数据后,滚动条会从长变短,也就是新数据来了,列表变成。 也就是说为开启占位符时,recycleView显示的条目个数==已经获取的数据总个数

开启了占位符之后,

enable_placeholder.png

当用户滑动到了底部尚未加载的数据时,开发者会看到还未渲染的条目。此时adpter在onBindViewHolder获取的数据是null,由我们开发者自行在决定未有真实时item如何渲染! 一般是保持一致高度,这样有真实数据来的时候就不会产生视觉差。

3.2 代码开启占位符功能

代码->PlaceholdersFragment

占位符功能开启相对简单,

  1. viewModel的Pager配置中开启占位符功能enablePlaceholders=true
PagingConfig:enablePlaceholders=true

val flow = Pager(
        //step 1 开启占位符功能:enablePlaceholders = true
        config = PagingConfig(pageSize = 10, enablePlaceholders = true),
        pagingSourceFactory = { PlaceholderPagingSource(FakeService()) }
    )
        .flow
        .cachedIn(viewModelScope)

  1. 在数据源PagingSource中自行决定界面要显示多少个占位符itemsAfter/itemsBefore,这里是向下,所以只需itemsAfter写10个, LoadResult.Page(xxx,itemsAfter = 10)
PagingSource:itemsAfter = 10

return LoadResult.Page(
            data = newData,
            prevKey = null,
            nextKey = if (nextKey >=100) null else nextKey,
            //step 2 插入10个占位符
            itemsAfter = 10
        )

  1. adapter自行兼容item数据为null,即占位符时渲染;
Adapter渲染占位符

//step 3 null时显示content为 "我是占位符"
contentView.text = msg?.content ?: "我是占位符"
idView.text = msg?.id?.let { "ID:$it" } ?: "----"

Clipboard_2023-05-30-16-57-52.png

四 数据变换:过滤、列表分隔符、header,Footer

4.1 功能概述

官方文档:转换数据流 代码->InsertSeparatorsFragment

转换数据流是比较常见的操作,比如将网络Response转成uiModel,过滤,插入分隔符等;paging3开发了少量的操作符允许数据变换:

pager.flow 
  .map { pagingData ->
    pagingData.filter { xxx }
		    .insertSeparators {xxx}
	      ...
  }
	.cachedIn(viewModelScope)

Clipboard_2023-05-31-10-45-47.png

paging库将数据源尽可能隐藏,保证了数据安全不变性,但某些场景却加大了开发者扩展耗时。个人比较喜欢在pagingSource,产生数据源的时候就按场景调整好数据。插入列表符就用paging提供的insertSeparators操作符

过滤和map简单理解就不讲了,讲讲insertSeparators,根据前后两个item插入分隔符,header/footer算是前/后item为null的特殊分隔符。大概效果如:

留意白色item为插入的header,分隔符和footer,每个十个item插入一个分隔符

device-2023-05-31-145050.gif

可以理解为一种多类型支持吧。

4.2 代码实现

核心在于理解insertSeparators能做什么,代码改动很简单,大致跟定义多类型布局一样。

  1. 定义多一个分割符data class以及更新DiffUtil.ItemCallback
分隔符多布局

// step 1.1 定义不同viewType model
data class SeparatorModel(val desc: String)

// step 1.2 定义diffCallback
private val diffCallback = object : DiffUtil.ItemCallback<Any>() {
    override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
        return (oldItem is Message && newItem is Message && oldItem.id == newItem.id)
                || (oldItem is SeparatorModel && newItem is SeparatorModel && oldItem.desc == newItem.desc)
    }

    override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean =
        (oldItem is Message && newItem is Message && oldItem == newItem)
                || (oldItem is SeparatorModel && newItem is SeparatorModel && oldItem == newItem)
}
// step 1.3 定义多类型viewHolder和adapter
class SeparatorViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
    LayoutInflater.from(parent.context).inflate(R.layout.item_message_separator, parent, false)
) {

    private val separatorTv get() = itemView.findViewById<TextView>(R.id.tvSeparator)

    @SuppressLint("SetTextI18n")
    fun bindTo(model: SeparatorModel) {
        separatorTv.text = model.desc
    }
}

  1. 变换数据 对viewModel中pager.flow进行变换
数据变换

val flow = Pager(
        config = PagingConfig(pageSize = 10),
        pagingSourceFactory = { InsertSeparatorPagingSource(FakeService()) }
    )
        .flow
        //step 2 插入header,footer,常规分割符
        .map { pagingData ->
            pagingData.insertSeparators<Message, Any> { before, after ->
                when {
                    //前面item为null,代表插入的是header
                    before == null -> SeparatorModel("我是header,ID:0->9")


                    //后面item为null,代表插入的是footer
                    after == null -> SeparatorModel("我是Footer,Goodbye!!!")

                    //每隔十个插入一个分隔符
                    before.id % 10 != 0 && after.id % 10 == 0 -> SeparatorModel("ID:${after.id}->${after.id + 9}")
                    else -> null
                }
            }


        }.cachedIn(viewModelScope)

是的,这就实现了分隔符功能了。

还记得paging2.x的时候不支持这些转换,插入header和footer得自己去改造adapter,特别麻烦。现在paging3的pagingData已经开放了一些操作符满足了上述的需求。

五 后续

本想一次性写完,发现有点啰嗦,篇幅长了,就将下述的切到第二篇吧。下一份主要着重讲两大块功能:

  1. 状态功能:在正常的业务开发里,完善的界面是有状态的,loading -> success/error -> retry -> refresh。每一个状态对应不同ui展示。paging3是支持状态控制和监听的。

device-2023-06-01-101647.gif

device-2023-06-01-102127.gif

很多开发者偷懒,就简单处理了加载和成功,根本不考虑异常状态,一出现异常各种崩溃还排查不到问题所在,因小失大。

  1. 本地数据库和网络数据结合:paging3提供了remoteMediator(实验性api)和Room扩展类,能快速支持开发者从本地数据库和网络加载分页数据

本文demo已放到git仓库

六 ❤️ 感谢

如果觉得这篇内容对你有所帮忙,一键三连支持下(👍)

关于纠错和建议:欢迎直接在留言分享记录(🌹)