首页 文章 精选 留言 我的

精选列表

搜索[android],共10000篇文章
优秀的个人博客,低调大师

[Android 从零到一] Paging 3 RemoteMediator 实战:构建离线优先的分页列表

# Paging 3 RemoteMediator 实战:构建离线优先的分页列表 只从网络分页,断网后页面就会失去内容;只从数据库读取,又需要自己处理拉取、缓存和翻页。`RemoteMediator` 把两者连接起来:界面始终观察 Room,网络负责按需补充数据。本文以资讯列表为例,逐步实现一套可刷新、可续页、可离线浏览的分页方案,并解释其中最容易出错的数据一致性问题。 ## 为什么要让数据库成为唯一数据源 一个常见实现是首次进入页面请求网络,失败时再读取缓存。这个方案看似直接,却会产生两套状态:网络结果一套,数据库结果一套。刷新、删除、排序变化后,两套数据很容易不同步。 离线优先架构采用另一种数据流: - UI 只订阅 Room 返回的 `PagingSource`; - `RemoteMediator` 判断何时请求网络; - 网络结果在事务中写入 Room; - Room 变更后自动使旧的 `PagingSource` 失效; - UI 从新快照中得到最新列表。 这样,网络响应不会直接交给页面。数据库是页面状态的唯一事实来源,在线和离线走的是同一条渲染链路。 ## 数据表不能只存业务实体 假设服务端接口按页码返回资讯: ``` data class ArticleDto( val id: Long, val title: String, val summary: String, val publishedAt: Long ) data class ArticlePage( val items: List, 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? ) ``` 不要用“当前请求到了哪一页”这样的内存变量代替远程键。进程重建、主动刷新或列表重新创建后,内存变量会丢失,也无法和数据库快照保持原子一致。 ## DAO 与数据库定义 `PagingSource` 的查询顺序必须稳定。若多个数据拥有相同发布时间,应增加主键作为次级排序条件,否则翻页过程中可能出现顺序抖动。 ``` @Dao interface ArticleDao { @Query( """ SELECT * FROM articles ORDER BY publishedAt DESC, id DESC """ ) fun pagingSource(): PagingSource @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(items: List) @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) @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 } ``` ## 实现 RemoteMediator 的加载决策 `load()` 会收到三种加载类型: - `REFRESH`:首次加载或用户主动刷新; - `PREPEND`:向列表头部加载; - `APPEND`:向列表尾部加载。 对于只支持向后翻页的接口,`PREPEND` 可以直接结束。`REFRESH` 从起始页请求,`APPEND` 则从列表末尾数据对应的远程键中取得下一页。 ``` @OptIn(ExperimentalPagingApi::class) class ArticleRemoteMediator( private val api: ArticleApi, private val database: AppDatabase ) : RemoteMediator() { private val articleDao = database.articleDao() private val remoteKeyDao = database.articleRemoteKeyDao() override suspend fun load( loadType: LoadType, state: PagingState ): 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`: ``` class ArticleRepository( private val api: ArticleApi, private val database: AppDatabase ) { @OptIn(ExperimentalPagingApi::class) fun articleStream(): Flow> = 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 本身失效,而是边界定义不一致。可以按以下顺序检查: - 服务端排序是否稳定,翻页期间新数据插入会不会改变页码边界; - 业务表主键是否真能唯一标识条目; - `nextPage` 是否在末页正确写为 `null`; - 空响应是否被识别为分页结束; - 刷新时业务表和远程键是否一起清理; - DAO 查询排序是否与服务端排序一致; - 多个筛选条件是否错误共用了同一套缓存和远程键。 若列表支持分类、关键词或用户维度,远程键的主键不能只有 `articleId`。更稳妥的方式是为查询参数生成 `queryKey`,远程键使用 `(queryKey, articleId)` 复合主键,业务缓存也要明确数据属于哪个查询。否则切换筛选条件后,会读到上一种条件留下的翻页位置。 页码分页天然容易受服务端数据插入影响。接口可控时,优先使用稳定游标,让服务端返回 `nextCursor`,远程键保存游标而不是页码,可以显著减少重复和遗漏。 ## 测试加载状态与事务结果 `RemoteMediator` 的核心逻辑可以使用内存 Room 和假接口测试。至少覆盖这些分支: - 首次刷新写入数据及远程键; - 刷新替换旧缓存; - 追加请求使用正确的下一页; - 末页返回分页结束; - 网络异常保留旧缓存; - 重复主键被更新而不是插入两份; - 不同查询条件之间互不污染。 测试时不要只断言 `MediatorResult.Success`,还应查询数据库,验证业务数据、顺序和远程键是一致的。真正影响页面的是事务提交后的数据库状态。 ## 适用边界 `RemoteMediator` 适合列表较大、需要增量加载、允许本地缓存,并且服务端具有明确分页协议的场景。数据量很小的一次性接口没有必要引入整套分页基础设施;实时消息流也更适合 WebSocket、增量同步和独立的本地落库策略。 离线优先不等于永不请求网络,而是把缓存、同步和展示的职责划清:UI 可信地读取本地状态,网络同步可失败、可重试,数据库提交保证状态一致。当这些边界稳定后,分页列表才能在断网、进程重建和频繁刷新下保持可预测。

资源下载

更多资源
Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

用户登录
用户注册