Paging 3 的 RemoteMediator,缓存策略怎么设计

Paging 3 的 RemoteMediator,缓存策略怎么设计

Paging 3 的 RemoteMediator,缓存策略怎么设计


Paging 3 的 RemoteMediator,缓存策略怎么设计


一个让人困惑的 API 设计


RemoteMediator 的签名看起来足够简单。load() 方法返回 MediatorResult.Success 或者 MediatorResult.ErrorendOfPaginationReached 标记是否还有下一页。但当我第一次在真实项目里尝试实现一个"下拉刷新保留缓存、上拉加载增量更新"的逻辑时,这个简洁的 API 背后藏着的复杂性才真正暴露出来。


问题出在 InitializeAction 上。RemoteMediator 在 initialize() 方法里返回三种行为:LAUNCH_INITIAL_REFRESH 要求强制刷新,SKIP_INITIAL_REFRESH 直接跳过,REQUIRED 则让框架自己判断。Google 官方文档里的示例几乎清一色返回 LAUNCH_INITIAL_REFRESH,配合 RemoteKeys 表来记录分页状态。但这里有个坑:LAUNCH_INITIAL_REFRESH 会触发 load()REFRESH 操作,而 REFRESH 在 Paging 3 的语义里是"清空现有数据,重新加载"。


这意味着什么?如果你的用户正在浏览第 5 页的内容,手滑下拉了一下,整个列表会被清空,滚动位置丢失,用户一脸茫然地看着 Loading 转圈。这个行为在 Paging 3.0.0 到 3.1.0 之间没有任何官方配置项可以绕过。我当时在 GitHub issue #221 里看到大量开发者抱怨这个问题,Google 的回应直到 3.1.1 版本才给出一个部分解决方案:remoteMediator 构造参数里新增的 initialLoadKeyPagingConfigjumpThreshold 配合可以缓解,但核心问题——REFRESH 到底该不该清缓存——依然悬在那里。


我的第一次尝试:无脑清空的代价


项目背景是一个内容聚合应用,Feed 流混合了推荐内容和关注内容,RemoteMediator 负责从后端拉取分页数据写入 Room。我最初的实现照搬了官方架构示例:


override suspend fun initialize(): InitializeAction {
    val cacheTimeout = TimeUnit.HOURS.toMillis(1)
    return if (System.currentTimeMillis() - db.remoteKeysDao().lastUpdated() < cacheTimeout) {
        SKIP_INITIAL_REFRESH
    } else {
        LAUNCH_INITIAL_REFRESH
    }
}

load() 方法里,REFRESH 分支直接 db.clearAllTables(),然后重新插入第一页数据。这个逻辑在实验室环境跑得很顺,直到线上崩溃报告砸过来。


问题一:用户网络不稳定时,REFRESH 清空了本地数据,但远程请求失败,列表变成空白页。MediatorResult.Error 不会自动回滚数据库事务,因为 Paging 3 的 load() 方法要求你在返回前自己完成数据库操作。我漏掉了 withTransaction 包裹的异常处理,导致半清半写状态。


问题二:更隐蔽的是 RemoteKeys 表的同步问题。REFRESH 时我清空了 item 表,但忘了清空 remote_keys 表,或者清空顺序不对。Paging 3 的 getItemCount()remoteKeysDao().remoteKeysByQuery() 在并发场景下会出现竞态。具体表现是:用户快速切换筛选条件时,新查询的 REFRESH 和老查询的 APPEND 重叠,RemoteKeys 表里混入了不同 query 的 key 值,导致后续分页请求发送错误的 page 参数,后端返回 404,Mediator 进入死循环,不断重试错误的页码。


这个竞态在 Paging 3.0.1 版本尤其严重,因为 RemoteMediatorAccessor 的实现里,initialize()load() 的调用没有强制序列化。3.1.0-alpha01 改进了锁粒度,但根本问题还是开发者自己对事务边界的理解。


我当时的修复是把所有数据库操作塞进一个 withTransaction 块,并且给 RemoteKeys 表增加了 query 字段作为联合主键的一部分:


@Entity(tableName = "remote_keys", primaryKeys = ["query", "id"])
data class RemoteKeys(
    val query: String,
    val id: String,
    val prevKey: Int?,
    val nextKey: Int?
)

load()REFRESH 分支变成:


db.withTransaction {
    db.remoteKeysDao().clearByQuery(query)
    db.itemDao().clearByQuery(query)
    val keys = page.map { RemoteKeys(query, it.id, prevKey, nextKey) }
    db.remoteKeysDao().insertAll(keys)
    db.itemDao().insertAll(page)
}

