Android Jetpack 之 Paging3的一些踩坑记录

3,142 阅读5分钟

前言

使用时多多少少遇到到了一些问题,去翻了源码发现 Paging3 的实现涉及到挺多协程的内容,但是自己对协程也是一知半解,所以文中的看法以及解决方案可能存在错误或不合理的地方。

简单使用

写这篇文章的时候虽然网上的使用教程寥寥无几,但是仅有的几篇讲的都很详细。结合官方的文档 Paging 3 library overview 自己再仔细琢磨一下使用起来基本没什么问题,这里就不再多做赘述。

注:关于paging3版本的问题,目前网上使用教程好像都是3.0.0-alpha02这个版本,这个版本存在一个bug:当加载更多失败时,调用retry方法有概率不会触发重试。具体原因没有详细了解,该bug在3.0.0-alpha05已修复,并且本文也是基于3.0.0-alpha05这个版本。

Paging3涉及哪些内容

Paging3相较于Paging之前的版本有了相当大的改变,之前也稍微用过Paging2(个人之前的实践项目,当时写完还留下很多坑,等手头上的坑填完打算重构这个项目),用的也是一知半解,导致项目中没少留坑,遇到的一些问题也是能绕就绕,这次使用Paging3的时候打算好好处理遇到的坑,同时也打算尽可能地理解该库的原理。

Pager

Pager 主要用于构建 flow 提供给 PagingDataAdapter 需要的 PagingData 。包含的代码很少,主要还是通过 PageFetcher 来创建 flow ,具体的逻辑代码都在 PageFetcher 中。

PagingConfig

主要配置一些基本的分页信息,其中部分信息例如页码、需要加载size等信息会在 PagingSource 的 load 方法中通过 LoadParams 传递过来。

PagingSource

继承该类并实现 load 方法来加载数据,根据加载情况返回LoadResult.PageLoadResult.Error。在加载上一页、下一页或刷新都通过这个方法,同时注意suspend修饰。加载状态可通过 PagingDataAdapter 的 addLoadStateListener 方法 或者 loadStateFlow 变量来获得。

PagingDataAdapter

比起普通的Adapter要额外提供一个 DiffUtil.ItemCallback 实例用于数据比较更新列表。

坑点一、该怎么做跳页功能

Paging3在构建完Pager完成数据观察后就是内部自身的运作了,根据PagingConfig来自动加载顶部或底部的数据。那么遇到有跳页需求的时候该如何做,在以上的组件中并没有为开发者提供跳往指定page的方法。

查不到api就去源码里看看。在 Pager 类注释中有提到,每个 PagingData 代表支持分页数据的快照,数据刷新时应该提供一个新的 PagingData 实例,其实在各个教程中并没有提到该如何实现跳页功能,通过这段描述就可以想到跳页功能就需要提供一个新的 PagingData 实例。

那么 PagingData 实例是在什么时候提供的,在类 PageFetcher 中有具体的实现

val flow: Flow<PagingData<Value>> = channelFlow {
    refreshChannel.asFlow()
        .onStart {
           //...
        }
        .scan(null) { previousGeneration: PageFetcherSnapshot<Key, Value>?,
                      triggerRemoteRefresh ->
       		//...
            
            PageFetcherSnapshot(
                initialKey = initialKey,
                pagingSource = pagingSource,
                config = config,
                retryFlow = retryChannel.asFlow(),
                triggerRemoteRefresh = triggerRemoteRefresh,
                remoteMediatorAccessor = remoteMediatorAccessor,
                invalidate = this@PageFetcher::refresh
            )
        }
        .filterNotNull()
        .mapLatest { generation ->
            PagingData(generation.pageEventFlow, PagerUiReceiver(generation, retryChannel))
        }
        .collect { send(it) }
}

可以看到在 flow 先是创建了分页快照,最后通过该对象的 pageEventFlow 和 一个 PagerUiReceiver 实例来创建了 PagingData 并发送出来供外部使用。此时可以再看 PageFetcherSnapshot 的 pageEventFlow,说实话里面太多协程内容,并看不太懂,但是能清楚 PagingSource 的 load 方法是在里面执行。

综合上面的线索,PagingData 能够通过触发刷新来提供,而刷新通过调用 PagingDataAdapter 的 refresh 方法,然后层层深入,最终通过 PagerUiReceiver 对象的 refresh 方法 调用 refreshChannel.offer(true) 来触发(又是协程的东西...)。

接下来是页码的处理。在 PageFetcherSnapshot 的 pageEventFlow 中可以发现在触发刷新的时候会通过以下方法构建 LoadParams 并调用 PagingSource 的 load 方法加载数据。

//部分参数就是当初配置PagingConfig的数据,包括key也是构建Pager时传入的initialKey(默认值null)
private fun loadParams(loadType: LoadType, k![](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/93c7b15b3994464ea550f091c8151101~tplv-k3u1fbpfcp-zoom-1.image)ey: Key?) = LoadParams.create(
    loadType = loadType,
    key = key,
    loadSize = if (loadType == REFRESH) config.initialLoadSize else config.pageSize,
    placeholdersEnabled = config.enablePlaceholders,
    pageSize = config.pageSize
)

也就是说需要跳页的时候只要触发刷新,然后在 PagingSource 的 load 方法中加载数据的时候使用个人期望的页码,而不是使用通过LoadParams带过来的初始页面的页码即可。

坑点二、在界面不可见后一段时间切回来时会重新加载数据。

这点算不算是坑不知道,但是影响了APP的使用体验(因为监听了加载状态,所以每次切换回来的时候都会出现加载圈),所以必须排查一下。

首先想到的是不是因为 PageFetcher 的 refresh 方法被调用了。观察调用掉一个是 PagerUiReceiver 对象,专门提供给adapter那边的方法的。遂从 PageFetcherSnapshot 的调用点入手,经过几次调试发现整个刷新行为是因为PageFetcher的flow被重新构建,在继续往外扒最终发现是LiveData搞的鬼。但个人印象中哪怕页面出现切换,LiveData中的数据不发生变化的话也不会出现发送数据的情况。

有一点值得让人注意的是Paging3提供的对象是协程中的flow,最后是通过 asLiveData 方法转到 LiveData对象。

@JvmOverloads
fun <T> Flow<T>.asLiveData(
    context: CoroutineContext = EmptyCoroutineContext,
    timeoutInMs: Long = DEFAULT_TIMEOUT
): LiveData<T> = liveData(context, timeoutInMs) {
    collect {
        emit(it)
    }
}

看到了一个很有意思的参数 timeoutInMs,默认值刚好是界面不可见的时间,而后面最终提供了一个CoroutineLiveData对象,此时真相大白。

还是因为对协程不熟悉的原因,大致了解了一下CoroutineLiveData这个对象。大致就是协程在界面不可见之后不会立即取消,会先等待 timeoutInMs 的时间再去检查界面是否可见,如果此时还不可见就取消协程。

综上个人推断产生这个问题的原因是Paging3内部拥有一个阻塞协程,通过 PageFetcher 中的 refreshChannel 和 retryChannel 来中止阻塞执行请求数据,在界面切换不可见之后,CoroutineLiveData会取消这个协程,在界面返回后又会重建一个协程导致触发刷新。

解决方法:

  1. 对PagingFlow添加cacheIn(viewModelScope)。
  2. 不使用CoroutineLiveData,直接用 lifecycleScope.launchWhenCreated 和 flow 来接收 PagingData 数据。

结语

Paging3 还在摸索使用中,后续遇到的较大的问题会再更新。