第4章 Repository层设计:单一数据源原则、本地优先策略、网络与本地数据合并
Repository 是离线数据绑定的核心边界,负责统一对外暴露数据,屏蔽数据来源的复杂性。本章围绕三个核心设计原则展开:单一数据源确保数据一致性,本地优先保证离线可用性,网络与本地数据合并解决数据时效性。理解并正确实现这些原则,是构建稳定离线缓存体系的基础。
4.1 单一数据源原则
单一数据源(Single Source of Truth, SSOT)要求应用中每一份数据只有一处权威来源。在离线数据绑定场景中,本地数据库(Room)是唯一的真实数据源。UI 层只订阅本地数据库中的 Flow,网络数据获取后先写入本地数据库,再由数据库通知 UI 更新。这种机制彻底避免了内存缓存与数据库、网络数据之间的不一致问题。
| 对比维度 | 双数据源(错误) | 单一数据源(正确) |
|---|---|---|
| 数据一致性 | 内存与数据库可能不同步 | 始终从本地数据库读取 |
| 离线恢复 | 应用重启后内存数据丢失 | 本地数据库持久化,重启可恢复 |
| 并发控制 | 需要额外同步锁机制 | Room 事务保证原子性 |
| 测试复杂度 | 需要 mock 多个数据源 | 只测试 Repository + Room |
4.2 本地优先策略
本地优先(Local-First)是指 UI 层请求数据时,Repository 立即从本地数据库返回当前缓存数据(可能较旧),再在后台发起网络请求更新本地数据,最后通过 Flow 自动推送更新到 UI。该策略确保用户在弱网或无网环境下依然能看到内容,不会出现白屏或无限加载。
实现流程分为四个步骤:
- UI 订阅 Repository 返回的 Flow,立即收到本地缓存数据(可能为空或过期)。
- Repository 在订阅时触发后台网络请求调度。
- 网络请求成功:将最新数据插入或更新到 Room 数据库。
- Room DAO 的 Flow 因数据变更自动重新 emit,UI 收到最新数据。
4.3 网络与本地数据合并策略
合并是指将网络返回的最新数据与本地已有数据整合成最终状态。合并策略取决于业务需求和数据特性,常见三种:
| 策略 | 适用场景 | 实现方式 |
|---|---|---|
| 全量替换 | 服务端返回完整数据集 | 清空本地表,插入全部新数据 |
| 增量合并 | 列表类数据,服务端只返回变更部分 | 按主键 upsert(插入或更新) |
| 冲突合并 | 本地有未提交的修改 | 基于时间戳或版本号决定覆盖方 |
对于 Jetpack Compose 离线数据绑定,全量替换最简洁且一致性最强,适合大多数数据列表场景。增量合并适用于无限滚动列表或服务端只推送 diff 的情况。冲突合并仅当用户允许离线编辑时采用,且需要额外的冲突解决 UI。
4.4 完整 Repository 代码骨架
以下是一个同时体现 SSOT、本地优先、合并策略的 Repository 实现示例:
class ArticleRepository(
private val articleDao: ArticleDao,
private val articleApi: ArticleApi
) {
// 对外暴露 Flow,数据来源唯一为 dao.observeAll()
fun observeArticles(): Flow<List<Article>> {
return articleDao.observeAll()
.map { entities -> entities.map { it.toDomain() } }
.onStart {
// 本地优先:先触发网络刷新,但不阻塞 UI 订阅
refreshFromNetwork()
}
}
// 后台刷新:网络数据写入 Room
private suspend fun refreshFromNetwork() {
try {
val response = articleApi.fetchArticles()
if (response.isSuccessful) {
val entities = response.body()?.map { it.toEntity() } ?: emptyList()
articleDao.replaceAll(entities) // 全量替换
}
} catch (e: Exception) {
// 网络失败时静默处理,用户仍能看到本地缓存数据
Log.w("Repository", "Network refresh failed", e)
}
}
}
StrictMode 检测主线程是否有数据库写入操作。
4.5 Repository 层数据流框架图
下方的逻辑框架图展示了 Repository 层如何串联 UI、Room 和网络,实现单一数据源和本地优先的数据绑定流程。
4.6 调试与验证方法
完成 Repository 编写后,可以通过以下步骤验证离线数据绑定是否按预期工作:
- Database Inspector 实时监控:打开 Android Studio > View > Tool Windows > App Inspection > Database Inspector,观察 Room 表数据在网络请求成功后是否自动更新。
- 数据流日志:在 Repository 的
observeArticles()中添加.onEach { Log.d("Repo", "Data emitted: ${it.size} items") },检查 emit 次数。正确行为是:订阅后立即 emit 本地数据,网络写入后第二次 emit。 - 无网启动测试:启用飞行模式后打开应用,应能显示上次缓存的数据,且无崩溃或空指针。
- 边界情况:首次安装且无网时,UI 应显示空状态提示,而非持续加载旋转图标。
4.7 常见错误与性能优化
| 类别 | 错误表现 | 解决方案 |
|---|---|---|
| 缓存未返回 | UI 一直显示加载中,直到网络请求完成 | 确保 onStart 或 observeAll 的 Room Flow 在无数据时也能 emit 空列表 |
| 数据过期 | UI 始终显示旧数据,不触发网络刷新 | 检查 onStart 内是否调用了 refreshFromNetwork() 且未被异常吞噬 |
| 并发写入 | 列表闪烁或出现重复项 | Room 写入使用事务 @Transaction,全量替换时先删除再插入 |
| 内存泄漏 | Activity 销毁后 Repository 仍在后台请求网络 | 网络请求使用 viewModelScope 或 lifecycleScope 控制协程生命周期 |
• Repository 层不要缓存任何可变的集合对象,所有数据依赖 Room 的持久化存储。
• 网络请求使用
suspend 函数而非回调,配合 withContext(Dispatchers.IO) 确保不阻塞主线程。• 对于列表数据量大(超 500 条)的场景,在 DAO 中配合
PagingSource 实现分页加载,避免一次性加载全部数据到内存。