利用 Paging 3 给 RecyclerView 列表分页

前置知识:

  • Kotlin Flow 的使用;
  • Android 的 view binding;
  • RecyclerView 的基本用法。

Paging 3 基本用法

首先实现一个 PagingSource<Key, Value> 类,这里的 Key 用于标识当前页,通常是当前的页码,Value 则是列表上每一项的具体内容对应的模型。例如:

class StuffPagingSource : PagingSource<Int, StuffResponse>() 

PagingSource 需要实现两个方法,一个是 load() 方法,用于从数据库或者远程 API 加载当前页所需要的数据。在该方法中,你主要需要做的是将当前页的页码、上一页的页码以及下一页的页码计算出来,并通过当前页的页码获取当前页的数据列表:

class StuffPagingSource : PagingSource<Int, StuffResponse>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, StuffResponse> {
        val page = params.key ?: 1
        val pageSize = params.loadSize
        return try {
            val response = StuffAPICaller.instance.index(page, pageSize) // 这里我是通过远程 API 获取数据
            val previousPage = if (response.previousPage == 0) null else response.previousPage
            val nextPage = if (response.nextPage == 0) null else response.nextPage

            LoadResult.Page(
                data = response.items, // response.items 类型为 List<StuffResponse>
                prevKey = previousPage,
                nextKey = nextPage
            )
        } catch (exception: HttpException) {
            LoadResult.Error(exception)
        } catch (exception: IOException) {
            LoadResult.Error(exception)
        }
    }
}

这里 LoadResult.Page 的构造参数里,如果当前页是第 1 页,prevKey 必须是 null 而不能是其它值;如果当前页是最后一页,则 nextKey 必须是 null. 而 data 的类型是你最终要在 UI 上使用的类型的 List.

尾部的两个 LoadResult.Error 最终会被传递给 LoadStateAdapter 处理,这个后面会提到。

另外 PagingSource 需要实现一个 getRefreshKey() 方法,具体的作用还没看太明白,似乎逻辑就是由当前锚点定位到当前页码,复制一段官方示例代码:

override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
    return state.anchorPosition?.let {
        state.closestPageToPosition(it)?.prevKey?.plus(1)
            ?: state.closestPageToPosition(it)?.nextKey?.minus(1)
    }
}

读了一遍逻辑,起码在我的代码里直接用没什么问题。

在对应的 ViewModel 里使用:

class StuffListViewModel : ViewModel() {
    override fun getPagingDataFlow(): Flow<PagingData<StuffResponse>> {
        return Pager(
            config = PagingConfig(pageSize = 15, initialLoadSize = 15),
            pagingSourceFactory = { StuffPagingSource() }
        ).flow.cachedIn(viewModelScope)
    }
}

这段代码里 PagingConfig 构造参数中的 initialLoadSize 用于配置加载第一页数据时的数据量,默认是 pageSize 的 3 倍。具体数值设置为多少合适,需要配合前面 PagingSource.load() 方法中 pageSize 变量的获取/计算方式来决定。

再将原来 RecyclerView.Adapter 的实现替换为继承 PagingDataAdapter, 此时需要传递一个 DiffUtil.ItemCallback 对象给 PagingDataAdapter 的构造方法。getItemCount() 方法则不需要了。

class StuffListAdapter : PagingDataAdapter<StuffResponse, StuffListAdapter.ListItemViewHolder>(COMPARATOR) {

    object COMPARATOR : DiffUtil.ItemCallback<StuffResponse>() {
        override fun areItemsTheSame(oldItem: StuffResponse, newItem: StuffResponse): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: StuffResponse, newItem: StuffResponse): Boolean {
            return oldItem.content == newItem.content
        }
    }

    override fun onBindViewHolder(holder: ListItemViewHolder, position: Int) {
        //...
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemViewHolder {
        //...
    }

    class ListItemViewHolder(private val binding: StuffListItemBinding) : RecyclerView.ViewHolder(binding.root) {
        //...
    }
}

最后在 Fragment/Activity 上使用:

