第4章 Repository层设计:单一数据源原则、本地优先策略、网络与本地数据合并

Repository 是离线数据绑定的核心边界,负责统一对外暴露数据,屏蔽数据来源的复杂性。本章围绕三个核心设计原则展开:单一数据源确保数据一致性,本地优先保证离线可用性,网络与本地数据合并解决数据时效性。理解并正确实现这些原则,是构建稳定离线缓存体系的基础。

4.1 单一数据源原则

单一数据源(Single Source of Truth, SSOT)要求应用中每一份数据只有一处权威来源。在离线数据绑定场景中,本地数据库(Room)是唯一的真实数据源。UI 层只订阅本地数据库中的 Flow,网络数据获取后先写入本地数据库,再由数据库通知 UI 更新。这种机制彻底避免了内存缓存与数据库、网络数据之间的不一致问题。

对比维度 双数据源(错误) 单一数据源(正确)
数据一致性 内存与数据库可能不同步 始终从本地数据库读取
离线恢复 应用重启后内存数据丢失 本地数据库持久化,重启可恢复
并发控制 需要额外同步锁机制 Room 事务保证原子性
测试复杂度 需要 mock 多个数据源 只测试 Repository + Room
关键实践:Repository 中的所有对外暴露方法都返回 Flow<T>,且该 Flow 的数据必须来源于 Room DAO。网络请求结果通过 Repository 内部方法写入 Room,避免直接向 UI 层 emit 网络数据。

4.2 本地优先策略

本地优先(Local-First)是指 UI 层请求数据时,Repository 立即从本地数据库返回当前缓存数据(可能较旧),再在后台发起网络请求更新本地数据,最后通过 Flow 自动推送更新到 UI。该策略确保用户在弱网或无网环境下依然能看到内容,不会出现白屏或无限加载。

实现流程分为四个步骤:

  1. UI 订阅 Repository 返回的 Flow,立即收到本地缓存数据(可能为空或过期)。
  2. Repository 在订阅时触发后台网络请求调度。
  3. 网络请求成功:将最新数据插入或更新到 Room 数据库。
  4. Room DAO 的 Flow 因数据变更自动重新 emit,UI 收到最新数据。
设计提示:在 Repository 构造函数中注入网络状态监听器,当设备离线时跳过网络请求步骤,只返回本地数据。这样可实现完全无感离线体验。

4.3 网络与本地数据合并策略

合并是指将网络返回的最新数据与本地已有数据整合成最终状态。合并策略取决于业务需求和数据特性,常见三种:

策略 适用场景 实现方式
全量替换 服务端返回完整数据集 清空本地表,插入全部新数据
增量合并 列表类数据,服务端只返回变更部分 按主键 upsert(插入或更新)
冲突合并 本地有未提交的修改 基于时间戳或版本号决定覆盖方

对于 Jetpack Compose 离线数据绑定,全量替换最简洁且一致性最强,适合大多数数据列表场景。增量合并适用于无限滚动列表或服务端只推送 diff 的情况。冲突合并仅当用户允许离线编辑时采用,且需要额外的冲突解决 UI。

常见陷阱:不要直接在网络回调中更新 UI State。正确做法是:网络数据 → 写入 Room → Room 自动推送数据到 UI。绕过 Room 直接更新 State 会导致本地缓存与 UI 不同步,破坏 SSOT 原则。

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)
        }
    }
}
调试要点:在开发阶段,通过 Android Studio 的 Database Inspector 观察 Room 数据变更,确认网络写入后 DAO 的 Flow 是否自动推送。同时使用 StrictMode 检测主线程是否有数据库写入操作。

4.5 Repository 层数据流框架图

下方的逻辑框架图展示了 Repository 层如何串联 UI、Room 和网络,实现单一数据源和本地优先的数据绑定流程。

Repository 层数据流框架(单一数据源 + 本地优先) UI 层 / Compose 订阅 Flow<UiState> Repository observeAll() Room DAO 触发网络刷新 网络 API 写入最新数据 本地数据库 数据变更自动推送 核心原则 单一数据源(Room)→ 本地优先(先读缓存,再网络刷新)→ 数据自动合并(网络写 Room,Flow 推送 UI)

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 实现分页加载,避免一次性加载全部数据到内存。