Jetpack-Paging使用教程
一、Paging是什么?
Paging
是一个分页库,Paging
可以帮助我们优雅地渐进加载大型数据集合,同时也可以减少网络的使用和系统资源的消耗。
-
在内存中缓存分页数据,确保您的 App 在使用分页数据时有效地使用系统资源。
-
内置删除重复数据的请求,确保您的 App 有效地使用网络带宽和系统资源。
-
可配置当用户滚动到加载数据的末尾时自动请求数据。
1.1. Paging版本
截止到目前2021-01-17 Paging
库现在分为两个版本,其中Paging 2
版本为稳定版,Paging 3
为Alpha
版。
Paging 2
虽然是稳定版但是使用起来还是会有问题,如:
-
分页失败就不会在进行分页了;
-
不支持
Header
和Footer
;
而在 Paging 3
中很多 API 的使用方式变了,也增加了很多功能,如:
-
支持
Kotlin
协程和Flow
。 -
支持
Header
和Footer
。 -
增加请求数据时状态的回调
-
内置的错误处理支持,包括刷新和重试等功能。
1. 2. 添加Paging库依赖
使用Paging 2
,请在应用或模块的 build.gradle
文件中添加以下依赖
dependencies {
def paging_version = "2.1.2"
implementation "androidx.paging:paging-runtime:$paging_version" // For Kotlin use paging-runtime-ktx
// alternatively - without Android dependencies for testing
testImplementation "androidx.paging:paging-common:$paging_version" // For Kotlin use paging-common-ktx
// optional - RxJava support
implementation "androidx.paging:paging-rxjava2:$paging_version" // For Kotlin use paging-rxjava2-ktx
}
如需使用 Paging 3
,请将以下依赖项添加到 build.gradle
文件中:
dependencies {
def paging_version = "3.0.0-alpha09"
implementation "androidx.paging:paging-runtime:$paging_version"
// alternatively - without Android dependencies for tests
testImplementation "androidx.paging:paging-common:$paging_version"
// optional - RxJava2 support
implementation "androidx.paging:paging-rxjava2:$paging_version"
// optional - Guava ListenableFuture support
implementation "androidx.paging:paging-guava:$paging_version"
// Jetpack Compose Integration
implementation "androidx.paging:paging-compose:1.0.0-alpha02"
}
详情可前往官方Paing依赖声明
二、 Paging 2的使用
我们先看一下Paging 2
数据流工作原理图:
-
DataSource
:数据源提供者,数据的改变会驱动列表的更新。 -
PageList
:核心类,它从数据源取出数据,同时,它负责页面初始化数据+分页数据什么时候加载,以何种方式加载。 -
PagedListAdapter
:是RecyclerView.Adapter
的实现类,从PagedList
过来的数据,通过DiffUtil
差分异定向更新列表数据。
2.1. DataSource
顾名思义就是数据来源,作用就是提供加载所需的数据,数据源可以是客户端本地数据库也可以是服务器。需要通过DataSource.Factory
工厂类创建。DataSource<Key, Value>
Key
对应数据加载的条件,Value
就是数据源的实体类。Paging 2
的设计者提供了三种不同类型的DataSource
抽象类:
-
PositionalDataSource<T>
:适用于数据容量固定,要通过特定的位置加载数据。比如从某个位置开始的 100 条数据; -
ItemKeyedDataSource<Key, Value>
:适用于目标数据的加载依赖特定item
的信息, 即Key
字段包含的是Item
中的信息,比如需要根据第N
项的信息加载第N+1
项的数据,传参中需要传入第N
项的ID
时。之前钱包记账就有请求下一页内容时需要传入当前页最后一笔账单的信息。 -
PageKeyedDataSource<Key, Value>
:和ItemKeyedDataSource
类似,适用于以页信息加载数据的场景。比如在网络加载数据的时候,需要通过setNextKey()
和setPreviousKey()
方法设置下一页和上一页的标识Key
。
2.1.1. PositionalDataSource
需要重写loadInitial
和loadRange
方法
class PositionDataSource: PositionalDataSource<Cheese>() {
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<Cheese>) {
callback.onResult(
data = loadData(params.requestedStartPosition, params.pageSize),
position = 0,
totalCount = 400
)
}
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<Cheese>) {
callback.onResult(
data = loadData(params.startPosition, params.loadSize)
)
}
fun loadData(itemKey: Int, pageSize: Int): List<Cheese> {
val data = ArrayList<Cheese>()
if (itemKey >= CHEESE_DATA.size - 1) {
return data
}
var end = itemKey + pageSize
if (end > CHEESE_DATA.size) {
end = CHEESE_DATA.size
}
for (i in itemKey until end) {
val cheese = Cheese(i, CHEESE_DATA[i])
data.add(cheese)
}
return data
}
}
-
loadInitial
:加载初始列表数据。在用DataSource
构建PageList
的时候才会调用一次。 -
LoadInitialParams
:初始加载的参数,包括请求的起始位置,加载大小和页面大小。与PageList.Config
设置有关 -
LoadInitialCallback
:接收初始负载数据(包括位置和数据集总大小)的回调。 -
loadRange
:加载其他页面列表数据。类似LoadMore
,在每次RecyclerView
滑动到底部没有数据的时候就会调用此方法进行数据的加载。 -
LoadRangeParams
:初始加载的参数,包括请求的起始位置,加载大小和页面大小 -
LoadRangeCallback
:接收已加载数据的回调。
2.1.2. ItemKeyedDataSource
需要重写getKey
、loadInitial
、loadBefore
和loadAfter
四个方法
class TextItemKeyDataSource() : ItemKeyedDataSource<Int, Cheese>() {
override fun getKey(item: Cheese): Int {
return item.id
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Cheese>) {
callback.onResult(loadData(params.key, params.requestedLoadSize))
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Cheese>) {
}
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Cheese>
) {
val data = loadData(0, params.requestedLoadSize)
callback.onResult(data)
}
}
-
getKey
:返回下一个loadAfter
调用所需要用到的key
。 -
loadInitial
:加载初始列表数据。 -
loadAfter
:在每次RecyclerView
滑动到底部PagedList
中没有数据的时候就会调用此方法进行数据的加载。 -
loadBefore
:在每次RecyclerView
滑动到顶部PagedList
没有数据的时候就会调用此方法进行数据的加载。
2.1.3. PageKeyedDataSource
需要重写loadInitial
、loadBefore
和loadAfter
三个方法
class TextPageKeyDataSource : PageKeyedDataSource<Int, Cheese>() {
private var pageKey: Int = 0
private var pageSize: Int = 10
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Cheese>) {
callback.onResult(loadData(params.key, pageSize), params.key + 1)
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Cheese>) {
}
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Int, Cheese>
) {
callback.onResult(
data = loadData(pageKey, pageSize),
previousPageKey = null,
nextPageKey = pageKey + 1
)
}
fun loadData(pageNumber: Int, pageSize: Int): List<Cheese> {
val data = ArrayList<Cheese>()
var startPosition = pageNumber * pageSize
if (startPosition > CHEESE_DATA.size) {
return data
}
var endPosition = (pageNumber + 1) * pageSize
if (endPosition > CHEESE_DATA.size) {
endPosition = CHEESE_DATA.size
}
for (i in startPosition until endPosition) {
val cheese = Cheese(i, CHEESE_DATA[i])
data.add(cheese)
}
return data
}
}
-
loadInitial
:加载初始列表数据。 -
loadAfter
:在每次RecyclerView
滑动到底部PagedList
没有数据的时候就会调用此方法进行数据的加载。 -
loadBefore
:在每次RecyclerView
滑动到顶部PagedList
没有数据的时候就会调用此方法进行数据的加载。
2.2. DataSource.Factory
这个接口的实现类主要是用来获取我们上面实现的DataSource
。
class MyDataSourceFactory() : DataSource.Factory<Int, Cheese>() {
override fun create(): DataSource<Int, Cheese> {
return TextPositionDataSource()
}
}
2.3. PageList
PagedList<T>
是一种List
,用来存储加载的数据。其中的所有数据均从DataSource
中加载。
2.4. PageList.Config
配置PagedList
如何从DataSource
中加载内容。不同的DataSource
设置PageList.Config
的效果也有差异
val pagedListConfig = PagedList.Config.Builder()
.setPageSize(10)
.setPrefetchDistance(5)
.setEnablePlaceholders(true)
.setInitialLoadSizeHint(20)
.setMaxSize(100)
.build()
-
pageSize
: 每一页的数据量。 -
prefetchDistance
: 预取数据的距离,也就是距离最后一个item
多远时开始加载下一页数据,默认是一页的数据量pageSize
。 -
enablePlaceholders
:定义PagedList
是否可以显示空的占位符(如果DataSource
提供的话) -
initialLoadSize
:初始化加载的数量,默认为pagesize * 3
。 -
maxsize
:保留在内存中的最大项目数,防止由于预取而导致连续读取和丢弃负载。比如:在PositionalDataSource
中maxsize=100
我们加载超过100个,倒回去第一页,会重新执行loadRange()
。
**注意:**默认情况下
PagingConfig.maxSize
是无界的,因此永远不会删除页面。如果确实要删除页面,请确保保持maxSize
足够高的数量,以免用户更改滚动方向时不会导致太多网络请求。最小值为pageSize + prefetchDistance * 2
。
2.5. LivePagedListBuilder
LivePagedListBuilder
是用于获取PagedList
类型的LiveData
对象的类,也就是LiveData<PagedList<T>>
的生成器。沟通DataSource.Factory
、PageList
、PageList.Config
三者的桥梁,将数据源工厂和相关配置统一交给PagedListBuilder
,即可生成对应的LiveData<PagedList<T>>
,为 PagedListAdapter
提供所需要的 PagedList
。
val data :LiveData<PagedList> = LivePagedListBuilder(mFactory, pagedListConfig).build()
如果需要构造Observable<PagedList>
或者Flowable<PagedList>
在RxJava
中使用请使用RxPagedListBuilder
2.6.PagedListAdapter
PagedListAdapter
和RecyclerView.Adapter
差别不大,比起普通的Adapter
要额外提供一个 DiffUtil.ItemCallback
实例用于数据比较更新列表。
class BillRecyclerViewAdapter :
PagedListAdapter<Cheese, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Cheese>() {
override fun areItemsTheSame(oldItem: Cheese, newItem: Cheese): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Cheese, newItem: Cheese): Boolean =
oldItem == newItem
}
}
}
-
areItemsTheSame
:比对新旧item
是否是同一个item
;一般比对item
的唯一标识id
即可,如果item
不同则可能不会更新UI
; -
areContentsTheSame
:当areItemsTheSame
确定是同一个item
之后,该方法会被调用。该方法比对item
的内容是否一样(数据是否相同),不一样则会更新UI;建议这里的比对把UI展示的数据都写上,写漏了会导致UI不更新对应字段;
Paging
组件支持三种不同数据结构:
-
仅从网络获取
-
仅从设备数据库获取
-
两种数据来源的组合,使用设备数据库作为缓存,注意是数据库作为缓存而不是即从数据库获取又从网络获取。
在单一数据源情况下,我们使用Paging 2
进行分页加载的整体架构设计一般是如下图:
如果时多层数据来源,需要使用BoundaryCallback
实现监听数据边界,实现本地无数据时请求网络数据。
2.7. PagedList.BoundaryCallback
监听数据边界,在PagedList
到达可用数据的末尾时发出信号。当本地存储是网络数据的缓存时,当本地缓存全部被加载完后,需要去触发网络加载新的数据。
abstract class BoundaryCallback<T : Any> {
open fun onZeroItemsLoaded() {}
open fun onItemAtFrontLoaded(itemAtFront: T) {}
open fun onItemAtEndLoaded(itemAtEnd: T) {}
}
onZeroItemsLoaded
:从PagedList
的数据源的初始加载返回零项时调用。onItemAtFrontLoaded
:当PagedList
前面存在加载项目的时候会被调用,在此方法之前,不会有更多数据添加到PagedList
。PositionalDataSource
:loadInitial()
之后会调用一次ItemKeyedDataSource
:没有见到有触发PageKeyedDataSource
:loadInitial()
之后会调用一次,超过PagedList.Config
中设置的maxSize
就会触发
onItemAtEndLoaded
:当PagedList
末尾的项目被加载时会被调用,此方法之后,将不再有更多数据附加到PagedList
。PositionalDataSource
中到达LoadInitialCallback
中设置的totalCount
触发ItemKeyedDataSource
、PageKeyedDataSource
数据加载完了就会触发
三、Paging 3的使用
Paging 3
相对于Paging 2
改动可以说很大,再来看一下Paging 3
数据加载流程,如下图:
-
PagingSource
:单一数据源。 -
RemoteMediator
:其实RemoteMediator
也是单一的数据源,它会在PagingSource
没有数据的时候,再使用RemoteMediator
提供的数据,如果既存在数据库请求,又存在网络请求,通常PagingSource
用于进行数据库请求,RemoteMediator
进行网络请求。 -
Pager
:用于构建Flow
提供给PagingDataAdapter
需要的PagingData
-
PagingData
:数据列表,相当于Paging 2
中的PageList
。Paging 3
中使用PagingData
替换Paging 2
中的PageList
-
PagingConfig
:配置PagedData
如何从其PagingSource
加载内容。Paging 3
中使用PagingConfig
替换Paging 2
中的PagedList.Config
。
2.1. PagingSource
使用的话需要继承该类并实现 load
方法来加载数据,根据加载情况返回LoadResult.Page
或LoadResult.Error
。
Paging 2
中的三种数据源ItemKeyedDataSource
、PageKeyedDataSource
、PositionalDataSource
合并为一个PagingSource
。
class SingleDataSource : PagingSource<Int, Cheese>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cheese> {
val position = params.key ?: 0
val dataList = loadData(position, params.loadSize)
return try {
LoadResult.Page(
data = dataList,
prevKey = if (position == 0) null else position - 1,
nextKey = if (position == 0) position + 4 else if (dataList.isEmpty()) null else position + 1
)
} catch (exception: Exception) {
return LoadResult.Error(exception)
}
}
private fun loadData(pageNumber: Int, pageSize: Int): List<Cheese> {
val data = ArrayList<Cheese>()
val startPosition = pageNumber * pageSize
if (startPosition > CHEESE_DATA.size) {
return data
}
var endPosition = (pageNumber + 1) * pageSize
if (endPosition > CHEESE_DATA.size) {
endPosition = CHEESE_DATA.size
}
for (i in startPosition until endPosition) {
val cheese = Cheese(i, CHEESE_DATA[i])
data.add(cheese)
}
return data
}
}
-
LoadParams
:LoadParams.key
要加载的页面的key
,params.loadSize
要加载的页面的数据大小 -
LoadResult.Page
:PagingSource.load
加载数据成功时调用。 -
LoadResult.Error
:PagingSource.load
加载数据失败时调用。失败将作为LoadState.Error
转发到UI
,并且可以重试。
2.2. Pager
Pager
主要用于构建 flow
类型的PagingData
对象提供给PagingDataAdapter
使用 。
Paging 2
中的LivePagedListBuilder
和RxPagedListBuilder
合并为了Pager
。
在其构造方法中接受 PagingConfig
、initialKey
、remoteMediator
、pagingSourceFactory
,代码如下所示::
class Pager<Key : Any, Value : Any>
@JvmOverloads constructor(
config: PagingConfig,
initialKey: Key? = null,
@OptIn(ExperimentalPagingApi::class)
remoteMediator: RemoteMediator<Key, Value>? = null,
pagingSourceFactory: () -> PagingSource<Key, Value>
)
initialKey
和remoteMediator
是可选参数,默认为null
。remoteMediator
上面我们讲到了,是在多层数据源下负责网络加载数据的。如果是单一数据源只需要使用PagingSource
就行。基本使用方式如下:
val pager : Flow<PagingData<Cheese>> = Pager(pagingConfig, pagingSourceFactory = factory).flow
-
Kotlin-Flow
使用Pager.flow
。 -
LiveData
使用Pager.liveData
。 -
RxJava-Flowable
使用Pager.flowable
。 -
RxJava-Observable
使用Pager.observable
。
2.3. PagingConfig
主要配置一些基本的分页信息,其中部分信息例如页码、需要加载size
等信息,会在PagingSource
的load
方法中通过 LoadParams
传递过来。与paging 2
中的一致。
val pagingConfig = PagingConfig(
pageSize = 10,
prefetchDistance = 5,
enablePlaceholders = true,
initialLoadSize = 40,
maxSize = 100
)
2.4. PagingDataAdapter
和paging 2
中的PagedListAdapter
一样要额外提供一个DiffUtil.ItemCallback
实例用于数据比较更新列表,但是比PagedListAdapter
多增加了一些API。
2.4.1 监听数据加载状态
PagedListAdapter
提供了loadStateFlow
和addLoadStateListener
两种方法,方便我们知道数据的加载状态,从而展示页面是在加载中还是加载失败等UI。
-
PagedListAdapter.loadStateFlow
返回的是个Flow<CombinedLoadStates>
。 -
PagedListAdapter.addLoadStateListener
方法回调给我们的是CombinedLoadStates
。
CombinedLoadStates
允许我们获取3种不同类型的加载操作的加载状态:
变量 | 作用 |
---|---|
refresh | 在初始化或者刷新的时候使用 |
append | 在当前列表末尾添加数据的时候使用 |
prepend | 在当前列表头部添加数据的时候使用 |
其中
CombinedLoadStates
来源是有区分的,区分来自PagingSource
和RemoteMediator
。
LoadState
也有三种状态 NotLoading
、 Loading
、 Error
代表网络请求状态。
变量 | 作用 |
---|---|
Error | 表示加载失败 |
Loading | 表示正在加载 |
NotLoading | 表示当前未加载 |
比如为我们的数据加载增加监听数据加载失败的情况:
lifecycleScope.launch {
mAdapter.loadStateFlow.collectLatest {
if (loadStates.append is LoadState.Error) {
val errInfo = loadStates.refresh as LoadState.Error
Toast.makeText(
this,
"\uD83D\uDE28 Wooops ${errInfo.error}",
Toast.LENGTH_LONG
).show()
}
}
}
mAdapter.addLoadStateListener { loadStates ->
if (loadStates.append is LoadState.Error) {
val errInfo = loadStates.refresh as LoadState.Error
Toast.makeText(
this,
"\uD83D\uDE28 Wooops ${errInfo.error}",
Toast.LENGTH_LONG
).show()
}
}
2.4.2 添加Header和Footer
Paging 3
支持添加Header
和Footer
。为此PagingDataAdapter
有3种可用的方法:
方法名 | 作用 |
---|---|
withLoadStateFooter | 添加到列表底部(类似于加载更多) |
withLoadStateHeader | 添加到列表的头部 |
withLoadStateHeaderAndFooter | 添加到头部和底部 |
他们接收的参数都是LoadStateAdapter
,所以我们的Header
和Footer
需要实现LoadStateAdapter
。
// mItemRecyclerView.adapter = mAdapter
// 添加Header和Footer时,设置adapter使用下面方法
mItemRecyclerView.adapter = mAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { mAdapter.retry() },
footer = ReposLoadStateAdapter { mAdapter.retry() }
)
2.4.3. 刷新和重试
-
refresh
:会回到初始化页,常用于下拉更新数据。 -
retry
:常用于底部更多样式,当请求网络失败的时候,显示重试按钮,点击调用retry
。
2.5. RemoteMediator
用于将数据从远程源增量加载到由PagingSource
包裹的本地源中,默认是先走PageSource
的数据,当 PageSource
提供的数据返回为空的情况,才会走 RemoteMediator
的数据。所以我们一般的使用都是PageSource
进行数据库请求提供数据,RemoteMediator
进行网络请求,然后将请求成功的数据存入数据库。
RemoteMediator 和 PagingSource 的区别
-
RemoteMediator
:实现加载网络分页数据并更新到数据库中,但是数据源的变动不能直接映射到 UI 上 -
PagingSource
:实现单一数据源以及如何从该数据源中查找数据,数据源的变动会直接映射到 UI 上
注意:
RemoteMediator
API目前处于实验阶段。所有实现的类RemoteMediator
都应使用注释@OptIn(ExperimentalPagingApi::class)
。
当使用此OptIn
批注,请确保您的应用程序的build.gradle
文件,你必须freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
在你的kotlinOptions
。
@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
private val query: String,
private val database: RoomDb,
private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
val userDao = database.userDao()
val remoteKeyDao = database.remoteKeyDao()
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, User>
): MediatorResult {
return try {
val loadKey = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val remoteKey = database.withTransaction {
remoteKeyDao.remoteKeyByQuery(query)
}
if (remoteKey.nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
remoteKey.nextKey
}
}
val response = networkService.searchUsers(query, loadKey)
database.withTransaction {
if (loadType == LoadType.REFRESH) {
remoteKeyDao.deleteByQuery(query)
userDao.deleteByQuery(query)
}
remoteKeyDao.insertOrReplace(RemoteKey(query, response.nextKey))
userDao.insertAll(response.users)
}
MediatorResult.Success(endOfPaginationReached = response.nextKey == null)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
-
LoadType
-这告诉我们是需要将数据加载到PagingData
的末尾 (LoadType.APPEND
) ,还是在PagingData
的开头(LoadType.PREPEND
)加载数据,还是我们是第一次加载数据(LoadType.REFRESH
)。 -
PagingState
-这为我们提供了有关之前加载的页面(pages),列表中最近访问的索引(anchorPosition)以及PagingConfig
。pages: List<Page<Key, Value>>
返回的上一页的数据,主要用来获取上一页最后一条数据作为下一页的开始位置config: PagingConfig
返回的初始化设置的PagingConfig
包含了pageSize
、prefetchDistance
、initialLoadSize
等
load()
的返回值 MediatorResult
,MediatorResult
是一个密封类,根据不同的结果返回不同的值
-
请求出现错误,返回
MediatorResult.Error(e)
-
请求成功且后面还有有数据,返回
MediatorResult.Success(endOfPaginationReached = true)
-
请求成功但是后面没有数据了,返回
MediatorResult.Success(endOfPaginationReached = false)
在 load()
方法里面将会做三件事
- 判断参数 LoadType
- 请求网络数据
- 将网络数据插入到本地数据库中
总结
-
PagingSource
负责异步加载自定义源中的数据。 -
通过
Pager.flow
创建了一个Flow<PagingData>
,Flow
提供PagingData
给PagingDataAdapter
使用。而Pager
基于PagingConfig
和PagingSource
实例化。 -
数据加载失败,可以使用
PagingDataAdapter.retry
方法进行重试。它会触发PagingSource.load()
方法。另外刷新可以使用PagingDataAdapter.refresh
方法。 -
要将加载状态显示为
header
或footer
,使用PagingDataAdapter.withLoadStateHeaderAndFooter()
方法并实现LoadStateAdapter
。 -
如果需要知道数据加载状态做对应的操作,使用
PagingDataAdapter.addLoadStateListener()
回调。 -
要同时使用网络和数据库,需要实现
RemoteMediator
。
参加文档:
https://developer.android.com/codelabs/android-paging
https://www.bilibili.com/video/BV1Wb411P7CR?from=search&seid=16871215559318514007