但即使这样,"下拉刷新清空列表"的体验问题没解决。用户反馈很直接:"刷新的时候内容闪一下没了,很吓人"。


探索增量刷新:RemoteMediator 的边界


我试图让 REFRESH 不清空数据,而是增量更新。想法很直接:后端返回第一页,和本地对比,只更新变更项。但 RemoteMediator 的架构设计在这里露出了它的局限性。


Paging 3 的数据流是 RemoteMediator -> PagingSource -> PagingData -> UIPagingSource 从 Room 读取,RemoteMediator 负责写。但 RemoteMediatorload() 返回后,Paging 框架会触发 PagingSource.invalidate(),UI 层重新订阅,整个列表重绘。这个 invalidate 是框架行为,你无法控制。


更关键的是,REFRESH 的语义在 Paging 内部和 PagingSourceload() 参数 LoadParams.Refresh 是绑定的。当你返回 MediatorResult.Success(endOfPaginationReached = false) 时,框架期望你提供了第一页数据,并且会重置内部的 page 计数器。如果你实际上没清空数据库,只是插入了新的第一页,旧数据的 page 标记就混乱了。


我试过一种 hack:在 REFRESH 时不操作数据库,把数据暂存到内存,然后在 PagingSource 里合并内存和数据库数据。但这破坏了 RemoteMediator 的设计意图,而且 PagingSourceload() 是同步阻塞的,不能挂起,从内存读数据需要额外的同步机制,复杂度爆炸。


这个方向走到死胡同后,我回头去看官方 issue。Paging 3.2.0-alpha03 引入了一个实验性 API:RemoteMediatorload() 方法在 REFRESH 场景下,可以通过 PagingState 获取当前列表的快照。state.pages 包含了已经加载的页信息,理论上可以据此做智能合并。但 PagingStatepages 属性在 REFRESH 时通常是空的,因为框架在调用 RemoteMediator.load(REFRESH) 之前已经标记了 PagingSource 失效。这个 API 的设计意图和实际能力之间存在落差,我最终没有采用。


转向双层缓存:Room 之外的策略


既然 RemoteMediator 的 REFRESH 语义不可撼动,我开始考虑在 Room 之上再加一层缓存。具体方案是:RemoteMediator 只负责写原始数据到 Room,不做清空操作;UI 层通过自定义的 PagingData 变换来过滤数据。


实现上,我引入了一个 visibleItemIds 的 Flow,由业务逻辑控制。PagingSource 仍然查询 Room,但查询条件加入 WHERE id IN (:visibleIds)REFRESH 时 RemoteMediator 写入新数据到 Room,同时更新 visibleItemIds 为最新第一页的 id 列表。这样 UI 层看到的"有效数据"是增量更新的,Room 里实际上保留了历史数据。


这个方案的代价是 PagingSource 的查询变得复杂,而且 DISTINCTCOUNT 这类聚合操作在带 IN 子集的查询上性能下降明显。我用 SQLite 的 EXPLAIN QUERY PLAN 分析过,当 visibleIds 超过 1000 个时,查询优化器会放弃索引,做全表扫描。对于 Feed 流这种可能加载几十页的场景,这个方案不可扩展。


更实际的折中方案是限制缓存深度。我最终采用了一个滑动窗口策略:Room 里只保留最近 N 页的数据,超出部分由 RemoteMediatorAPPEND 逻辑自动清理。具体实现是在 load(APPEND) 成功后,检查当前总页数,如果超过阈值,删除最老的页对应的 RemoteKeys 和 item 数据。


override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, Item>
): MediatorResult {
    val page = when (loadType) {
        LoadType.REFRESH -> 1
        LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
        LoadType.APPEND -> {
            val remoteKeys = db.withTransaction {
                db.remoteKeysDao().remoteKeysByQuery(query, state.lastItemOrNull()?.id)
            }
            remoteKeys?.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true)
        }
    }

    val response = api.fetchPage(query, page, state.config.pageSize)
    
    db.withTransaction {
        if (loadType == LoadType.REFRESH) {
            // 不清空,只更新或插入
        }
        
        val prevKey = if (page == 1) null else page - 1
        val nextKey = if (response.isEmpty()) null else page + 1
        val keys = response.map { RemoteKeys(query, it.id, prevKey, nextKey) }
        
        db.remoteKeysDao().insertAll(keys)
        db.itemDao().insertOrUpdate(response)
        
        // 滑动窗口清理
        if (loadType == LoadType.APPEND) {
            val totalPages = db.remoteKeysDao().pageCountByQuery(query)
            if (totalPages > MAX_CACHED_PAGES) {
                val oldestPage = db.remoteKeysDao().oldestPageByQuery(query)
                oldestPage?.let { db.itemDao().deleteByPage(query, it) }
                db.remoteKeysDao().deleteByPage(query, oldestPage)
            }
        }
    }
    
    return MediatorResult.Success(endOfPaginationReached = response.isEmpty())
}

