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

相关推荐
Underwood_176 小时前
星级评价——了解useState
前端
hunterandroid6 小时前
[鸿蒙从零到一] ArkUI 组件化实战:构建可复用、可组合的自定义组件
前端·华为·架构
一只公羊7 小时前
iPhone摄像头 在开发/调试过程中强行停止 App,导致 `AVCaptureSession` 没有被正常释放
前端
码农coding7 小时前
android12 开机启动PMS
android
Days20507 小时前
GPT-Image-2 国风美学人设生成提示词分享
android·gpt
浮江雾7 小时前
Flutter第十节-----Flutter布局与组件全解析
android·开发语言·前端·学习·flutter·入门
xcLeigh8 小时前
Doubao-Seed-Evolving大模型接入教程|搭建全品类提示词+AI工具导航网页
前端·人工智能·python·ai·html·ai开发·豆包
Jomurphys8 小时前
Compose 适配 - 自适应布局(窗口大小类 WindowSizeClasses、列表详情、辅助窗格)
android·compose