在Compose外刷新Paging

后记

法一

突然想到可以在LaunchedEffect里使用refresh方法,在ViewModel里用一个State来在非Compose层控制刷新,根本不需要这么麻烦,真是笨蛋QAQ

法二

var pagingSource: WpCommentPagingSource? = null
val pager = Pager(config = PagingConfig(20), initialKey = 1) {
    WpCommentPagingSource().also {
        pagingSource = it
    }
}

然后调用pagingSource?.invalidate()即可刷新


法三

前言

一般情况下,我们无法在非compose环境下刷新Paging。在compose中,我们可以在collect已缓存的只读流以后,调用refresh方法刷新。 这会调用当前的 PagingSource.invalidate(),Paging框架会自动重新加载第一页。

需求

有时候我们希望在ViewModel里封装刷新方法,在UI里调用自己封装的刷新函数,使代码更简洁。

方法

想刷新分页数据(即重新加载第一页),必须重新触发一个新的 Pager 实例或重新收集 Flow

    // 刷新触发器,带防抖
    private val refreshTrigger = MutableStateFlow(0L)
    
    // 用于标识每次刷新的唯一ID
    private val refreshId = AtomicInteger(0)
    private var currentRefreshId = 0

    @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
    val resListPagingFlow = refreshTrigger
        .debounce(100) // 100ms 防抖,避免过快点击
        .flatMapLatest {
            val thisRefreshId = currentRefreshId
            Log.d(TAG, "flatMapLatest: creating new Pager for refreshId=$thisRefreshId")
            Pager(
                config = PagingConfig(
                    pageSize = 20,
                    enablePlaceholders = false,
                    initialLoadSize = 20
                ),
                initialKey = 1,
                pagingSourceFactory = {
                    Log.d(TAG, "pagingSourceFactory: creating PagingSource for refreshId=$thisRefreshId")
                    MainResPostPagingSource(
                        resListUrl = resListUrl,
                        repository = repository,
                        refreshId = thisRefreshId,
                        onFinished = { completedRefreshId, isFirstPage ->
                            // 只有当前刷新ID匹配且是第一页加载完成时,才关闭刷新状态
                            if (completedRefreshId == currentRefreshId && isFirstPage) {
                                isRefreshing.value = false
                                Log.d(TAG, "onFinished: refreshId=$completedRefreshId matches, closing refresh indicator")
                            } else {
                                Log.d(TAG, "onFinished: refreshId=$completedRefreshId, current=$currentRefreshId, isFirstPage=$isFirstPage, ignoring (outdated request)")
                            }
                        }
                    )
                }
            ).flow
        }.cachedIn(viewModelScope)

先定义一个触发器refreshTrigger, 每次改变它,flatMapLatest会取消旧 flow 并开始新 flow。我们使用refreshTrigger.value += 1 就可以刷新Pager

说明:flatMapLatest的用途是把一个 Flow 转换成另一个 Flow,并在上游更新时取消旧的下游 Flow(不要和列表的flatMap混淆,这个跟flatMap没有关系)

这时候其实就已经完成compose外刷新的功能了。但是在我的使用场景,点击TabRow的item会在刷新的时候传入不同的url,请求不同的数据源列表。

后续问题

异步请求完成后会修改 isRefreshing ,如果此时Tab切换太快,导致可能有多个请求同时进行;使用 refreshTrigger.value += 1 触发新的 Pager Flow,但之前的请求可能还在进行,导致数据错乱;旧的请求完成后会把 isRefreshing 设为 false,导致刷新图标提前收回

解决方案

首先想到了使用Job,在刷新请求执行请求之前,先取消上一个请求。但在这里我们刷新只是触发了触发器,无法将实际异步执行放入Job里

实际上,flatMapLatest会在上游状态更新的时候取消当前flow,这时候只需要在PagingSource的load方法里,异步请求后使用coroutineContext.ensureActive()来确认协程是否取消,就可以避免数据错乱问题

接着是下拉刷新指示器提前消失的问题,想到使用最简单的id来判断是否为最后一次刷新。每次请求生成一个独立id存储到当前id并传入PagingSource,当请求结束以后判断id是否和当前id匹配,若不匹配说明后续还进行了其它请求,故不取消刷新状态。

其实还有一些细节问题,如上划加载更多尚未测试,不过主要问题已经解决

补充

.cachedIn(viewModelScope)要放在外层嵌套,而不是紧跟.flow,避免切换到别的页面,再回来时触发重新collect导致列表刷新