手写一个支持Compose,RecyclerView的分页模块

995 阅读7分钟

前言

基于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

原理

Loadmore

列表数据状态

分页模块需要在Mvvm中使用,首先需要将其UI状态抽象出来。主要显示2个内容:

  1. 显示的列表数据
  2. 加载状态(加载中/加载失败/没有更多/空闲中)

简单实现的话,是可以直接使用StateFlow<List<T>> 来描述列表数据的,然后在View层监听数据变化替换所有数据即可。

但是在RecyclerView中每次都替换所有所有列表数据是低效的,官方也提供了一个方案就是使用Differ来帮助实现增量更新,但必须实现 DiffUtil.ItemCallback

而分页大多数情况我们都是末尾追加数据,也不需要Differ计算更新,我们直接追加数据即可。

因此可以把列表数据描述为一组的数据更新事件SharedFlow<DataChangeEvent>

数据更新事件分为2种:

  1. 列表全量更新DataChangeEvent.Replace 重新加载、下拉刷新的时候,列表的全部数据发生了变化
  2. 列表数据追加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的问题。