一、前言
Paging3,是Jetpack提供给开发者用来显示本地或者网络数据集的分页库。针对这类场景,传统的做法是用RecyclerView的加载更多来实现分页加载,很多逻辑需要自行处理且不一定完善。Paging3相当于是官网提供的一套解决方案。
下图为您应用的各个层级中推荐直接接入 Paging 的 Android 应用架构
二、添加依赖
根据语言二选一即可,我使用的是kotlin
//java
implementation 'androidx.paging:paging-runtime:3.0.0-alpha09'
//kotlin
implementation 'androidx.paging:paging-runtime-ktx:3.0.0-alpha09'
三、基本使用
基本使用主要包含如下内容:
- 配置数据源:PagingSource
- 构建分页数据:Pager、PagingData
- 构建RecyclerView Adapter:PagingDataAdapter
- 展示分页UI列表数据
- 设置Header和Footer
- 监听数据加载的状态
3.1 配置数据源
abstract class PagingSource<Key : Any, Value : Any>
参数解析:
- Key:分页标识类型,如页码,则为Int
- Value:返回列表元素的类型
3.1.1 Room
如果使用的是Room,从 2.3.0-alpha 开始,它将默认为您实现 PagingSource。在定义 Dao 接口的 Query 语句时,返回类型要使用 PagingSource
类型。同时不需要在 Query 里指定页数和每页展示数量,页数由 PagingSource
来控制,每页数量页在 PagingConfig
中定义。
@Dao
interface UserDao {
@Query("SELECT * FROM User ORDER BY name COLLATE NOCASE ASC")
fun allUserByName(): PagingSource<Int, User>
@Insert
fun insert(user: User)
@Delete
fun delete(user: User)
}
使用 Room 有一个好处是,如果通过 insert() 或 delete() 等方法修改了 Room 里的数据,不需要额外处理就会即时反应在 PagingSource 里,界面上展示的数据会相应变化。
3.1.2 自定义数据源
使用PagingSource
如果不是直接使用 Room 的数据,而是使用源自其他地方的数据,比如网络数据,就需要自定义 PagingSource 了,创建方式如下:
class UserDataSource : PagingSource<Int, User>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
return try {
val page = params.key ?: 0
//获取网络数据
val result = Retrofitance.instance.ApiService.getUsers(page)
LoadResult.Page(
//需要加载的数据
data = result.data,
//如果可以往上加载更多就设置该参数,否则不设置
prevKey = null,
//加载下一页的key 如果传null就说明到底了
nextKey = if (result.curPage == result.pageCount) null else page + 1
)
} catch (e: IOException) {
// IOException for network failures.
return LoadResult.Error(e)
} catch (e: HttpException) {
// HttpException for any non-2xx HTTP status codes.
return LoadResult.Error(e)
}
}
}
参数解释:
- data :返回的数据列表
- prevKey :上一页的key (传 null 表示没有上一页)
- nextKey :下一页的key (传 null 表示没有下一页)
- paging3 使用 flow 传递数据,不了解的可以搜索一下flow ;
- cachedIn:绑定协程生命周期,必须加上,否则可能崩溃;
- asLiveData:熟悉livedata的都知道怎么用;
使用RemoteMediator
RemoteMediator 和 PagingSource 相似,都需要覆盖 load() 方法,但是不同的是 RemoteMediator 不是加载分页数据到 RecyclerView 列表上,而是获取网络分页数据并更新到数据库中。
区别:
PagingSource
:实现单一数据源以及如何从该数据源中查找数据,例如 Room,数据源的变动会直接映射到 UI 上RemoteMediator
:实现加载网络分页数据并更新到数据库中,但是数据源的变动不能直接映射到 UI 上
在项目中如何进行选择?
PagingSource
:用于加载有限的数据集(本地数据库)例如手机通讯录等等。RemoteMediator
:主要用来加载网络分页数据并更新到数据库中,当我们没有更多的数据时,我们向网络请求更多的数据,结合 PagingSource 当保存更多数据时会直接映射到 UI 上
注意:
RemoteMediator 目前是实验性的 API ,所有实现 RemoteMediator 的类都需要添加 @OptIn(ExperimentalPagingApi::class)
注解。
当我们使用 OptIn 注解,需要在 App 模块下的 build.gradle 文件内添加以下代码
android {
kotlinOptions {
freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
}
}
@OptIn(ExperimentalPagingApi::class)
class UserRemoteMediator(
val api: ApiService,
val db: AppDataBase
) : RemoteMediator<Int, User>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, User>
): MediatorResult {
try {
/**
* 在这个方法内将会做三件事
*
* 1. 参数 LoadType 有个三个值,关于这三个值如何进行判断
* LoadType.REFRESH
* LoadType.PREPEND
* LoadType.APPEND
*
* 2. 访问网络数据
*
* 3. 将网路插入到本地数据库中
*/
var pageCount = Int.MAX_VALUE
var curPage = 0
// 第一步: 判断 LoadType
val pageKey = when (loadType) {
// 首次访问 或者调用 PagingDataAdapter.refresh()
LoadType.REFRESH -> null
// 在当前加载的数据集的开头加载数据时
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
// 上拉加载更多时触发
LoadType.APPEND -> return if (curPage == result) MediatorResult.Success(endOfPaginationReached = false) else curPage + 1
}
// 第二步: 请问网络分页数据
val page = pageKey ?: 0
//获取网络数据
val result = Retrofitance.instance.ApiService.getUsers(page)
curPage = result.curPage
pageCount = result.pageCount
val data = result.data
val endOfPaginationReached = data.isEmpty()
// 第三步: 插入数据库
val userDao = db.userDao()
if (!endOfPaginationReached){
userDao.insertUsers(data)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (e: IOException) {
return MediatorResult.Error(e)
} catch (e: HttpException) {
return MediatorResult.Error(e)
}
}
}
LoadType
是一个枚举类,里面定义了三个值,如下所示
类名 | 作用 |
---|---|
LoadType.Refresh | 在初始化刷新的使用,首次访问 或者调用 PagingDataAdapter.refresh() 触发 |
LoadType.Append | 在加载更多的时候使用,需要注意的是当 LoadType.REFRESH 触发了,LoadType.PREPEND 也会触发 |
LoadType.Prepend | 在当前列表头部添加数据的时候使用 |
PagingState
:这个类当中有两个重要的变量
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)
3.2 构建分页数据
分页数据的容器被称为 PagingData
,每次刷新数据时,都会创建一个 PagingData的实例。如果要创建 PagingData
数据流,您需要创建一个 Pager
实例,并提供一个 PagingConfig
配置对象和一个可以告诉 Pager 如何获取您实现的 PagerSource 的实例的函数,以供 Pager 使用。
class PagingViewModel : ViewModel() {
val allUser: Flow<PagingData<User>> = Pager(
PagingConfig(
// 每页显示的数据的大小。对应 PagingSource 里 LoadParams.loadSize
pageSize = 20,
// 预刷新的距离,距离最后一个 item 多远时加载数据,默认为 pageSize
prefetchDistance = 3,
// 初始化加载数量,默认为 pageSize * 3
initialLoadSize = 60,
// 一次应在内存中保存的最大数据,默认为 Int.MAX_VALUE
maxSize = 200
),
remoteMediator = UserRemoteMediator(api, db)
) {
// 数据源,要求返回的是 PagingSource 类型对象
UserDataSource()
}.flow.cachedIn(viewModelScope) // 最后构造的和外部交互对象,有 flow 和 liveData 两种
}
3.3 构建RecyclerView Adapter
为了将 RecyclerView 与 PagingData 联系起来,您需要实现一个 PagingDataAdapter
。adpater 构造必须传参数 DiffUtil.ItemCallback,对DiffUtil.ItemCallback
和AsyncListDiffer
不了解的可以参考一下这篇文章:Android RecyclerView的正确打开方式——DiffUtil、AsyncListDiff以及最新ListAdapter使用
PagingDataAdapter的使用方式与ListAdapter差不多
class UserAdapter : PagingDataAdapter<User, UserAdapter.ViewHolder>(UserDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val itemView: View = LayoutInflater.from(parent.context).inflate(R.layout.item_user_list, parent, false)
return ViewHolder(itemView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private var tvName: TextView? = null
private var tvAge: TextView? = null
init {
tvName = itemView.findViewById(R.id.tv_name)
tvAge = itemView.findViewById(R.id.tv_age)
}
fun bind(user: User) {
tvName?.text = user.name
tvAge?.text = user.age.toString()
}
}
}
private class UserDiffCallback : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.age == newItem.age
}
}
3.4 展示分页列表数据
class PagingActivity : AppCompatActivity() {
private val viewModel by lazy { ViewModelProvider(this).get(PagingViewModel::class.java) }
private val adapter by lazy { UserAdapter() }
private val binding by lazy {
ActivityPagingBinding.inflate(layoutInflater)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.recyclerView.adapter = adapter
lifecycleScope.launch {
viewModel.allUser.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
}
adapter.submitData
是一个协程挂起(suspend)操作,所以要放入协程赋值
3.5 监听数据加载的状态
除了上面的 override fun onBindViewHolder(holder: BindingViewHolder, loadState: LoadState)
会返回数据的加载状态,我们还可以使用监听器
想要监听数据获取的状态在PagingDataAdapter里有两个方法
addDataRefreshListener
这个方法是当新的PagingData被提交并且显示的回调addLoadStateListener
这个相较于上面那个比较复杂,listener中的回调会返回一个CombinedLoadStates对象
data class CombinedLoadStates(
/**
* [LoadStates] corresponding to loads from a [PagingSource].
*/
val source: LoadStates,
/**
* [LoadStates] corresponding to loads from a [RemoteMediator], or `null` if RemoteMediator
* not present.
*/
val mediator: LoadStates? = null
) {
val refresh: LoadState = (mediator ?: source).refresh
val prepend: LoadState = (mediator ?: source).prepend
val append: LoadState = (mediator ?: source).append
...
}
refresh:LoadState
:刷新时的状态,因为可以调用 PagingDataAdapter#refresh() 方法进行数据刷新。append:LoadState
:可以理解为 RecyclerView 向下滑时数据的请求状态。prepend:LoadState
:可以理解为RecyclerView 向上滑时数据的请求状态。source
和mediator
分别包含上面3个的属性,source
代表单一的数据源,mediator
代表多数据源的场景,source 和 mediator 二选一。
adapter.addLoadStateListener {
when (it.refresh) {
is LoadState.Loading -> {}
is LoadState.NotLoading -> {}
is LoadState.Error -> {}
}
}
状态对应的类是 LoadState,它有三种状态:
Loading
:数据加载中。NotLoading
:内存中有已经获取的数据,即使往下滑,Paging 也不需要请求更多的数据。Error
:请求数据时返回了一个错误。
一般来说,对于可下拉刷新或者上拉加载更多的视图,我们都会用第三方框架,这样会更好处理,并且有默认写好的上拉或下拉的样式。以SmartRefreshLayout为例:
private fun onSubscribeUi(binding: ActivityPagingBinding) {
var currentStates:LoadStates? = null
// 数据加载状态的回调
adapter.addLoadStateListener { state:CombinedLoadStates ->
currentStates = state.source
// 如果append没有处于加载状态,但是refreshLayout出于加载状态,refreshLayout停止加载状态
if (state.append is LoadState.NotLoading && binding.refreshLayout.isLoading) {
binding.refreshLayout.finishLoadMore()
}
// 如果refresh没有出于加载状态,但是refreshLayout出于刷新状态,refreshLayout停止刷新
if (state.source.refresh is LoadState.NotLoading && binding.refreshLayout.isRefreshing) {
binding.refreshLayout.finishRefresh()
}
}
binding.refreshLayout.setRefreshHeader(DropBoxHeader(context))
binding.refreshLayout.setRefreshFooter(ClassicsFooter(context))
binding.refreshLayout.setOnLoadMoreListener {
// 如果当前数据已经全部加载完,就不再加载
if(currentStates?.append?.endOfPaginationReached == true)
binding.refreshLayout.finishLoadMoreWithNoMoreData()
}
//... 省略无关代码
}
刷新失败处理
直接调用刷新即可
adapter.refresh()
加载更多失败处理
adapter.retry()
为什么是重试?
因为paging是无缝加载,所以没有手动上拉加载逻辑
retry()虽然是重试,但是paging已处理,只有失败后会重试,所以这里上拉加载调用重试没问题
3.6 设置Header和Footer
除了使用第三方框架,Paging 3.0支持添加 Header 和 Footer,官方示例是把它们用作上拉刷新和下拉加载更多的控件。
3.6.1 Create the view layout
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/error_msg"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/error_text_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
tools:text="Timeout"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
</LinearLayout>
3.6.2 Create the LoadStateAdapter
class UserLoadStateAdapter(private val adapter: UserAdapter) : LoadStateAdapter<UserLoadStateAdapter.BindingViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): BindingViewHolder {
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingViewHolder(binding, adapter)
}
override fun onBindViewHolder(holder: BindingViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
class BindingViewHolder(
private val binding: ItemFooterBinding,
private val adapter: UserAdapter
) : RecyclerView.ViewHolder(binding.root) {
fun bind(loadState: LoadState) {
when (loadState) {
is LoadState.Error -> {
binding.progressBar.visibility = View.GONE
binding.errorMsg.visibility = View.VISIBLE
binding.errorMsg.text = "Load Failed, Tap Retry"
binding.errorMsg.setOnClickListener {
adapter.retry()
}
}
is LoadState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
binding.errorMsg.visibility = View.VISIBLE
binding.errorMsg.text = "Loading"
}
is LoadState.NotLoading -> {
binding.progressBar.visibility = View.GONE
binding.errorMsg.visibility = View.GONE
}
}
}
}
}
binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
UserLoadStateAdapter(adapter),
UserLoadStateAdapter(adapter)
)
方法 | 使用 |
---|---|
fun withLoadStateHeader(header: LoadStateAdapter<*> ) | 头部添加状态适配器 |
fun withLoadStateFooter(footer: LoadStateAdapter<*> ) | 底部添加状态适配器 |
fun withLoadStateHeaderAndFooter(header: LoadStateAdapter<>, footer: LoadStateAdapter<> ) | 头尾都添加状态适配器 |
四、总结
PagingSource
:负责提供源数据,一般是网络请求或者数据库查询,PagingData
:分页数据的容器,负责一些分页的参数设定和订阅源数据流PagingDataAdapter
:跟常规的RecyclerivewAdapter一样把数据转换成UILoadStateAdapter
:可以添加页头/页脚,方便实现loadmore的样式