这里有个细节:insertOrUpdate 的实现要用 ON CONFLICT 策略,否则同一 id 的数据在 REFRESHAPPEND 重叠时会报主键冲突。我最初用了 @Insert(onConflict = OnConflictStrategy.REPLACE),但发现 Room 的 REPLACE 在 SQLite 层面是先 DELETEINSERT,会触发外键级联删除,把关联表的数据也清掉。最终改成 OnConflictStrategy.IGNORE 配合业务层的显式更新,或者直接用 @QueryINSERT OR REPLACE INTO ... 并控制列的覆盖范围。


预加载与占位:PagingConfig 的隐藏交互


缓存策略不只是"存多少",还包括"什么时候触发加载"。PagingConfigprefetchDistance 默认值是 pageSize,这个设定在 RemoteMediator 场景下有特殊的含义。


Paging 3 的加载触发逻辑是:当用户滚动到距离已加载内容末尾 prefetchDistance 个 item 时,发起 APPEND 请求。这个距离是相对于 PagingSource 返回的数据,不是 UI 的可见项。如果你的 PagingSource 查询被过滤过(比如上面的 visibleItemIds 方案),实际返回的数据量可能远小于 Room 里的存储量,导致 prefetchDistance 的计算基准失真。


我遇到的具体问题是:开启了 prefetchDistance = 10,但 PagingSource 因为过滤条件只返回了 3 条可见数据,用户滚动到第 2 条时就应该触发预加载,但框架认为还有 1 条才到边界,没有发起 APPEND。结果用户滑到底时看到 Loading,体验卡顿。


修复方案是调整 PagingConfigenablePlaceholders = true。占位符会让 PagingData 包含完整的列表骨架,即使 PagingSource 只返回了部分数据,框架也能正确计算距离边界的位置。但占位符在 Compose 的 LazyColumn 里需要特殊处理,itemContent 会收到 null 值,要渲染占位 UI。


LazyColumn {
    items(
        items = pagingItems,
        key = { it?.id ?: it.hashCode() }
    ) { item ->
        if (item != null) {
            ItemCard(item)
        } else {
            ItemPlaceholder()
        }
    }
}

key 的取值也要小心。占位符的 key 不能重复,否则 LazyColumn 的复用机制会崩溃。我最初写 key = { it?.id ?: 0 },结果所有占位符共享 key 0,滚动时 Compose 报错 IllegalArgumentException: Key 0 was already used。改成 it.hashCode() 结合占位符在 PagingData 中的位置索引才解决。


另一个 PagingConfig 的参数 jumpThreshold 在 RemoteMediator 场景下几乎没人提到,但和缓存策略密切相关。当用户快速滚动时,如果目标位置距离当前加载窗口超过 jumpThreshold,Paging 会放弃增量 APPEND,直接发起 REFRESH 从目标位置附近加载。这个 REFRESH 同样会触发 RemoteMediator.load(REFRESH),如果你的缓存策略是"不清空",这里就会出现数据断层——中间页的数据缺失,但前后页存在。


我测过 jumpThreshold 的默认值行为。在 Paging 3.1.1 里,如果不显式设置,框架根据 pageSizeprefetchDistance 动态计算,通常是一个比较大的值,快速滚动很少触发。但显式设置 jumpThreshold = 50 后,长列表的跳跃加载明显变频繁,RemoteMediator 的 REFRESH 调用次数上升,缓存命中率下降。最终我去掉了显式设置,让框架自己决定。


错误处理与重试:MediatorResult 的语义陷阱


MediatorResult.Error 的文档说明是"加载失败,框架会重试"。但这个重试策略的细节在文档里语焉不详。


