前言
这里用Paging3完成一个简单的网络加载示例,不考虑数据库缓存。
实现效果
Paging3简介
Paging3是Google提供的一个分页框架,可以简化分页需求的开发。
Paging3提供了3个层级的概念:UI、ViewModel、Repository,每一层都提供了相应的支持类。
UI层
相关类: RecyclerView、PagingDataAdapter、LoadStateAdapter。
UI层主要是将ViewModel加载的数据进行显示。PagingDataAdapter充当RecyclerView的适配器,ViewModel层的Pager以Flow流的形式提供PagingData给PagingDataAdapter。
ViewModel层
相关类: Pager、PagingConfig、PagingSourceFactory
ViewModel层主要是将Repository层请求的数据转化成PagingData,这个转化过程由Pager完成。
Repository层
相关类: PagingSource、RemoteMediator(这里没有用到)
Reposiitory将网络请求回来的数据交给PagingSource,为ViewModel提供数据来源。PagingSource提供了分页相关的回调。
Paging3官方架构图:
下面从左往右搭建这个架构,为了方便以后使用,有些类我抽象了一下。
Repository层搭建
// 模拟网络数据加载,请求时间500ms,请求前面4页,第5页返回空列表
class PersonPagingSource : RemotePagingSource<Person>() {
override suspend fun onLoad(page: Int): List<Person> {
delay(500)
Log.d(TAG, "onLoad: page = $page")
// if (page == 3) throw IllegalArgumentException("加载错误")
if (page < 5) {
return List(10) { index ->
val p = Person()
p.id = index + (page - 1) * 10
p.age = index + 20
p.name = "name$index"
p
}
} else {
return listOf()
}
}
override suspend fun onError(e: Exception) {
}
}
// 分页请求抽象类
abstract class RemotePagingSource<R: Any> : PagingSource<Int, R>() {
override fun getRefreshKey(state: PagingState<Int, R>): Int? {
// adapter.refresh()刷新从第一页开始
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, R> {
return try {
// 首次加载时给定页数1
val nextPage = params.key ?: 1
val result = onLoad(nextPage)
LoadResult.Page(
data = result,
// 页数递减加载时返回上一页的页码,回到第一页以后递减要把页码设置为null,这样load方法就不会调用了
prevKey = if (nextPage <= 1) null else nextPage - 1,
// 页数递增加载时返回下一页的页码,数据全部加载完成要把页码设置为null,这样load方法就不会调用了
nextKey = if (result.isEmpty()) null else nextPage + 1
)
} catch (e: Exception) {
onError(e)
LoadResult.Error(e)
}
}
abstract suspend fun onLoad(page: Int) : List<R>
abstract suspend fun onError(e: Exception)
}
ViewModel层搭建
class MainViewModel : ViewModel() {
// Pager类需要一个PagingSourceFactory,这里我们用PersonPagingSource()来充当
val flow = Pager(
// 分页配置类
PagingConfig(
// 每一页的大小
pageSize = 10
)
) {
// 用之前在Repository写好的PagingSource充当数据源
PersonPagingSource()
}.flow
}
UI层搭建
UI层比较复杂,需要准备一个RecyclerView,还有列表子项的布局视图,列表适配器,数据类
列表和列表子项视图
- RecyclerView列表,这里用一个按钮来模拟下拉刷新
<androidx.constraintlayout.widget.ConstraintLayout
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"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rc_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/button" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="下拉刷新"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
- 列表子项视图
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tv_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/holo_orange_light"
android:textColor="@color/white"
android:textSize="20sp"
android:padding="30dp">
</TextView>
数据类
由于PagingDataAdapter加了AsyncPagingDataDiffer
,因此我们数据类需要提供一个比较器
class Person {
var id: Int = 0
var name: String? = null
var age: Int = 0
var money: Int = 0
override fun hashCode(): Int {
return id.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other is Person) {
return id == other.id
} else {
return false
}
}
// Person对象比较器
object DIFF : DiffUtil.ItemCallback<Person>() {
override fun areItemsTheSame(oldItem: Person, newItem: Person): Boolean {
// Id is unique.
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Person, newItem: Person): Boolean {
return oldItem == newItem
}
}
}
列表适配器
适配器这里我抽象了一下
- 数据适配器
class PersonAdapter : PagingLoadAdapter<Person>(Person.DIFF) {
override val itemLayoutId: Int
get() = R.layout.list_item
override val tailLayoutId: Int
get() = R.layout.list_load_more
override fun onBindItemView(v: View, item: Person?, position: Int) {
v.findViewById<TextView>(R.id.tv_name).text = getItem(position)?.id.toString()
}
override fun onBindTailView(v: View, state: LoadMoreState) {
val tv = v.findViewById<TextView>(R.id.tv_load_more)
Log.d(TAG, "onBindTailView: load state = $state")
when (state) {
LoadMoreState.LOADED -> {
tv.text = "没有更多数据啦"
}
LoadMoreState.LOADING -> {
tv.text = "加载中..."
}
LoadMoreState.ERROR -> {
tv.text = "加载失败,请重试"
tv.setOnClickListener {
// PagingSource返回Error时才有效
retry()
}
}
}
}
}
- 抽象的适配器(PagingDataAdapter + LoadStateAdapter)
这里有两个适配器,
列表适配器
和底部状态适配器
/**
* 分页适配器
*/
abstract class PagingLoadAdapter<T: Any>(
diffCallback: DiffUtil.ItemCallback<T>
) : PagingDataAdapter<T, RecyclerView.ViewHolder>(diffCallback) {
/**
* 内容项视图
*/
abstract val itemLayoutId: Int
/**
* 加载状态视图
*/
abstract val tailLayoutId: Int
/**
* 加载状态适配器
*/
private val footerStateAdapter: FooterStateAdapter by lazy {
FooterStateAdapter(
tailLayoutId = tailLayoutId,
onBindTailView = ::onBindTailView
)
}
/**
* 带加载状态的组合适配器
*/
fun withLoadStateAdapter(): ConcatAdapter = withLoadStateFooter(footerStateAdapter)
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
onBindItemView(holder.itemView, getItem(position), position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return ItemViewHolder(inflater.inflate(itemLayoutId, parent, false))
}
abstract fun onBindItemView(v: View, item: T?, position: Int)
abstract fun onBindTailView(v: View, state: LoadMoreState)
class ItemViewHolder(v: View) : RecyclerView.ViewHolder(v)
}
private class TailViewHolder(v: View) : RecyclerView.ViewHolder(v)
/**
* 加载中状态适配器,和主适配器组合,显示<加载中>、<加载完成>、<加载错误>的状态
*/
private class FooterStateAdapter(
private val tailLayoutId: Int,
private val onBindTailView: (v: View, state: LoadMoreState) -> Unit,
) : LoadStateAdapter<TailViewHolder>() {
override fun onBindViewHolder(holder: TailViewHolder, loadState: LoadState) {
val itemView = holder.itemView
when(loadState) {
is LoadState.Loading -> onBindTailView(itemView, LoadMoreState.LOADING)
is LoadState.Error -> onBindTailView(itemView, LoadMoreState.ERROR)
is LoadState.NotLoading -> {
if (loadState.endOfPaginationReached) onBindTailView(itemView, LoadMoreState.LOADED)
}
else -> {}
}
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): TailViewHolder {
val v = LayoutInflater.from(parent.context).inflate(tailLayoutId, parent, false)
return TailViewHolder(v)
}
override fun displayLoadStateAsItem(loadState: LoadState): Boolean {
// 除了初始化状态返回false,其他状态都返回true
return !(loadState is LoadState.NotLoading && !loadState.endOfPaginationReached)
}
}
enum class LoadMoreState {
LOADED, LOADING, ERROR
}
这个抽象适配器需要说明下:
-
Paging3提供了LoadStateAdapter,但是的是LoadStateAdapter.onBindViewHolder()方法中LoadState对象默认只能提供
加载中
和加载失败
的状态,LoadState.endOfPaginationReached
的值始终为false,如果需要更多的状态,需要重载LoadStateAdapter.displayLoadStateAsItem()
方法。 -
这里出现了两个Adapter,一个是PagingDataAdapter,另一个是LoadStateAdapter,赋值给RecyclerView的adapter的时候,需要将这两个组合成一个
ConcatAdapter
再赋值给RecyclerView的adapter。
最后就是将之前准备的东西组合起来,初始化RecyclerView。
初始化列表
class MainActivity : AppCompatActivity() {
private val viewModel by viewModels<MainViewModel>()
private val adapter: PersonAdapter by lazy { PersonAdapter() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val btn = findViewById<Button>(R.id.button)
val rcList = findViewById<RecyclerView>(R.id.rc_list)
rcList.layoutManager = LinearLayoutManager(this)
// 注意这里需要赋值ConcatAdapter
rcList.adapter = adapter.withLoadStateAdapter()
btn.setOnClickListener {
/**
* Paging3的数据刷新Bug,页面回到初始位置时刷新,第二页的加载不会被触发
* 这里需要重置下Adapter
*/
rcList.swapAdapter(adapter.withLoadStateAdapter(), true)
/**
* 刷新列表,这里调adapter.refresh()或者loadData()都可以
*/
adapter.refresh()
}
loadData()
findViewById<Button>(R.id.btn_retry).setOnClickListener {
// PagingSource返回Error时才有效
adapter.retry()
}
}
private fun loadData() {
lifecycleScope.launch {
viewModel.flow.collectLatest {
adapter.submitData(it)
}
}
}
}
这里对列表的刷新行为会有点奇怪,需要重置下adapter。不然第二页不会被加载。
到这里demo就完成了。
关于ConcatAdapter、PagingDataAdapter和LoadStateAdapter
这两个adapter都是继承RecyclerView.Adapter
查看PagingDataAdapter.withLoadStateFooter()方法可以发现该方法会返回一个ConcatAdapter,注意这个ConcatAdapter也是一个RecyclerView.Adapter。
fun withLoadStateFooter(
footer: LoadStateAdapter<*>
): ConcatAdapter {
addLoadStateListener { loadStates ->
footer.loadState = loadStates.append
}
return ConcatAdapter(this, footer)
}
ConcatAdapter初始化时会把adapter都添加到一个集合里面,通过调用ConcatAdapterController.addAdapter方法添加。
查看ConcatAdapterController.addAdapter方法可以看到
boolean addAdapter(int index, Adapter<ViewHolder> adapter) {
...
NestedAdapterWrapper wrapper = new NestedAdapterWrapper(adapter, this,
mViewTypeStorage, mStableIdStorage.createStableIdLookup());
mWrappers.add(index, wrapper);
...
return true;
}
ConcatAdapterController持有的实际上是一个NestedAdapterWrapper集合,它是RecyclerView.Adapter的一个代理类。
此时我们的ConcatAdapter拥有了一个RecyclerView.Adapter的集合,而且它本身也是一个RecyclerView.Adapter。下面看下ConcatAdapter.onBindViewHolder
这个方法的调用,
// ConcatAdapter.onBindViewHolder
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
mController.onBindViewHolder(holder, position);
}
// ConcatAdapterController.onBindViewHolder
// 这里的globalPosition是ConcatAdapter的position,也就是RecyclerView的子项位置
// 但是实际上调用的onBindViewHolder方法是ConcatAdapter里面维护的Adapter列表中某个Adapter
// 的onBindViewHolder。
public void onBindViewHolder(ViewHolder holder, int globalPosition) {
WrapperAndLocalPosition wrapperAndPos = findWrapperAndLocalPosition(globalPosition);
mBinderLookup.put(holder, wrapperAndPos.mWrapper);
// 调用adapter的onBindViewHolder方法
wrapperAndPos.mWrapper.onBindViewHolder(holder, wrapperAndPos.mLocalPosition);
releaseWrapperAndLocalPosition(wrapperAndPos);
}
// ConcatAdapterController.findWrapperAndLocalPosition
// 这个方法的功能是把不同的Adapter拼起来,给RecyclerView的每个位置去调用相应的方法,
// 让RecyclerView觉得面对的是一个Adapter
private WrapperAndLocalPosition findWrapperAndLocalPosition(int globalPosition) {
WrapperAndLocalPosition result;
if (mReusableHolder.mInUse) {
result = new WrapperAndLocalPosition();
} else {
mReusableHolder.mInUse = true;
result = mReusableHolder;
}
int localPosition = globalPosition;
// 这里的wrapper其实就adapter
for (NestedAdapterWrapper wrapper : mWrappers) {
// getCachedItemCount就是adapter.itemCount
// 如果全局位置在当前的Adapter.itemCount范围内,那么给当前位置标记一个adapter类型和adapter内的位置,
// 如果全局位置超过了当前Adapter的范围,那么给下一个Adapter的位置赋值
if (wrapper.getCachedItemCount() > localPosition) {
result.mWrapper = wrapper;
result.mLocalPosition = localPosition;
break;
}
// 切换到下一个Adapter
localPosition -= wrapper.getCachedItemCount();
}
if (result.mWrapper == null) {
throw new IllegalArgumentException("Cannot find wrapper for " + globalPosition);
}
// 返回一个WrapperAndLocalPosition,这个类包含了一个NestedAdapterWrapper(即Adapter),和Adapter内的位置信息
return result;
}
所以ConcatAdapter.onBindViewHolder
事实上会调用它持有的每一个Adapter的onBindViewHolder(),也就实现了把多个Adapter拼接成一个提供给RecyclerView调用。
为什么LoadStateAdapter只提供加载中和加载错误的状态
通过查看LoadStateAdapter的源码可以发现
open fun displayLoadStateAsItem(loadState: LoadState): Boolean {
return loadState is LoadState.Loading || loadState is LoadState.Error
}
其他
学习Paging3的同时做了一个小Demo。发现了Paging3刷新的问题,现在只能通过重置Adapter来处理。上面的代码可以参考Paging3Demo。