Paging3网络加载的使用和分析

1,082 阅读6分钟

前言

这里用Paging3完成一个简单的网络加载示例,不考虑数据库缓存。

实现效果

Paging3Demo示例代码

20211222_170032.gif

Paging3简介

Paging3是Google提供的一个分页框架,可以简化分页需求的开发。

Google官方介绍

Google官方github示例

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官方架构图:

image.png

下面从左往右搭建这个架构,为了方便以后使用,有些类我抽象了一下。

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,还有列表子项的布局视图,列表适配器,数据类

列表和列表子项视图

  1. 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>
  1. 列表子项视图
<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
}

这个抽象适配器需要说明下:

  1. Paging3提供了LoadStateAdapter,但是的是LoadStateAdapter.onBindViewHolder()方法中LoadState对象默认只能提供加载中加载失败的状态,LoadState.endOfPaginationReached的值始终为false,如果需要更多的状态,需要重载LoadStateAdapter.displayLoadStateAsItem()方法。

  2. 这里出现了两个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