Paging介绍
Paging是Google 2018IO大会最新发布的Jetpack中的一个组件,主要用于大数据的分页加载,这篇文章就来探索一下关于Paging的简单使用。
Paging它是什么,怎么用?
一句话概述: Paging 可以使开发者更轻松在 RecyclerView 中 分页加载数据。
分页效果实现方式
在使用之前,我们需要搞明白的是,目前Android设备中比较主流的两种 分页模式,用我的语言概述,大概是:
- 传统的 上拉加载更多 分页效果
- 无限滚动分页效果(当滑动了一定量的数据时,会自动请求加载下一页的数据)
举个例子,传统的 上拉加载更多分页效果,滑到底部,再上拉显示footer,才会加载数据。而无限滚动分页效果,如果我们慢慢滑动,当滑动了一定量的数据(这个阈值一般是数据总数的某个百分比)时,会自动请求加载下一页的数据,如果我们继续滑动,到达一定量的数据时,它会继续加载下一页数据,直到加载完所有数据——在用户看来,就好像是一次就加载出所有数据。
很明显,无限滚动 分页效果带来的用户体验更好,不仅是京东,包括 知乎 等其它APP,所采用的分页加载方式都是 无限滚动 的模式,而 Paging 也正是以无限滚动 的分页模式而设计的库。
特点
- 每一页的数据会缓存至内存中,以此保证处理分页数据时更有效的使用系统资源
- 同时多个相同的请求只会触发一个,确保App有效的使用网络资源和系统资源
- 可以配置RecyclerView的adapters,让其滑动到末尾自动发起请求
- 支持Kotlin协程、Flow、LiveData以及RxJava
- 内置错误处理支持,如刷新和重试功能。
逻辑图
Paging3依赖
def paging_version = "3.0.0-alpha03"
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
// alternatively - without Android dependencies for tests
testImplementation "androidx.paging:paging-common-ktx:$paging_version"
// optional - RxJava2 support
implementation "androidx.paging:paging-rxjava2-ktx:$paging_version"
// optional - Guava ListenableFuture support
implementation "androidx.paging:paging-guava:$paging_version"
Paging基本使用
基本使用主要包含如下内容:
- 配置数据源:PagingSource
- 构建分页数据:Pager、PagingData
- 构建RecyclerView
- Adapter:PagingDataAdapter
- 展示分页UI列表数据
- 加载状态
- 预取阈值
- 支持RxJava
Paging的使用案例
通过一个案例来学习Paging的使用:请求网络数据,分页加载。
案例使用的接口:
https://www.wanandroid.com/article/list/1/json
ApiService
interface ApiService {
/**
* https://www.wanandroid.com/article/list/1/json
*/
@GET("article/list/{pageNum}/json")
suspend fun getListData(@Path("pageNum") pageNum: Int): BaseResp<ArticleBean>
}
BaseResp
class BaseResp<T>(val data: T, val errorCode: Int, val errorMsg: String)
ArticleBean
data class ArticleBean(
val curPage: Int,
val size: Int,
val total: Int,
val pageCount:Int,
val datas: List<ArticleItemBean>
)
data class ArticleItemBean(
val title: String,
val id:Int
)
RetrofitClient
class RetrofitClient {
// 单例实现
companion object {
val instance: RetrofitClient by lazy { RetrofitClient() }
}
private val retrofit: Retrofit
private val interceptor: Interceptor
init {
//通用拦截
interceptor = Interceptor { chain ->
val request = chain.request()
.newBuilder()
.addHeader("Content_Type", "application/json")
.addHeader("charset", "UTF-8")
.addHeader("token", "")
.build()
chain.proceed(request)
}
//Retrofit实例化
retrofit = Retrofit.Builder()
.baseUrl("https://www.wanandroid.com/")
.addConverterFactory(GsonConverterFactory.create())
//.client(initClient())
.build()
}
/**
* OKHttp创建
*/
private fun initClient(): OkHttpClient? {
return OkHttpClient.Builder()
.addInterceptor(interceptor)
.addInterceptor(initLogInterceptor())
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
}
/**
* 日志拦截器
*/
private fun initLogInterceptor(): Interceptor? {
val interceptor = HttpLoggingInterceptor()
interceptor.level = HttpLoggingInterceptor.Level.BODY
return interceptor
}
/**
* 具体服务实例化
*/
fun <T> createApiService(service: Class<T>): T {
return retrofit.create(service)
}
}
配置数据源:PagingSource
/**
* Cerated by xiaoyehai
* Create date : 2020/11/19 14:36
* description : 配置数据源数据源,获取数据是通过DataSource实现的。
*/
//PagingSource的两个泛型参数分别是表示当前请求第几页的Int,以及请求的数据类型
class MainDataSource() : PagingSource<Int, ArticleItemBean>() {
//实现这个方法来触发异步加载(例如从数据库或网络)。 这是一个suspend挂起函数,可以很方便的使用协程异步加载
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ArticleItemBean> {
try {
//param.key为空时,默认加载第1页数据。
val currentPage = params.key ?: 0
//获取网络数据
val response = RetrofitClient.instance.createApiService(ApiService::class.java)
.getListData(currentPage)
//请求成功使用LoadResult.Page返回分页数据,prevKey 和 nextKey 分别代表前一页和后一页的索引。
return LoadResult.Page(
//加载的数据
data = response.data.datas,
//上一页,如果有上一页设置该参数,否则不设置
prevKey = if (currentPage == 0) null else currentPage - 1,
//加载下一页的key 如果传null就说明到底了
nextKey = if (response.data.curPage == response.data.pageCount) null else currentPage + 1
)
} catch (e: Exception) {
//请求失败使用LoadResult.Error返回错误状态
return LoadResult.Error(e)
}
}
}
- 继承PagingSource,需要两个泛型,第一个表示下一页数据的加载方式,比如使用页码加载可以传Int,使用最后一条数据的某个属性来加载下一页就传别的类型比如String等。
- 实现其load方法来触发异步加载,可以看到它是一个用suspend修饰的挂起函数,可以很方便的使用协程异步加载。
- 其参数LoadParams中有一个key值,我们可以拿出来用于加载下一页。
- 返回值是一个LoadResult,出现异常调用LoadResult.Error(e),正常强开情况下调用LoadResult.Page方法来设置从网络或者数据库获取到的数据。
- prevKey 和 nextKey 分别代表下次向上加载或者向下加载的时候需要提供的加载因子,比如我们通过page的不断增加来加载每一页的数据,nextKey就可以传入下一页page+1。如果设置为null的话说明没有数据了。
MainRepository:数据仓库层Repository
Repository层主要使用PagingSource这个分页组件来实现,每个PagingSource对象都对应一个数据源,以及该如何从该数据源中查找数据。PagingSource可以从任何单个数据源比如网络或者数据库中查找数据。
Repository层还有另一个分页组件可以使用RemoteMediator,它是一个分层数据源,比如有本地数据库缓存的网络数据源。
class MainRepository {
fun getArticleData() =
Pager(PagingConfig(pageSize = 20, prefetchDistance = 10)) { MainDataSource() }.flow
}
代码虽少不过有两个重要的对象:Pager 和 PagingData
- Pager是进入分页的主要入口,它需要4个参数:PagingConfig、Key、RemoteMediator、PagingSource其中第一个和第四个是必填的。
- PagingConfig用来配置加载的时候的一些属性,比如多少条算一页,距离底部多远的时候开始加载下一页,初始加载的条数等等。
- PagingData 用来存储每次分页数据获取的结果 。
- flow是kotlin的异步数据流,点类似 RxJava 的 Observable。
构建分页数据:ViewModel
Repository最终返回一个异步流包裹的PagingDataFlow<PagingData>,PagingData存储了数据结果,最终可以使用它将数据跟UI界面关联。
class MainViewModel(application: Application) : AndroidViewModel(application) {
//抽到MainRepository实现
// val listData = Pager(PagingConfig(pageSize = 20)) { MainDataSource() }.flow.cachedIn(viewModelScope)
private val repository:MainRepository by lazy { MainRepository() }
/**
* Pager 分页入口 每个PagingData代表一页数据
*/
fun getArticleData() = repository.getArticleData().cachedIn(viewModelScope)
/**
* mainListAdapter
*/
fun getArticleData2() = repository.getArticleData().asLiveData()
}
MainActivity:展示分页列表数据
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding =
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
val viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
binding.rv.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
val mainListAdapter = MainListAdapter()
binding.rv.adapter = mainListAdapter
lifecycleScope.launch {
viewModel.getArticleData().collectLatest {
mainListAdapter.submitData(it)
}
}
/* viewModel.getArticleData2().observe(this, Observer {
lifecycleScope.launchWhenCreated {
mainListAdapter.submitData(it)
}
})*/
}
}
调用adapter的submitData方法来触发页面的渲染。这个方法是一个suspend修饰的挂起方法,所以将它放到一个有生命周期的协程中调用。如果不想放到协程中可以调用另外一个两个参数的方法adapter.submitData(lifecycle,it)传入lifecycle就行了。
MainListAdapter:构建Adapter
Adapter的创建跟Paging2.x写法差不多,不过继承的类不一样了,Paging2.x继承的是PagedListAdapter,在3.0中PagedListAdapter已经没有了,需要继承PagingDataAdapter。
写法跟写正常的RecyclerView.Adapter基本一样,就加了一样东西,需要在构造方法里传入一个DiffUtil.ItemCallback用来确定差量更新的时候的计算规则。
class MainListAdapter : PagingDataAdapter<ArticleItemBean, MainListAdapter.BindingViewHolder>(DataDifferntiator) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {
val binding = ItemRvBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingViewHolder(binding)
}
override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {
val binding = holder.binding
binding.listBean = getItem(position)
binding.executePendingBindings()
}
class BindingViewHolder(val binding: ItemRvBinding) : RecyclerView.ViewHolder(binding.root)
object DataDifferntiator : DiffUtil.ItemCallback<ArticleItemBean>() {
override fun areItemsTheSame(oldItem: ArticleItemBean, newItem: ArticleItemBean): Boolean {
return oldItem.id== newItem.id
}
override fun areContentsTheSame(oldItem: ArticleItemBean, newItem: ArticleItemBean): Boolean {
return oldItem == newItem
}
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbarSize="10dp"
android:scrollbars="vertical"
tools:listitem="@layout/item_rv"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
item_rv.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="listBean"
type="com.zly.jetpack.bean.ArticleItemBean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="70dp"
android:gravity="center_vertical"
android:paddingLeft="10dp"
android:text="@{listBean.title}"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="条目" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
刷新和重试
Paging3.0中调用刷新的方法比Paging2.x中方便多了,直接就提供了刷新的方法,并且还提供了加载数据出错后的重试方法。
在下拉刷新控制的下拉监听中直接调用adapter.refresh()方法就可以完成刷新了,那什么时候关闭刷新动画呢,需要调用adapter.loadStateFlow.collectLatest方法来监听
refreshView.setOnRefreshListener {
adapter.refresh()
}
lifecycleScope.launchWhenCreated {
@OptIn(ExperimentalCoroutinesApi::class)
adapter.loadStateFlow.collectLatest {
if(it.refresh !is LoadState.Loading){
refreshView.finishRefresh()
}
}
}
收集流的状态,如果是不是Loading状态的说明加载完成了,可以关闭动画了。
预取阈值
构建Pager时,使用到PagingConfig,其中有一个属性值为prefetchDistance,用于表示距离底部多少条数据开始预加载,设置0则表示滑到底部才加载。默认值为分页大小。若要让用户对加载无感,适当增加预取阈值即可。比如调整到分页大小的5倍。
class MainViewModel(application: Application) : AndroidViewModel(application) {
val listData =
Pager(PagingConfig(pageSize = 20,prefetchDistance = 10)) { MainDataSource() }.flow.cachedIn(viewModelScope)
}
加载状态
LoadState分为LoadState.NotLoading、LoadState.Loading、LoadState.Error三种状态。而处理PagingAdapter的加载状态的方式有两种:
- 使用监听器获取并处理加载状态
- 使用PagingAdapter处理加载状态
使用监听器
mainListAdapter.addLoadStateListener { loadState ->
Toast.makeText(this, "$loadState", Toast.LENGTH_LONG).show()
}
使用PagingAdapter
官方提供了工具类LoadStateAdapter给开发者处理加载状态的问题,可以添加header和footer。
PagingDataAdapter可以设置头部和底部的加载进度或者加载出错时候的布局,这样当处于加载中的状态的时候,可以显示加载动画,加载出错的时候可以显示出重试的按钮。用起来也简单舒服。
需要自定义一个Adapter继承自LoadStateAdapter,并将这个Adapter设置给最开始adapter就可以了。
class MainLoadStateAdaptr(private val adapter: MainListAdapter):LoadStateAdapter<MainLoadStateAdaptr.BindingViewHolder>() {
override fun onBindViewHolder(holder: BindingViewHolder, loadState: LoadState) {
val binding = holder.binding
when (loadState) {
is LoadState.Error -> {
binding.pb.visibility = View.GONE
binding.tvState.visibility = View.VISIBLE
binding.tvState.text = "加载失败..."
binding.tvState.setOnClickListener { adapter.retry()
}
}
is LoadState.Loading -> {
binding.pb.visibility = View.VISIBLE
binding.tvState.visibility = View.VISIBLE
binding.tvState.text = "正在加载..."
}
is LoadState.NotLoading -> {
binding.pb.visibility = View.GONE
binding.tvState.visibility = View.GONE
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): BindingViewHolder {
return BindingViewHolder(ItemFooterBinding.inflate(LayoutInflater.from(parent.context),parent,false))
}
class BindingViewHolder(val binding: ItemFooterBinding) : RecyclerView.ViewHolder(binding.root)
}
通过withLoadStateHeaderAndFooter将其添加到MainListAdapter:
binding.rv.adapter=mainListAdapter.withLoadStateHeaderAndFooter(
MainLoadStateAdaptr(mainListAdapter), MainLoadStateAdaptr(mainListAdapter)
)
也可以单独只设置hader或footer:
// only footer
mainListAdapter.withLoadStateFooter(
HeaderFooterAdaptermainListAdapter()
}
// only header
mainListAdapter.withLoadStateHeader(
HeaderFooterAdapter(mainListAdapter)
)
Paging3支持RxJava
如果你不习惯使用Coroutine或者Flow,Paging3同样支持RxJava。
添加RxJava的相关依赖:
implementation "androidx.paging:paging-rxjava2-ktx:$paging_version"
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
使用RxPagingSource替代PagingSource:
class MainDataSource() : RxPagingSource<Int, ArticleItemBean>() {
override fun loadSingle(params: LoadParams<Int>): Single<LoadResult<Int, ArticleItemBean>> {
TODO("Not yet implemented")
}
}
ApiService接口也改为Single:
interface ApiService {
/**
* https://www.wanandroid.com/article/list/1/json
*/
@GET("article/list/{pageNum}/json")
fun getListData(@Path("pageNum") pageNum: Int):Single<BaseResp<ArticleBean>>
}
配置Pager时,使用.observable将LiveData转为RxJava:
class MainViewModel(application: Application) : AndroidViewModel(application) {
val listData =
Pager(PagingConfig(pageSize = 20,prefetchDistance = 2)) { MainDataSource() }
.observable
.cachedIn(viewModelScope)
}
在Activity中请求数据:
viewModel.listData.subscribe {
mainListAdapter.submitData(lifecycle,it)
}