Paging3实际应用--如何在Paging刷新数据后清除旧数据

1,974 阅读6分钟

Paging3这个库让人又爱又恨。爱其内部的设计和对协程的应用,曾想翻取源码仿写一个精简的库,但翻了一遍也没啥好精简的 (外加一些还看不懂的)。恨其对外接口太少太少,大量使用internal关键字使得外部无法访问相关内容,在遇到实际业务场景时常常被库所局限,无法实现相关功能。

需求与困难

目前遇到了一个实际业务需求:在列表刷新数据后无论成功失败都清除原有的数据。
这个需求中刷新成功还好说,空数据会直接清除旧数据,新数据直接更新进列表。比较麻烦的是刷新请求出错时清除数据,在传统实现方法中时非常简单的,只需要清除数据列表并调用适配器的notifyItemRangeRemoved即可。而在Paging3中这一步就尤为艰难,因为在PagingSource的load方法中返回LoadResult.Error不会触发任何列表数据的更新,又如开头所言,面对internal关键字遍布的Paging3我不得不挖源码去寻求解决方案 (又或者为了使用库而需求让步)

解决方案初探

既然我能遇到这个问题,作为一个后来者总有先行者会遇到吧。遂键入关键词开始搜索,茫茫多的文章介绍Paging3的使用又或是对官方教程的搬运,大部分人止步于Demo却鲜有人应用于实际业务,终于是在stackoverflow的一个提问中找到了头绪。

回答中提到使用pagingAdapter.submitData(lifecycle, PagingData.empty())来处理,那么先动手试一试。

mMainAdapter.submitData(lifecycle, PagingData.empty())
mMainAdapter.refresh()

运行起来一看就发现不对了,列表数据确实被清除了,但是后续的刷新逻辑全没了。那么来看一下PagingData.empty()到底给了什么。

public class PagingData<T : Any> internal constructor(
    internal val flow: Flow<PageEvent<T>>,
    internal val receiver: UiReceiver
) {
    public companion object {
        internal val NOOP_RECEIVER = object : UiReceiver {
            override fun accessHint(viewportHint: ViewportHint) {}
            override fun retry() {}
            override fun refresh() {}
        }

        internal val EMPTY = PagingData(
            flow = flowOf(PageEvent.Insert.EMPTY_REFRESH_LOCAL),
            receiver = NOOP_RECEIVER
        )
        
        public fun <T : Any> empty(): PagingData<T> = EMPTY as PagingData<T>
        
        ...
    }
}

从源码看PagingData.empty()给了一个空实现的UiReceiver对象实例,而UiReceiver则是Pading3分页、刷新、重试逻辑触发的核心,PagingDataAdapterrefreshretry最终都是调用UiReceiverrefreshretry,这也就是为什么在使用pagingAdapter.submitData(lifecycle, PagingData.empty())后无法再触发刷新逻辑的原因,那么如果要使用PagingData.empty()清空数据必须在刷新逻辑调用之后。

解决方案实现

首先来看一下PagingDataAdapter中的refresh,看一看在调用该方法后有没有机会去清除列表数据。通过PagingDataAdapter->AsyncPagingDataDiffer->PagingDataDiffer查找确定是由UiReceiver提供refresh的实现的。再通过Pager->PageFetcher->PagerUiReciver确定UiReceiver的实现是PageFetcherrefresh方法。

internal class PageFetcher<Key : Any, Value : Any>(
    private val pagingSourceFactory: suspend () -> PagingSource<Key, Value>,
    private val initialKey: Key?,
    private val config: PagingConfig,
    @OptIn(ExperimentalPagingApi::class)
    remoteMediator: RemoteMediator<Key, Value>? = null
) {
    ...
    
    fun refresh() {
        refreshEvents.send(true)
    }
    
    ...
}

由此可见,refresh方法是发送了一个事件触发PageFetcher的flow发送了一个PagingData,而这个PagingData接收的地方会将利用该实例调用PagingDataAdaptersubmitData开始接收分页数据。每当调用refresh就会有新的PagingData发送过去,并且这个实例包含分页控制逻辑,所以能插入EmptyPagingData的时机只有是在refresh调用之后在新的PagingData进入submitData之前。

此时歪想法就来了,既然这样子,我不如在收到新的PagingData后使用PagingData.empty()清空数据,然后进行同时用进程锁进行阻塞,在一个合适的时机释放锁保证新的PagingData能够进入适配器,而且Paging3的使用中伴随着协程,去做一些进程阻塞相关的操作还是比较方便的。接下来就要找一个合适的释放时机。

回到对PagingData数据收集的地方PagingDataDiffer看一下,从里面可以看出我们可以在监听加载状态的地方去监听PagingData.empty()的加载状态,在那个时候进行释放相关的锁。