lifecycleScope.launch {
    viewModel.getPagingDataFlow().collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

显示数据加载状态

前面的 PagingSource.load() 方法在捕获异常之后,返回了两个 LoadResult.Error 对象。在某些情况下(例如网络断开、服务器关机)时,会引发相关异常,这些异常信息可以显示在列表的最底部,并同时提供一个按钮供用户尝试重新加载。另外当网络拥堵时,数据加载过慢,也可以在列表最底部显示一个旋转的进度条表示数据正在加载。

首先需要一个界面来显示这些内容,我起名为 load_state_list_item.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="16dp">

    <TextView
        android:id="@+id/error_message_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerVertical="true"
        android:layout_alignParentStart="true"
        android:textAppearance="@style/TextAppearance.AppCompat.Body2"
        android:textColor="@color/design_default_color_error"
        android:textSize="16sp" />

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"/>

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_centerVertical="true"
        android:text="@string/button_page_loading_retry" />

</RelativeLayout>

接着实现一个 LoadStateAdapter 以及它的 ViewHolder

class LoadStateFooterAdapter(private val retry: () -> Unit) : LoadStateAdapter<LoadStateFooterAdapter.LoadStateViewHolder>() {
    override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState) : LoadStateViewHolder {
        // 这里我开启了 view binding
        val binding = LoadStateListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return LoadStateViewHolder(binding)
    }

    class LoadStateViewHolder(val binding: LoadStateListItemBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(loadState: LoadState) {
            binding.progressBar.isVisible = loadState is LoadState.Loading
            binding.retryButton.isVisible = loadState !is LoadState.Loading
            binding.errorMessageTextView.isVisible = loadState !is LoadState.Loading

            if (loadState is LoadState.Error) {
                binding.errorMessageTextView.text = loadState.error.localizedMessage
            }
            binding.retryButton.setOnClickListener {
                (bindingAdapter as LoadStateFooterAdapter).retry.invoke()
            }
        }
    }
}

然后在原来给 RecyclerView 的 adapter 赋值的地方,给 adapter 加上 load state footer 即可。

recyclerView.let {
    // ...
    it.adapter = adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry))
}

当然如果你希望加在头部可以使用 withLoadStateHeader(), 如果想首尾都加,可以使用 withLoadStateHeaderAndFooter(). 代码当中的 adapter::retry 来自 PagingDataAdapter 本身,无需实现。

左右滑动实现删除

abstract class SwipeCallback(direction: Int = ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT)
    : ItemTouchHelper.SimpleCallback(ItemTouchHelper.ACTION_STATE_IDLE, direction) {

    // 用于实现拖拽排序之类的逻辑,这里暂时用不着。
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        return false
    }
}

class StuffListViewModel : ViewModel() {
    fun deleteStuff(stuff: StuffModel, callback: () -> Unit) {
        viewModelScope.launch {
            stuff.delete()
            callback.invoke()
        }
    }
}

class StuffListFragment : Fragment() {
    val adapter = StuffListAdapter()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // ......
        
        ItemTouchHelper(object : SwipeCallback() {
            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                val stuff = (viewHolder as StuffListAdapter.ListItemViewHolder).stuff
                // 这里如果不用 callback 的形式传入 refresh 方法而是直接在下一行调用
                // deleteStuff() 会和 adapter.refresh() 并发执行,UI 的行为就会变得很奇怪
                viewModel.deleteStuff(stuff, adapter::refresh)
            }
        }).attachToRecyclerView(recyclerView)
    }
}

这里 onSwiped() 方法起初的实现是这样的:

ItemTouchHelper(object : SwipeCallback() {
    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        val stuff = (viewHolder as StuffListAdapter.ListItemViewHolder).stuff
        viewModel.deleteStuff(stuff)
        adapter.notifyItemRemoved(viewHolder.adapterBindingPosition)
    }
}).attachToRecyclerView(recyclerView)

但由于用了 Paging 3, 采用这种实现会出现各种奇怪的问题,例如在第 1 页删除掉某一项,向下滚动页面,之后再滚回来,会发现删除掉的那一项还在。官方的解释是

A PagingSource / PagingData pair is a snapshot of the data set. A new PagingData / PagingData must be created if an update occurs, such as a reorder, insert, delete, or content update occurs. A PagingSource must detect that it cannot continue loading its snapshot (for instance, when Database query notices a table being invalidated), and call invalidate. Then a new PagingSource / PagingData pair would be created to represent data from the new state of the database query.

意思是当 PagingData 的内容发生变化时(包括重新排序、插入新项、删除某一项),要通过调用 PagingSourceinvalidated() 方法来使旧的 PagingSource 和 PagingData 失效,同时创建新的 PagingSource 实例来加载数据。不过我试了一下,直接调用 adapter 的 refresh() 方法也能有同样的效果。

这里同时也解决了我以前尝试实现拖拽排序时对各种诡异现象的疑惑。

1