Paging 3 的 RemoteMediator,缓存策略怎么设计
Paging 3 的 RemoteMediator,缓存策略怎么设计
一个让人困惑的 API 设计
RemoteMediator 的签名看起来足够简单。load() 方法返回 MediatorResult.Success 或者 MediatorResult.Error,endOfPaginationReached 标记是否还有下一页。但当我第一次在真实项目里尝试实现一个"下拉刷新保留缓存、上拉加载增量更新"的逻辑时,这个简洁的 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 构造参数里新增的 initialLoadKey 和 PagingConfig 的 jumpThreshold 配合可以缓解,但核心问题——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 -> UI。PagingSource 从 Room 读取,RemoteMediator 负责写。但 RemoteMediator 的 load() 返回后,Paging 框架会触发 PagingSource.invalidate(),UI 层重新订阅,整个列表重绘。这个 invalidate 是框架行为,你无法控制。
更关键的是,REFRESH 的语义在 Paging 内部和 PagingSource 的 load() 参数 LoadParams.Refresh 是绑定的。当你返回 MediatorResult.Success(endOfPaginationReached = false) 时,框架期望你提供了第一页数据,并且会重置内部的 page 计数器。如果你实际上没清空数据库,只是插入了新的第一页,旧数据的 page 标记就混乱了。
我试过一种 hack:在 REFRESH 时不操作数据库,把数据暂存到内存,然后在 PagingSource 里合并内存和数据库数据。但这破坏了 RemoteMediator 的设计意图,而且 PagingSource 的 load() 是同步阻塞的,不能挂起,从内存读数据需要额外的同步机制,复杂度爆炸。
这个方向走到死胡同后,我回头去看官方 issue。Paging 3.2.0-alpha03 引入了一个实验性 API:RemoteMediator 的 load() 方法在 REFRESH 场景下,可以通过 PagingState 获取当前列表的快照。state.pages 包含了已经加载的页信息,理论上可以据此做智能合并。但 PagingState 的 pages 属性在 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 的查询变得复杂,而且 DISTINCT 和 COUNT 这类聚合操作在带 IN 子集的查询上性能下降明显。我用 SQLite 的 EXPLAIN QUERY PLAN 分析过,当 visibleIds 超过 1000 个时,查询优化器会放弃索引,做全表扫描。对于 Feed 流这种可能加载几十页的场景,这个方案不可扩展。
更实际的折中方案是限制缓存深度。我最终采用了一个滑动窗口策略:Room 里只保留最近 N 页的数据,超出部分由 RemoteMediator 的 APPEND 逻辑自动清理。具体实现是在 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 的数据在 REFRESH 和 APPEND 重叠时会报主键冲突。我最初用了 @Insert(onConflict = OnConflictStrategy.REPLACE),但发现 Room 的 REPLACE 在 SQLite 层面是先 DELETE 后 INSERT,会触发外键级联删除,把关联表的数据也清掉。最终改成 OnConflictStrategy.IGNORE 配合业务层的显式更新,或者直接用 @Query 写 INSERT OR REPLACE INTO ... 并控制列的覆盖范围。
预加载与占位:PagingConfig 的隐藏交互
缓存策略不只是"存多少",还包括"什么时候触发加载"。PagingConfig 的 prefetchDistance 默认值是 pageSize,这个设定在 RemoteMediator 场景下有特殊的含义。
Paging 3 的加载触发逻辑是:当用户滚动到距离已加载内容末尾 prefetchDistance 个 item 时,发起 APPEND 请求。这个距离是相对于 PagingSource 返回的数据,不是 UI 的可见项。如果你的 PagingSource 查询被过滤过(比如上面的 visibleItemIds 方案),实际返回的数据量可能远小于 Room 里的存储量,导致 prefetchDistance 的计算基准失真。
我遇到的具体问题是:开启了 prefetchDistance = 10,但 PagingSource 因为过滤条件只返回了 3 条可见数据,用户滚动到第 2 条时就应该触发预加载,但框架认为还有 1 条才到边界,没有发起 APPEND。结果用户滑到底时看到 Loading,体验卡顿。
修复方案是调整 PagingConfig 的 enablePlaceholders = 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 里,如果不显式设置,框架根据 pageSize 和 prefetchDistance 动态计算,通常是一个比较大的值,快速滚动很少触发。但显式设置 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 的架构约束下是务实选择。
重试按钮的实现也有讲究。LazyPagingItems 的 retry() 方法只重试 PagingSource 的加载,不触发 RemoteMediator。如果 RemoteMediator 的 APPEND 失败了,用户点击"重试"实际上没任何效果。正确的做法是调用 refresh(),但这会重新走 REFRESH 流程,列表清空。我最终给 RemoteMediator 加了一个自定义的 retryChannel,在 Error 时把失败信息发送出去,UI 层通过 LaunchedEffect 监听,用户点击时直接向 channel 发送重试信号,load() 方法里用 select 同时监听框架调用和自定义信号。
这个方案依赖 RemoteMediator 的实例在 ViewModel 里持久化,不能用匿名类。而且 retryChannel 的并发控制要小心,快速点击重试可能触发多次重叠加载。我用 Mutex 保证了 load() 的串行执行,但 Mutex 的 tryLock 在 Paging 3 的协程调度器上偶尔出现假死,原因没完全查清,怀疑和 Dispatchers.IO 的线程池饥饿有关。最终改成 Semaphore(1) 配合 withTimeout 才稳定。
版本差异与迁移成本
Paging 3 的版本迭代对缓存策略有实质性影响。3.0.0 到 3.0.1 修复了 RemoteMediator 的内存泄漏,但 initialize() 的调用时机没变。3.1.0 引入了 PagingSource 的 getRefreshKey() 支持跳转到指定位置,和 RemoteMediator 的交互有微妙变化:如果 getRefreshKey() 返回非空,RemoteMediator 的 REFRESH 会带上对应的 PagingState,你可以从中读取 anchorPosition 来优化刷新起点。
但这个优化在 Feed 流场景下很难用。anchorPosition 是用户当前浏览的位置,基于这个位置刷新意味着后端要支持"从某个 item 开始向前/向后加载"的 API,大多数分页接口只有 page 和 pageSize 参数。我尝试把 anchorPosition 映射到页码,通过 RemoteKeys 反查,但 anchorPosition 是 Int 索引,在过滤后的列表里和数据库主键没有直接对应关系,映射逻辑复杂且易错。
3.2.0-alpha 系列开始支持 RemoteMediator 的 load() 返回 MediatorResult.Success 时附带 itemsBefore 和 itemsAfter,用于更精确的占位符计算。这个特性对缓存策略的影响是:你可以让 PagingSource 返回部分数据,但通过 itemsBefore/itemsAfter 告诉框架总数,从而优化滚动条和占位符表现。但这个 API 在 3.2.0 还是 @ExperimentalPagingApi,且和 Room 的 PagingSource 生成不兼容,需要手写 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 缓存策略可以概括为:
数据库层保留全量数据,但按 query 和 updatedAt 标记时效。RemoteMediator 的 REFRESH 不清空,只做增量 insertOrUpdate。PagingSource 查询时按 updatedAt 降序,保证新数据在前。UI 层通过 swipeRefresh 触发 refresh(),列表会闪一下(因为 PagingSource.invalidate()),但数据不会空白。
对于历史数据的清理,不在 RemoteMediator 里做,而是交给一个独立的 CacheManager,按应用生命周期或存储配额触发。CacheManager 直接操作 Room,删除 updatedAt 超过 7 天的数据,同时清理孤儿 RemoteKeys。这个清理和 RemoteMediator 的加载可能并发,靠 Room 的 TRANSACTION 和 SQLite 的锁机制保证一致性。
RemoteMediator 的 load() 方法里,REFRESH 和 APPEND 都写入数据,但 PREPEND 直接返回 Success(endOfPaginationReached = true),因为 Feed 流通常不需要向上加载历史。如果业务需要,可以把 PREPEND 也实现为增量加载,但要注意 RemoteKeys 的 prevKey 维护。
错误处理上,网络错误返回 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 是额外加的表,按错误类型和时间段统计。过度设计了一点,但线上问题排查时确实有用。
还没解决的问题
有几个点我至今没找到优雅方案。
一个是 RemoteMediator 和 PagingSource 的并发加载。用户快速切换筛选条件时,旧的 RemoteMediator 实例可能还在执行 load(),新的实例已经创建。如果它们共享同一个 Room 数据库表,写入冲突不可避免。我目前的做法是给每个查询生成唯一的 queryId,RemoteKeys 和 item 表都带 queryId 字段,物理隔离不同查询的数据。但这样表膨胀很快,且 PagingSource 的查询条件变复杂。
另一个是 RemoteMediator 的测试。RemoteMediator 的 load() 依赖 PagingState,而 PagingState 的构造是内部的,测试里很难模拟。官方提供了 TestPager 类(androidx.paging:paging-testing),但 TestPager 的 refresh() 和 append() 方法在 3.1.1 版本有 bug,RemoteMediator 的 initialize() 不会被调用。我提的 issue #339 到现在还是 open 状态。实际测试靠的都是集成测试,跑在真机上,用 IdlingResource 等加载完成,效率很低。
最后,Compose 和 Paging 3 的集成在复杂列表场景下仍有性能问题。LazyColumn 的 items 扩展配合 LazyPagingItems 时,每个 item 的重组粒度太粗。我试过用 derivedStateOf 优化,但 PagingData 的不可变性让细粒度优化很难下手。Google 的 Now in Android 项目里也有类似的性能 TODO,说明这不是我一个人的困境。
RemoteMediator 的设计初衷是好的——把分页加载的远程逻辑和本地缓存解耦。但在真实项目的复杂性面前,这个解耦带来了新的耦合点。缓存策略的设计不是选一个"最佳方案",而是在 Paging 3 的约束条件下,找到和业务需求最匹配的折中。我的方案未必适合你,但希望这些踩坑细节能帮你少走些弯路。