犹记得首次接触paging时,数据源DataSource,分为positional/ItemKeyed/pageKeyed DataSource,优点是严格把控数据源,减少错误和崩溃,但缺点也是把控太严格,而且很多定制化需求不支持,如header和footer,实现起来太过麻烦,所以抛弃了。 paging3.x目前更新了
pagingSource
,remoteMediator
和insertSeparators
等优秀api,重新定义写法,大大提高了可扩展性。所以重新理解其设计理念和实际应用,向Google工程师学习其优秀的框架设计能力。
Paging3 ǀ 官方分页库拆解与应用🔥🔥🔥
一 前言
Paging是jetpack的一个分页组件,可以使开发者更轻松在 RecyclerView中分页加载来自本地存储或网络端数据。包含一些特性支持:
- 提供了配套的RecyclerView适配器,会在用户滚动到已加载数据的“末端”时自动请求数据「即自动提前向下/向上加载更多」
- 本地数据库Room提供了便捷支持扩展
- 对协程flow,liveData以及rxjava这几个常用的响应式开发api有一流的支持
- 包含错误重试,刷新等功能;
1.1 一些资料
官方资料永远是最正确的,能保证实时更新。目前的remoteMediator还是实验性,后续更新随时都会改变api调用方式。所以本文也只能从整体结构和设计上给大家指指路,具体api得按最新的来。
- 官方文档:Paging库概述
- “Android Paging 基础知识”Codelab
- “Android Paging 高级知识”Codelab
- 优秀的博客「基于旧版本写的,可供参考」:反思|Android 列表分页组件Paging的设计与实现:系统概述
个人习惯是,在开始正式学习前,先找好对应的官方及优秀的第三方资料&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方便理解阅读
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
- getRefreshKey就是当我们请求刷新时的key,这里HelloWorld先返回null
- 重点先理解load这个函数:
params.key
:就是你自行传递的请求下一页的key,首次请求是null值,自行写初始请求key,这里是0; params.loadSize
:就是你一页请求的个数
首次请求时,loadSize是含有预加载个数的,默认是你后面配的pageSize*3)
- 分页成功后返回
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。首次尝试,会发现步骤比自己写的繁琐些。但用到实际业务,会发现扩展性强大,写起业务场景很流畅。
三 占位符PlaceHolder
3.1 概念相关
还记得前面讲到的,adapter渲染数据时拿到的数据message是可空值么?就是因为他是返回了占位符,数据传null
class SimpleViewHolder(parent: ViewGroup):xxx {
...
@SuppressLint("SetTextI18n")
fun bindTo(msg: Message?) { //可空
...
}
}
先上两张大佬的动图理解下区别,
先看下未开启占位符的:
没有开启占位符的情况下,就更常规的加载更多一样,滑到最底部就阻塞等待加载,即列表展示的是当前所有的数据,注意后侧滚动条,当滚动到列表底部,成功加载下一页数据后,滚动条会从长变短,也就是新数据来了,列表变成。 也就是说为开启占位符时,recycleView显示的条目个数==已经获取的数据总个数
开启了占位符之后,
当用户滑动到了底部尚未加载的数据时,开发者会看到还未渲染的条目。此时adpter在onBindViewHolder获取的数据是null
,由我们开发者自行在决定未有真实时item如何渲染! 一般是保持一致高度,这样有真实数据来的时候就不会产生视觉差。
3.2 代码开启占位符功能
占位符功能开启相对简单,
- 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)
- 在数据源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
)
- adapter自行兼容item数据为null,即占位符时渲染;
Adapter渲染占位符
//step 3 null时显示content为 "我是占位符"
contentView.text = msg?.content ?: "我是占位符"
idView.text = msg?.id?.let { "ID:$it" } ?: "----"
四 数据变换:过滤、列表分隔符、header,Footer
4.1 功能概述
转换数据流是比较常见的操作,比如将网络Response转成uiModel,过滤,插入分隔符等;paging3开发了少量的操作符允许数据变换:
pager.flow
.map { pagingData ->
pagingData.filter { xxx }
.insertSeparators {xxx}
...
}
.cachedIn(viewModelScope)
paging库将数据源尽可能隐藏,保证了数据安全不变性,但某些场景却加大了开发者扩展耗时。个人比较喜欢在
pagingSource
,产生数据源的时候就按场景调整好数据。插入列表符就用paging提供的insertSeparators
操作符
过滤和map简单理解就不讲了,讲讲insertSeparators
,根据前后两个item插入分隔符,header/footer
算是前/后item为null的特殊分隔符。大概效果如:
留意白色item为插入的header,分隔符和footer,每个十个item插入一个分隔符
可以理解为一种多类型支持吧。
4.2 代码实现
核心在于理解insertSeparators
能做什么,代码改动很简单,大致跟定义多类型布局一样。
- 定义多一个分割符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
}
}
- 变换数据 对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已经开放了一些操作符满足了上述的需求。
五 后续
本想一次性写完,发现有点啰嗦,篇幅长了,就将下述的切到第二篇吧。下一份主要着重讲两大块功能:
- 状态功能:在正常的业务开发里,完善的界面是有状态的,loading -> success/error -> retry -> refresh。每一个状态对应不同ui展示。paging3是支持状态控制和监听的。
很多开发者偷懒,就简单处理了加载和成功,根本不考虑异常状态,一出现异常各种崩溃还排查不到问题所在,因小失大。
- 本地数据库和网络数据结合:paging3提供了
remoteMediator(实验性api)
和Room扩展类,能快速支持开发者从本地数据库和网络加载分页数据
本文demo已放到git仓库
六 ❤️ 感谢
如果觉得这篇内容对你有所帮忙,一键三连支持下(👍)
关于纠错和建议:欢迎直接在留言分享记录(🌹)