只从网络分页,断网后页面就会失去内容;只从数据库读取,又需要自己处理拉取、缓存和翻页。`RemoteMediator` 把两者连接起来:界面始终观察 Room,网络负责按需补充数据。本文以资讯列表为例,逐步实现一套可刷新、可续页、可离线浏览的分页方案,并解释其中最容易出错的数据一致性问题。
一个常见实现是首次进入页面请求网络,失败时再读取缓存。这个方案看似直接,却会产生两套状态:网络结果一套,数据库结果一套。刷新、删除、排序变化后,两套数据很容易不同步。
离线优先架构采用另一种数据流:
这样,网络响应不会直接交给页面。数据库是页面状态的唯一事实来源,在线和离线走的是同一条渲染链路。
假设服务端接口按页码返回资讯:
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
)本地至少需要业务表和远程键表。业务表保存页面真正展示的数据:
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: Long,
val title: String,
val summary: String,
val publishedAt: Long
)远程键记录每条数据相邻页的位置。它不是页面展示数据,却是恢复分页边界的关键:
@Entity(tableName = "article_remote_keys")
data class ArticleRemoteKey(
@PrimaryKey val articleId: Long,
val previousPage: Int?,
val nextPage: Int?
)不要用“当前请求到了哪一页”这样的内存变量代替远程键。进程重建、主动刷新或列表重新创建后,内存变量会丢失,也无法和数据库快照保持原子一致。
`PagingSource` 的查询顺序必须稳定。若多个数据拥有相同发布时间,应增加主键作为次级排序条件,否则翻页过程中可能出现顺序抖动。
@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()
}数据库还需要暴露事务能力:
@Database(
entities = [ArticleEntity::class, ArticleRemoteKey::class],
version = 1
)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
abstract fun articleRemoteKeyDao(): ArticleRemoteKeyDao
}`load()` 会收到三种加载类型:
对于只支持向后翻页的接口,`PREPEND` 可以直接结束。`REFRESH` 从起始页请求,`APPEND` 则从列表末尾数据对应的远程键中取得下一页。
@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 会在事务提交后统一通知查询失效,页面拿到的是一致的新快照。
不过,刷新时无条件清空也有体验代价:服务端请求成功但返回解析异常时,旧缓存可能已被删除。正确顺序是先在事务外完成请求和数据转换,确认结果可用后,再进入事务替换缓存。
Repository 同时提供 `RemoteMediator` 和 Room 的 `PagingSource`:
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 应缓存分页流,避免配置变化后重新建立整条加载链路:
class ArticleViewModel(
repository: ArticleRepository
) : ViewModel() {
val articles = repository.articleStream()
.cachedIn(viewModelScope)
}`CombinedLoadStates` 同时包含本地 `source` 和远端 `mediator` 的状态。离线优先页面通常更关心 `mediator`,因为网络错误来自这里,而已缓存的数据仍可能正常显示。
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` 展示追加加载和重试按钮:
binding.recyclerView.adapter = adapter.withLoadStateFooter(
footer = ArticleLoadStateAdapter(adapter::retry)
)主动刷新调用 `adapter.refresh()`,失败后的原请求重试调用 `adapter.retry()`。两者语义不同,不应混用。
默认情况下,每次创建新的 `Pager` 都可能触发刷新。若希望短时间内复用缓存,可覆写 `initialize()`:
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 本身失效,而是边界定义不一致。可以按以下顺序检查:
若列表支持分类、关键词或用户维度,远程键的主键不能只有 `articleId`。更稳妥的方式是为查询参数生成 `queryKey`,远程键使用 `(queryKey, articleId)` 复合主键,业务缓存也要明确数据属于哪个查询。否则切换筛选条件后,会读到上一种条件留下的翻页位置。
页码分页天然容易受服务端数据插入影响。接口可控时,优先使用稳定游标,让服务端返回 `nextCursor`,远程键保存游标而不是页码,可以显著减少重复和遗漏。
`RemoteMediator` 的核心逻辑可以使用内存 Room 和假接口测试。至少覆盖这些分支:
测试时不要只断言 `MediatorResult.Success`,还应查询数据库,验证业务数据、顺序和远程键是一致的。真正影响页面的是事务提交后的数据库状态。
`RemoteMediator` 适合列表较大、需要增量加载、允许本地缓存,并且服务端具有明确分页协议的场景。数据量很小的一次性接口没有必要引入整套分页基础设施;实时消息流也更适合 WebSocket、增量同步和独立的本地落库策略。
离线优先不等于永不请求网络,而是把缓存、同步和展示的职责划清:UI 可信地读取本地状态,网络同步可失败、可重试,数据库提交保证状态一致。当这些边界稳定后,分页列表才能在断网、进程重建和频繁刷新下保持可预测。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。