public abstract class PagingDataDiffer<T : Any>(
    private val differCallback: DifferCallback,
    private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main
) {
    ...
    
    public suspend fun collectFrom(pagingData: PagingData<T>) {
        collectFromRunner.runInIsolation {
            receiver = pagingData.receiver
            pagingData.flow.collect { event ->
                ...
                //这里分发了LoadStates事件
                dispatchLoadStates(event.sourceLoadStates, event.mediatorLoadStates)
                ...
            }
        }
    }
    
    ...
}

然后便有了以下的实现

class PagingEmptyValve {

    private val mLock = Mutex()

    suspend fun <T : Any> submitData(
        lifecycle: Lifecycle,
        adapter: PagingDataAdapter<T, *>,
        pagingData: PagingData<T>
    ) {
        withContext(Dispatchers.Default) {
            if (mLock.isLocked) {
                adapter.submitData(lifecycle, PagingData.empty())
                mLock.withLock { /* just lock */ }
                adapter.submitData(lifecycle, pagingData)
            } else {
                adapter.submitData(lifecycle, pagingData)
            }
        }
    }

    fun enable() {
        runBlocking {
            if (!mLock.isLocked) {
                mLock.lock()
            }
        }
    }

    //非empty的loadStates,需要处理相关事件
    fun emptyStates(loadStates: LoadStates): Boolean {
        return loadStates.isEmptyStates().also { es ->
            if (es) {
                mLock.tryUnlock()
            }
        }
    }

    //基于internal特性调用,随时关注api的变动,此处可能需要改为反射实现
    @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
    private fun LoadStates.isEmptyStates(): Boolean {
        return this === androidx.paging.PageEvent.Insert.EMPTY_REFRESH_LOCAL.sourceLoadStates
    }
}
//触发刷新
mPagingEmptyValve.enable()
mMainAdapter.refresh()
//状态事件处理
mMainAdapter.loadStateFlow.collect {
    if (mPagingEmptyValve.emptyStates(it.source)) {
        return@collect
    }
    
    ...
}
//PagingData的接收
list.collect { mPagingEmptyValve.submitData(lifecycle, mMainAdapter, it) }

这里提一下,Paging3中用internal屏蔽了很多类的可见性,导致该库根本无法应对实际多变的业务场景,此处利用internal编译后依旧是public的特性并且注解消除了访问异常使得该实现可用,但不保证后期库升级还有可用性。

解决方案改进

本以为以上的实现基本OK了,但是实际使用中发现部分场景还是出现了列表清空但是没有加载新数据的情况。进过事件排查发现PagingData.empty()的LoadStates并不一定会分发,这是想起了PagingDataDifferdispatchLoadStates的实现,可以看到部分事件被过滤了。

internal fun dispatchLoadStates(source: LoadStates, mediator: LoadStates?) {
    // No change, skip update + dispatch.
    if (combinedLoadStatesCollection.source == source &&
        combinedLoadStatesCollection.mediator == mediator
    ) {
        return
    }

    combinedLoadStatesCollection.set(
        sourceLoadStates = source,
        remoteLoadStates = mediator
    )
}

然后简单粗暴地开始了改造,新增了定时释放锁的逻辑。

class PagingEmptyValve {

    private val mLock = Mutex()

    suspend fun <T : Any> submitData(
        lifecycle: Lifecycle,
        adapter: PagingDataAdapter<T, *>,
        pagingData: PagingData<T>
    ) {
        withContext(Dispatchers.Default) {
            if (mLock.isLocked) {
                adapter.submitData(lifecycle, PagingData.empty())
                launch {
                    //某些状态下PagingData.empty不会触发加载事件,所以自动进行解锁
                    delay(200)
                    //自动释放
                    mLock.tryUnlock()
                }
                mLock.withLock { /* just lock */ }
                adapter.submitData(lifecycle, pagingData)
            } else {
                adapter.submitData(lifecycle, pagingData)
            }
        }
    }

    fun enable() {
        runBlocking {
            if (!mLock.isLocked) {
                mLock.lock()
            }
        }
    }

    //非empty的loadStates,需要处理相关事件
    fun emptyStates(loadStates: LoadStates): Boolean {
        return loadStates.isEmptyStates().also { es ->
            if (es) {
                mLock.tryUnlock()
            }
        }
    }

    //基于internal特性调用,随时关注api的变动,此处可能需要改为反射实现
    @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
    private fun LoadStates.isEmptyStates(): Boolean {
        return this === androidx.paging.PageEvent.Insert.EMPTY_REFRESH_LOCAL.sourceLoadStates
    }
}

后记

网络上能看到了另一种清除数据的方案是通过PagingListAdapter获取快照,然后通过快照中的列表数据引用删除数据。然而在Paging3中PagingListAdapter变为了PagingDataAdapter,其中的数据快照也变成了新建的列表实例对象,无法再通过快照去清除数据刷新列表。

回看整个实现还是非常粗鲁的,并且也无法做到在出现加载异常的时候再去清除数据,Paging3的一整套内部循环过于封闭,作为开发者很难插手更改,而且对外暴露的接口也不多,给人看到的只有按该库开发者认为正确的加载方案去走这一条路,目前这个库也只能在自己的项目中玩一玩。