Paging 3 RemoteMediator 实战:构建离线优先的分页列表
只从网络分页,断网后页面就会失去内容;只从数据库读取,又需要自己处理拉取、缓存和翻页。RemoteMediator 把两者连接起来:界面始终观察 Room,网络负责按需补充数据。本文以资讯列表为例,逐步实现一套可刷新、可续页、可离线浏览的分页方案,并解释其中最容易出错的数据一致性问题。
为什么要让数据库成为唯一数据源
一个常见实现是首次进入页面请求网络,失败时再读取缓存。这个方案看似直接,却会产生两套状态:网络结果一套,数据库结果一套。刷新、删除、排序变化后,两套数据很容易不同步。
离线优先架构采用另一种数据流:
- UI 只订阅 Room 返回的
PagingSource; RemoteMediator判断何时请求网络;- 网络结果在事务中写入 Room;
- Room 变更后自动使旧的
PagingSource失效; - UI 从新快照中得到最新列表。
这样,网络响应不会直接交给页面。数据库是页面状态的唯一事实来源,在线和离线走的是同一条渲染链路。
数据表不能只存业务实体
假设服务端接口按页码返回资讯:
kotlin
data class ArticleDto(
val id: Long,
val title: String,
val summary: String,
val publishedAt: Long
)
data class ArticlePage(
val items: List<ArticleDto>,
val page: Int,
val hasMore: Boolean
)
本地至少需要业务表和远程键表。业务表保存页面真正展示的数据:
kotlin
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: Long,
val title: String,
val summary: String,
val publishedAt: Long
)
远程键记录每条数据相邻页的位置。它不是页面展示数据,却是恢复分页边界的关键:
kotlin
@Entity(tableName = "article_remote_keys")
data class ArticleRemoteKey(
@PrimaryKey val articleId: Long,
val previousPage: Int?,
val nextPage: Int?
)
不要用"当前请求到了哪一页"这样的内存变量代替远程键。进程重建、主动刷新或列表重新创建后,内存变量会丢失,也无法和数据库快照保持原子一致。
DAO 与数据库定义
PagingSource 的查询顺序必须稳定。若多个数据拥有相同发布时间,应增加主键作为次级排序条件,否则翻页过程中可能出现顺序抖动。
kotlin
@Dao
interface ArticleDao {
@Query(
"""
SELECT * FROM articles
ORDER BY publishedAt DESC, id DESC
"""
)
fun pagingSource(): PagingSource<Int, ArticleEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<ArticleEntity>)
@Query("DELETE FROM articles")
suspend fun clearAll()
}
@Dao
interface ArticleRemoteKeyDao {
@Query("SELECT * FROM article_remote_keys WHERE articleId = :id")
suspend fun remoteKeyById(id: Long): ArticleRemoteKey?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(keys: List<ArticleRemoteKey>)
@Query("DELETE FROM article_remote_keys")
suspend fun clearAll()
}
数据库还需要暴露事务能力:
kotlin
@Database(
entities = [ArticleEntity::class, ArticleRemoteKey::class],
version = 1
)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
abstract fun articleRemoteKeyDao(): ArticleRemoteKeyDao
}
实现 RemoteMediator 的加载决策
load() 会收到三种加载类型:
REFRESH:首次加载或用户主动刷新;PREPEND:向列表头部加载;APPEND:向列表尾部加载。
对于只支持向后翻页的接口,PREPEND 可以直接结束。REFRESH 从起始页请求,APPEND 则从列表末尾数据对应的远程键中取得下一页。
kotlin
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val api: ArticleApi,
private val database: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
private val articleDao = database.articleDao()
private val remoteKeyDao = database.articleRemoteKeyDao()
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticleEntity>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> STARTING_PAGE
LoadType.PREPEND -> return MediatorResult.Success(
endOfPaginationReached = true
)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
?: return MediatorResult.Success(
endOfPaginationReached = false
)
val key = remoteKeyDao.remoteKeyById(lastItem.id)
?: return MediatorResult.Success(
endOfPaginationReached = false
)
key.nextPage
?: return MediatorResult.Success(
endOfPaginationReached = true
)
}
}
return try {
val response = api.getArticles(
page = page,
pageSize = state.config.pageSize
)
val entities = response.items.map(ArticleDto::toEntity)
val endReached = !response.hasMore || entities.isEmpty()
database.withTransaction {
if (loadType == LoadType.REFRESH) {
remoteKeyDao.clearAll()
articleDao.clearAll()
}
val previousPage = if (page == STARTING_PAGE) null else page - 1
val nextPage = if (endReached) null else page + 1
val keys = entities.map { article ->
ArticleRemoteKey(
articleId = article.id,
previousPage = previousPage,
nextPage = nextPage
)
}
remoteKeyDao.insertAll(keys)
articleDao.upsertAll(entities)
}
MediatorResult.Success(endOfPaginationReached = endReached)
} catch (exception: IOException) {
MediatorResult.Error(exception)
} catch (exception: HttpException) {
MediatorResult.Error(exception)
}
}
private fun ArticleDto.toEntity() = ArticleEntity(
id = id,
title = title,
summary = summary,
publishedAt = publishedAt
)
companion object {
private const val STARTING_PAGE = 1
}
}
这里有一个容易忽略的细节:当 APPEND 时列表暂时为空,不应立刻断定已经到达末尾。返回 endOfPaginationReached = false,让 Paging 在数据库快照稳定后继续判断,通常更符合预期。
为什么写入必须放在同一个事务里
刷新时需要依次清理远程键、清理旧数据、写入新远程键、写入新数据。如果这些操作不在同一个事务中,应用可能在中间状态被终止,留下"有数据但没有键"或"有键但没有数据"的数据库。
事务还避免 UI 观察到短暂的空列表。Room 会在事务提交后统一通知查询失效,页面拿到的是一致的新快照。
不过,刷新时无条件清空也有体验代价:服务端请求成功但返回解析异常时,旧缓存可能已被删除。正确顺序是先在事务外完成请求和数据转换,确认结果可用后,再进入事务替换缓存。
配置 Pager 和 Repository
Repository 同时提供 RemoteMediator 和 Room 的 PagingSource:
kotlin
class ArticleRepository(
private val api: ArticleApi,
private val database: AppDatabase
) {
@OptIn(ExperimentalPagingApi::class)
fun articleStream(): Flow<PagingData<ArticleEntity>> = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
initialLoadSize = 40,
enablePlaceholders = false
),
remoteMediator = ArticleRemoteMediator(api, database),
pagingSourceFactory = { database.articleDao().pagingSource() }
).flow
}
pageSize 最好和服务端支持的分页大小一致。prefetchDistance 太大会过早触发请求,太小则可能在用户滑到底部时出现等待。是否开启占位符取决于数据源能否提供稳定总数,普通网络分页通常关闭更简单。
ViewModel 应缓存分页流,避免配置变化后重新建立整条加载链路:
kotlin
class ArticleViewModel(
repository: ArticleRepository
) : ViewModel() {
val articles = repository.articleStream()
.cachedIn(viewModelScope)
}
页面要区分刷新和追加状态
CombinedLoadStates 同时包含本地 source 和远端 mediator 的状态。离线优先页面通常更关心 mediator,因为网络错误来自这里,而已缓存的数据仍可能正常显示。
kotlin
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.articles.collectLatest(adapter::submitData)
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
adapter.loadStateFlow.collectLatest { states ->
val refresh = states.mediator?.refresh
binding.progress.isVisible =
refresh is LoadState.Loading && adapter.itemCount == 0
binding.swipeRefresh.isRefreshing =
refresh is LoadState.Loading && adapter.itemCount > 0
binding.errorGroup.isVisible =
refresh is LoadState.Error && adapter.itemCount == 0
binding.offlineHint.isVisible =
refresh is LoadState.Error && adapter.itemCount > 0
}
}
}
有缓存时网络失败,不应把整个页面替换成错误页。保留列表并显示轻量提示,用户仍可浏览本地内容。只有无缓存且刷新失败时,才展示全屏错误状态。
列表底部可以使用 LoadStateAdapter 展示追加加载和重试按钮:
kotlin
binding.recyclerView.adapter = adapter.withLoadStateFooter(
footer = ArticleLoadStateAdapter(adapter::retry)
)
主动刷新调用 adapter.refresh(),失败后的原请求重试调用 adapter.retry()。两者语义不同,不应混用。
缓存过期策略
默认情况下,每次创建新的 Pager 都可能触发刷新。若希望短时间内复用缓存,可覆写 initialize():
kotlin
override suspend fun initialize(): InitializeAction {
val lastUpdated = cacheMetaDao.lastUpdatedAt() ?: return LAUNCH_INITIAL_REFRESH
val cacheTimeout = TimeUnit.MINUTES.toMillis(30)
return if (System.currentTimeMillis() - lastUpdated < cacheTimeout) {
SKIP_INITIAL_REFRESH
} else {
LAUNCH_INITIAL_REFRESH
}
}
更新时间应在成功写入网络结果的同一事务中更新。不要只记录"请求发起时间",否则失败请求也会让陈旧缓存被误判为新鲜。
内容型列表可以使用较长缓存时间,价格、库存等高时效数据则应缩短,甚至每次进入都刷新。缓存策略是业务决策,不是一个适用于所有页面的固定常量。
排查重复、跳页与死循环
线上分页问题往往不是 Paging 本身失效,而是边界定义不一致。可以按以下顺序检查:
- 服务端排序是否稳定,翻页期间新数据插入会不会改变页码边界;
- 业务表主键是否真能唯一标识条目;
nextPage是否在末页正确写为null;- 空响应是否被识别为分页结束;
- 刷新时业务表和远程键是否一起清理;
- DAO 查询排序是否与服务端排序一致;
- 多个筛选条件是否错误共用了同一套缓存和远程键。
若列表支持分类、关键词或用户维度,远程键的主键不能只有 articleId。更稳妥的方式是为查询参数生成 queryKey,远程键使用 (queryKey, articleId) 复合主键,业务缓存也要明确数据属于哪个查询。否则切换筛选条件后,会读到上一种条件留下的翻页位置。
页码分页天然容易受服务端数据插入影响。接口可控时,优先使用稳定游标,让服务端返回 nextCursor,远程键保存游标而不是页码,可以显著减少重复和遗漏。
测试加载状态与事务结果
RemoteMediator 的核心逻辑可以使用内存 Room 和假接口测试。至少覆盖这些分支:
- 首次刷新写入数据及远程键;
- 刷新替换旧缓存;
- 追加请求使用正确的下一页;
- 末页返回分页结束;
- 网络异常保留旧缓存;
- 重复主键被更新而不是插入两份;
- 不同查询条件之间互不污染。
测试时不要只断言 MediatorResult.Success,还应查询数据库,验证业务数据、顺序和远程键是一致的。真正影响页面的是事务提交后的数据库状态。
适用边界
RemoteMediator 适合列表较大、需要增量加载、允许本地缓存,并且服务端具有明确分页协议的场景。数据量很小的一次性接口没有必要引入整套分页基础设施;实时消息流也更适合 WebSocket、增量同步和独立的本地落库策略。
离线优先不等于永不请求网络,而是把缓存、同步和展示的职责划清:UI 可信地读取本地状态,网络同步可失败、可重试,数据库提交保证状态一致。当这些边界稳定后,分页列表才能在断网、进程重建和频繁刷新下保持可预测。