实际测试下来(Paging 3.1.1,Android 12 设备),Error 后的重试间隔是指数退避的,初始约 1 秒,最大到约 30 秒。重试时 loadType 保持不变,但 state 参数会更新为当前列表状态。如果你的 REFRESH 失败了,框架会不断重试 REFRESH,每次调用 initialize() 检查是否该跳过。如果 initialize() 返回 LAUNCH_INITIAL_REFRESH,就继续重试;返回 SKIP_INITIAL_REFRESH,则暂停重试直到下次列表订阅。


这个行为导致一个坑:我在 initialize() 里加了网络状态判断,无网络时返回 SKIP_INITIAL_REFRESH。但用户从飞行模式切回正常网络后,Paging 框架不会自动重新调用 initialize(),列表一直显示旧数据或空白。必须配合 PagingDataAdapter.refresh()LazyPagingItems.refresh() 主动触发。


更隐蔽的是 Error 后的 PagingSource 行为。RemoteMediator.load() 返回 Error 时,框架会标记 PagingSource 为需要重试,但不会立即 invalidate()。这意味着 UI 层可能显示着不完整的数据,同时后台在默默重试。如果你的 Error 是因为后端返回了部分成功数据(比如 200 响应体里有个 partial: true 标记),这部分数据已经写入 Room,但框架认为整个加载失败,逻辑上存在不一致。


我处理这种"部分成功"的方式是:在 load() 里自己判断业务状态,能写入的数据尽量写入,只有完全不可恢复的错误才返回 Error。对于可恢复的错误(如单个 item 解析失败),返回 Success(endOfPaginationReached = false) 但记录日志,让框架继续正常分页。这种"谎报成功"的做法不太优雅,但在 Paging 3 的架构约束下是务实选择。


重试按钮的实现也有讲究。LazyPagingItemsretry() 方法只重试 PagingSource 的加载,不触发 RemoteMediator。如果 RemoteMediatorAPPEND 失败了,用户点击"重试"实际上没任何效果。正确的做法是调用 refresh(),但这会重新走 REFRESH 流程,列表清空。我最终给 RemoteMediator 加了一个自定义的 retryChannel,在 Error 时把失败信息发送出去,UI 层通过 LaunchedEffect 监听,用户点击时直接向 channel 发送重试信号,load() 方法里用 select 同时监听框架调用和自定义信号。


这个方案依赖 RemoteMediator 的实例在 ViewModel 里持久化,不能用匿名类。而且 retryChannel 的并发控制要小心,快速点击重试可能触发多次重叠加载。我用 Mutex 保证了 load() 的串行执行,但 MutextryLock 在 Paging 3 的协程调度器上偶尔出现假死,原因没完全查清,怀疑和 Dispatchers.IO 的线程池饥饿有关。最终改成 Semaphore(1) 配合 withTimeout 才稳定。


版本差异与迁移成本


Paging 3 的版本迭代对缓存策略有实质性影响。3.0.0 到 3.0.1 修复了 RemoteMediator 的内存泄漏,但 initialize() 的调用时机没变。3.1.0 引入了 PagingSourcegetRefreshKey() 支持跳转到指定位置,和 RemoteMediator 的交互有微妙变化:如果 getRefreshKey() 返回非空,RemoteMediatorREFRESH 会带上对应的 PagingState,你可以从中读取 anchorPosition 来优化刷新起点。


但这个优化在 Feed 流场景下很难用。anchorPosition 是用户当前浏览的位置,基于这个位置刷新意味着后端要支持"从某个 item 开始向前/向后加载"的 API,大多数分页接口只有 pagepageSize 参数。我尝试把 anchorPosition 映射到页码,通过 RemoteKeys 反查,但 anchorPositionInt 索引,在过滤后的列表里和数据库主键没有直接对应关系,映射逻辑复杂且易错。


3.2.0-alpha 系列开始支持 RemoteMediatorload() 返回 MediatorResult.Success 时附带 itemsBeforeitemsAfter,用于更精确的占位符计算。这个特性对缓存策略的影响是:你可以让 PagingSource 返回部分数据,但通过 itemsBefore/itemsAfter 告诉框架总数,从而优化滚动条和占位符表现。但这个 API 在 3.2.0 还是 @ExperimentalPagingApi,且和 RoomPagingSource 生成不兼容,需要手写 PagingSource,维护成本上升。


我目前项目锁定在 Paging 3.1.1,没有升级到 3.2.0。原因是 3.2.0 的 RemoteMediator 内部改用了 kotlinx.coroutines.flow 的重试机制,和项目里已有的 RxJava 桥接层冲突。androidx.paging:paging-runtime 从 3.1.1 到 3.2.0 的 transitive dependency 变化引入了 kotlinx-coroutines-core 的版本升级,和另一个依赖的 coroutines 版本不兼容,Gradle 的 force 解决不了运行时 NoSuchMethodError。这种版本地狱在 Android 生态里太常见,我选择稳定优先。


