前言
基于RecyclerView,Adapter的分页模块很常见,大多数的实现内容为监听ui上滚动到底部的行为,然后触发分页加载,再将数据添加到列表中。
大约的实现可能是这样加载状态,列表数据都在View层维护。
var pageIndex = 1
var pageSize = 10
var loading = false
recyclerView.setLoadMoreListener {
loading = true
requestLoad(pageIndex, pageSize,
onSucceed = { data ->
loading = false
adapter.data.addAll(data)
adapter.notifyDataSetChanged()
if (adapter.data.isEmpty()) {
showNoMoreLayout()
} else {
showDataLayout()
}
},
onFail = { error ->
loading = false
showLoadMoreErrorLayout(error)
}
)
}
refreshLayout.setOnRefreshListener {
pageIndex = 1
//...
}
这在Mvvm中使用会有一些问题,Mvvm与Mvp/Mvc不同,Mvvm它需要一个承载UI状态的数据,然后数据的变更同步到Ui中。
于是谷歌推出了Paging 库来解决这个问题, 它的设计很好,将数据和UI进行了分离,但是必须继承PagingDataAdapter,引入项目中的话改动较大
因此,本文实现一个能在Mvvm中使用的分页模块。它具有以下特点:
- 对原有代码(Adapter,RecyclerView)不能改动太大,最好无侵入
- 支持Mvp、Mvc、Mvvm
- 支持Compose、Compose for desktop
- 支持手动加载,自动检测加载,刷新,错误重试,状态监听
项目地址 BindingAdapter
- 拓展模块-分页模块
- 拓展模块-选择模块
- 拓展模块-滚轮模块
- 拓展模块-悬浮模块
原理
列表数据状态
分页模块需要在Mvvm中使用,首先需要将其UI状态抽象出来。主要显示2个内容:
- 显示的列表数据
- 加载状态(加载中/加载失败/没有更多/空闲中)
简单实现的话,是可以直接使用StateFlow<List<T>> 来描述列表数据的,然后在View层监听数据变化替换所有数据即可。
但是在RecyclerView中每次都替换所有所有列表数据是低效的,官方也提供了一个方案就是使用Differ来帮助实现增量更新,但必须实现 DiffUtil.ItemCallback。
而分页大多数情况我们都是末尾追加数据,也不需要Differ计算更新,我们直接追加数据即可。
因此可以把列表数据描述为一组的数据更新事件SharedFlow<DataChangeEvent>。
数据更新事件分为2种:
- 列表全量更新
DataChangeEvent.Replace重新加载、下拉刷新的时候,列表的全部数据发生了变化 - 列表数据追加
DataChangeEvent.Append上拉到末尾的时候触发加载下一页,新的一页数据追加到末尾
这样UI在订阅的时候,就能收到若干个Replace事件和Append事件,从而去更新列表数据。
launch {
data.dataFlow.collect {
when (it) {
is DataChangeEvent.Append -> adapter.appendData(it.data)
is DataChangeEvent.Replace -> adapter.replaceData(it.data)
}
}
}
但是SharedFlow有个问题, 如果在订阅的时候列表数据就已经有数据了(比如Activity横竖屏切换导致的重建,数据不会销毁,但会重新订阅,或者预加载了一些数据),这时订阅我们是就收不到订阅前的数据的。
这里本质上也就是事件和状态的区别,展开讲的话是这样的:
-
StateFlow描述的是状态,应该任何时间都有值的,是用来驱动UI显示的,状态不关心中途的变更,只关心最终UI需要显示最新状态:比如 红色->蓝色->红色->蓝色 它只会收到蓝色,或者 红色->红色->红色 它只会收到1次红色。 这也是我们无法使用StateFlow 去描述增量更新的原因。 -
SharedFlow描述的是事件,比如登录成功事件,它是可重复的,没有状态的。 进行订阅时,订阅前的数据都会丢失。
但是现在我们的需求是,需要描述列表的状态,但是同时又要支持增量更新,比如 追加1页数据->追加1页数据-> 追加1页数据 最终应该是要追加3页数据。 因此StateFlow和SharedFlow都无法满足,我们需要自己新建一个flow
在collect的时候先发送当前最新已经加载的数据,然后再发送后续的一系列DataChangeEvent
private val cacheData: MutableList<T> = ArrayList()
private val dataEventFlow = MutableSharedFlow<DataChangeEvent<T>>()
private val dataFlow = flow {
emit(DataChangeEvent.Replace(cacheData)) //发送当前最新数据
emitAll(dataEventFlow) //发送的变更的事件
}
加载状态
加载状态LoadMoreStatus有4种:
sealed interface LoadMoreStatus {
/**
* 空闲
*/
object Idle : LoadMoreStatus
/**
* 没有更多数据了
* @param isReload 当前是否为reload,reload状态的NoMore,相当于列表是空的
*/
class NoMore(val isReload: Boolean) : LoadMoreStatus
/**
* 加载失败
* @param isReload 当前是否为reload,reload状态的Fail,相当于列表加载失败了
* @param throwable 失败原因
*/
class Fail(val isReload: Boolean, val throwable: Throwable) : LoadMoreStatus
/**
* 加载失败
* @param isReload 当前是否为reload
*/
class Loading(val isReload: Boolean) : LoadMoreStatus
}
描述加载状态因为不涉及增量更新可以直接使用StateFlow<LoadMoreStatus>
加载逻辑实现
重新加载和加载更多的加载数据部分是一样的,因此抽取一份公共实现
/**
* 加载更多/重新加载
* @param isReload 重新加载
*/
private fun loadMoreOrReload(isReload: Boolean): Boolean {
val fetcher = fetchMore ?: return false
statusFlow.value = LoadMoreStatus.Loading(isReload)
currentLoadJob = coroutineScope.launch {
try {
val data = fetcher.fetch(progress) ?: emptyList() //调用加载
if (data.isNotEmpty()) {
//数据不为空
progress.nextProgress()
if (isReload) {
//重新加载,替换数据
cacheData.clear()
cacheData.addAll(data)
dataEventFlow.emit(DataChangeEvent.Replace(data))
} else {
//加载更多,追加数据
cacheData.addAll(data)
dataEventFlow.emit(DataChangeEvent.Append(data))
}
//加载完成,恢复空闲状态
statusFlow.value = LoadMoreStatus.Idle
} else {
//数据为空,没有更多数据了
statusFlow.value = LoadMoreStatus.NoMore(isReload)
}
} catch (e: CancellationException) {
//协程取消,恢复空闲状态
statusFlow.value = LoadMoreStatus.Idle
throw e
} catch (e: Throwable) {
//加载失败
statusFlow.value = LoadMoreStatus.Fail(isReload, e)
}
}
return true
}
加载更多loadMore
主要对状态进行判断,触发加载
//加载下一页
fun loadMore() {
if (statusFlow.value is LoadMoreStatus.Idle) {
loadMoreOrReload(false)
}
}
重新加载reload
取消之前的加载请求和恢复状态,将加载进度恢复到第一页,然后触发加载
//重新加载数据
fun reload(): Boolean {
cancelLoad()
progress.resetProgress()
return loadMoreOrReload(true)
}
重试retry
重试相当于再次请求加载下一页
/**
* 错误重试
*/
fun retry() {
if (statusFlow.value is LoadMoreStatus.Fail) {
loadMoreOrReload(false)
}
}
取消加载cancelLoad
将之前保存到加载Job进行取消
/**
* 取消加载
*/
private fun cancelLoad() {
currentLoadJob?.cancel()
currentLoadJob = null
statusFlow.value = LoadMoreStatus.Idle
}
检测加载更多
一般是RecyclerView滚动到底部触发加载下一页,但不同的LayoutManager可能实现不同,拓展性低,因此采用和Paging一样的方案,利用对item下标的访问来检测。
比如在onBindViewHolder中访问下标时,将这个事件传输给加载模块去判断是否需要加载
数据暴露
本着接口隔离的原则,我们将需要暴露给View层的数据和接口,封装为LoadMoreDataSource,而不是LoadMoreData
class LoadMoreDataSource<T>(
val uiReceiver: UiReceiver,
val dataFlow: Flow<DataChangeEvent<T>>,
val statusFlow: StateFlow<LoadMoreStatus>,
)
RecyclerView封装和使用
适配Adapter
这里利用BindingAdapter 中的doAfterBindViewHolder
添加监听器来避免修改Adapter
主要是完成对onBindViewHolder 的监听去触发加载下一页的逻辑,已经将数据更新到列表中
/**
* Created by ve3344@qq.com.
* 分页加载的Adapter帮助类
*/
class LoadMoreAdapterModule<I : Any, V : ViewBinding>(val adapter: MultiTypeBindingAdapter<I, V>) {
/**
* 当前绑定的数据源
*/
private var attachedData: LoadMoreDataSource<I>? = null
/**
* 控制加载模块
*/
private val uiReceiver get() = attachedData?.uiReceiver
/**
* 加载状态监听
*/
private val statusListeners = mutableListOf<(LoadMoreStatus) -> Unit>()
/**
* 保存job,切换data时取消订阅
*/
private var dataCollectJob: Job? = null
/**
* 加载状态
*/
val loadStatus: LoadMoreStatus get() = attachedData?.statusFlow?.value ?: LoadMoreStatus.Idle
init {
//触发加载下一页
adapter.doAfterBindViewHolder { holder, position, payloads ->
adapter.recyclerView?.postAvoidComputingLayout {
uiReceiver?.onItemAccess(position)
}
}
}
/**
* 添加加载状态监听
*/
fun addLoadMoreStatusListener(function: (LoadMoreStatus) -> Unit) {
statusListeners += function
}
/**
* 添加加载状态监听
*/
fun observeLoadMoreStatus(listener: (value: LoadMoreStatus) -> Unit) {
addLoadMoreStatusListener(listener)
listener.invoke(loadStatus)
}
/**
* 设置数据
*/
fun setDataSource(
lifecycleOwner: LifecycleOwner,
data: LoadMoreDataSource<I>
) = setDataSource(lifecycleOwner.lifecycleScope, data)
/**
* 设置数据
*/
fun setDataSource(
scope: CoroutineScope,
data: LoadMoreDataSource<I>
) {
if (this.attachedData === data) {
return
}
this.attachedData = data
dataCollectJob?.cancel()
dataCollectJob = scope.launch {
launch {
data.statusFlow.collectLatest { status ->
statusListeners.forEach { it(status) }
}
}
launch {
data.dataFlow.collect {
when (it) {
is DataChangeEvent.Append -> adapter.appendData(it.data)
is DataChangeEvent.Replace -> adapter.replaceData(it.data)
}
}
}
}
}
/**
* 重新加载
*/
fun reload() = uiReceiver?.reload()
/**
* 重试加载
*/
fun retry() = uiReceiver?.retry()
}
fun <I : Any, V : ViewBinding> MultiTypeBindingAdapter<I, V>.setupLoadMoreModule() =
LoadMoreAdapterModule(this)
使用
ViewModel中加载数据
val projects = PageLoadMoreData<ProjectBean> {
ProjectRepository.getProjects(it.pageIndex, it.pageSize).data.datas
}
Activity/Fragment 中绑定数据源
val loadMoreModule = dataAdapter.setupLoadMoreModule()
loadMoreModule.setDataSource(lifecycleScope, vm.projects.source)
Compose封装和使用
适配Compose
在Compose 中,我们不需要增量的逻辑,可以将其转换回List数据,然后包装在State中。 然后在items中检测数据的使用,触发加载下一页
class ComposeLoadMoreData<T : Any>(private val source: LoadMoreDataSource<T>) {
private val mutableData: MutableList<T> = ArrayList()
var data: List<T> by mutableStateOf(ImmutableListWrapper(mutableData))
private set
var loadStatus: LoadMoreStatus by mutableStateOf(LoadMoreStatus.Idle)
private set
val itemCount: Int get() = data.size
private val uiReceiver get() = source.uiReceiver
operator fun get(index: Int): T {
uiReceiver.onItemAccess(index)
return data[index]
}
fun peek(index: Int): T = data[index]
fun retry() {
uiReceiver.retry()
}
fun reload() {
uiReceiver.reload()
}
internal suspend fun collectLoadMoreStatus() {
source.statusFlow.collectLatest {
loadStatus = it
}
}
internal suspend fun collectLoadMoreData() {
source.dataFlow.collectLatest {
when (it) {
is DataChangeEvent.Append -> mutableData.addAll(it.data)
is DataChangeEvent.Replace -> {
mutableData.clear()
mutableData.addAll(it.data)
}
}
data = ImmutableListWrapper(mutableData)
}
}
}
@Composable
fun <T : Any> LoadMoreDataSource<T>.collectComposeLoadMoreData(): ComposeLoadMoreData<T> {
val composeLoadMoreData = remember(this) { ComposeLoadMoreData(this) }
LaunchedEffect(composeLoadMoreData) {
composeLoadMoreData.collectLoadMoreData()
}
LaunchedEffect(composeLoadMoreData) {
composeLoadMoreData.collectLoadMoreStatus()
}
return composeLoadMoreData
}
@Suppress("UNUSED_VARIABLE")
fun <T : Any> LazyListScope.items(
loadMoreData: ComposeLoadMoreData<T>,
itemContent: @Composable LazyItemScope.(value: T) -> Unit,
) {
items(loadMoreData.itemCount) { index ->
itemContent(loadMoreData[index])
}
}
fun <T : Any> LazyListScope.itemsIndexed(
loadMoreData: ComposeLoadMoreData<T>,
itemContent: @Composable LazyItemScope.(index: Int, value: T) -> Unit,
) {
items(loadMoreData.itemCount) { index ->
itemContent(index, loadMoreData[index])
}
}
internal class ImmutableListWrapper<T>(private val delegate: List<T>) : List<T> by delegate {
override fun toString() = delegate.toString()
}
使用
val data = viewModel.projects.collectComposeLoadMoreData()
LazyColumn {
items(data) {
}
}
总结
实现了支持Compose和RecyclerView一个侵入性低的分页模块,完成了分页加载,状态管理,重试等功能。 主要解决了Mvvm中数据增量更新的问题和Paging中需要继承PagingAdapter的问题。