Paging 3 RemoteMediator 实战:构建离线优先的分页列表

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 可信地读取本地状态,网络同步可失败、可重试,数据库提交保证状态一致。当这些边界稳定后,分页列表才能在断网、进程重建和频繁刷新下保持可预测。

相关推荐
一位正在转型AI全栈的前端工程师6 小时前
AI 全栈学习之旅 -Week 5:RAG 知识库问答系统:从零到生产级部署的全栈实践总结
前端·python
用户69371750013846 小时前
曾经安卓开发人手一个的 EventBus,为什么现在没人爱用了?
android·android studio
光影少年6 小时前
react navite 安卓iOS 打包、签名、环境区分
前端·react native·react.js
coderCN7 小时前
Nodejs 第三十四章 数据库(表达式和函数、子查询和连表)
前端·node.js
ssshooter7 小时前
AI 时代你不能不知道的 git worktree
前端·后端·面试
观测云7 小时前
AI时代的用户访问监测:观测云带你身临其境体验用户与前端UI交互旅程
前端·可观测性·观测云·rum
喵本喵叁肆8 小时前
06-M6-部门过滤与综合研判-从问答机到研判助手
前端·javascript·jquery
九皇叔叔8 小时前
MySQL ORDER BY 性能优化详解:Using filesort、联合索引、ASC/DESC 与覆盖索引
android·adb
TomEval8 小时前
【Web UI 自动化】05 - KDT 模式原理与实现
前端·ui·自动化
南雨北斗9 小时前
vue3项目状态持久化方案Pinia
前端