一个务实的最终方案


经过几轮迭代,我现在的 RemoteMediator 缓存策略可以概括为:


数据库层保留全量数据,但按 queryupdatedAt 标记时效。RemoteMediatorREFRESH 不清空,只做增量 insertOrUpdatePagingSource 查询时按 updatedAt 降序,保证新数据在前。UI 层通过 swipeRefresh 触发 refresh(),列表会闪一下(因为 PagingSource.invalidate()),但数据不会空白。


对于历史数据的清理,不在 RemoteMediator 里做,而是交给一个独立的 CacheManager,按应用生命周期或存储配额触发。CacheManager 直接操作 Room,删除 updatedAt 超过 7 天的数据,同时清理孤儿 RemoteKeys。这个清理和 RemoteMediator 的加载可能并发,靠 Room 的 TRANSACTIONSQLite 的锁机制保证一致性。


RemoteMediatorload() 方法里,REFRESHAPPEND 都写入数据,但 PREPEND 直接返回 Success(endOfPaginationReached = true),因为 Feed 流通常不需要向上加载历史。如果业务需要,可以把 PREPEND 也实现为增量加载,但要注意 RemoteKeysprevKey 维护。


错误处理上,网络错误返回 Error,但 4xx/5xx 的区分很重要。400 错误(如参数非法)重试无意义,应该直接 Error 且不再自动重试;500 错误可以重试。Paging 3 的 RemoteMediator 没有内置的 HTTP 状态码感知,需要自己实现退避逻辑。我在 load() 里加了一个 errorCount 的持久化计数,连续错误 3 次后强制返回 Success(endOfPaginationReached = true) 终止加载,避免无限重试耗尽电池。


override suspend fun load(loadType: LoadType, state: PagingState<Int, Item>): MediatorResult {
    return try {
        // ... 正常加载逻辑
    } catch (e: HttpException) {
        val shouldRetry = when (e.code()) {
            in 500..599 -> {
                val count = db.errorLogDao().incrementAndGet("remote_mediator")
                count < 3
            }
            else -> false
        }
        if (shouldRetry) MediatorResult.Error(e) else MediatorResult.Success(true)
    }
}

这个 errorLogDao 是额外加的表,按错误类型和时间段统计。过度设计了一点,但线上问题排查时确实有用。


还没解决的问题


有几个点我至今没找到优雅方案。


一个是 RemoteMediatorPagingSource 的并发加载。用户快速切换筛选条件时,旧的 RemoteMediator 实例可能还在执行 load(),新的实例已经创建。如果它们共享同一个 Room 数据库表,写入冲突不可避免。我目前的做法是给每个查询生成唯一的 queryIdRemoteKeys 和 item 表都带 queryId 字段,物理隔离不同查询的数据。但这样表膨胀很快,且 PagingSource 的查询条件变复杂。


另一个是 RemoteMediator 的测试。RemoteMediatorload() 依赖 PagingState,而 PagingState 的构造是内部的,测试里很难模拟。官方提供了 TestPager 类(androidx.paging:paging-testing),但 TestPagerrefresh()append() 方法在 3.1.1 版本有 bug,RemoteMediatorinitialize() 不会被调用。我提的 issue #339 到现在还是 open 状态。实际测试靠的都是集成测试,跑在真机上,用 IdlingResource 等加载完成,效率很低。


最后,ComposePaging 3 的集成在复杂列表场景下仍有性能问题。LazyColumnitems 扩展配合 LazyPagingItems 时,每个 item 的重组粒度太粗。我试过用 derivedStateOf 优化,但 PagingData 的不可变性让细粒度优化很难下手。Google 的 Now in Android 项目里也有类似的性能 TODO,说明这不是我一个人的困境。


RemoteMediator 的设计初衷是好的——把分页加载的远程逻辑和本地缓存解耦。但在真实项目的复杂性面前,这个解耦带来了新的耦合点。缓存策略的设计不是选一个"最佳方案",而是在 Paging 3 的约束条件下,找到和业务需求最匹配的折中。我的方案未必适合你,但希望这些踩坑细节能帮你少走些弯路。

Docker 构建 Android 环境,镜像层缓存策略 2026-06-30
Ktor 客户端替代 Retrofit,协程原生支持 2026-06-30

评论区