返回文章列表

Repository 分层越来越整齐,为什么状态一致性反而更难保证

真正难管的不是层次多,而是本地缓存、内存态、远端回包和 UI 派生状态都在偷偷写“真相”

很多 Android 项目一出状态错乱,第一反应不是去找状态源头,而是继续加分层。

ViewModel -> UseCase -> Repository -> LocalDataSource -> RemoteDataSource 这一串摆出来,代码看上去确实更工整了。问题是,工整和一致不是一回事。很多团队把 Repository 做得越来越像“统一入口”,最后却发现页面状态更难推断:列表和详情不一致,收藏状态来回跳,请求成功了 UI 没变,进程重建后又冒出一套旧数据。

我的判断是:Repository 分层的价值,不是把调用链拉长,而是明确状态源头和写入边界。只要内存缓存、本地数据库、远端回包和 UI 派生状态都能各自改值,分层越整齐,状态一致性越难收住。

真正的问题不是层不够多,而是真相不止一个

很多人说 Repository 能“统一管理数据”,这句话只说对了一半。

Repository 当然可以把网络、本地缓存、磁盘持久化都包在一起,但如果没有继续追问“到底谁才是真相源头”,Repository 只是把多份状态包装进了同一个类名里。

最常见的失控路径是这样的:

  • 页面先读本地数据库,立刻显示旧值;
  • 同时发起远端请求,回包后更新内存缓存;
  • 某个交互为了追求丝滑,先直接改 UI state 做 optimistic update;
  • 另一个页面又从 Repository 的单例字段里读到另一份值;
  • 最后数据库异步落盘完成,再把旧页面重新顶回去。

这时候你表面上看到的是“架构分层完整”,实际上系统里已经有四套状态在竞争解释权。

它们分别回答的是不同问题:

  • 数据库想回答“下次启动还能不能恢复”;
  • 内存缓存想回答“这次访问快不快”;
  • 远端回包想回答“服务端刚刚说了什么”;
  • UI state 想回答“此刻界面应该怎么渲染”。

这些东西都重要,但重要不等于都能当真相源头

如果没有明确规定“谁负责持久真值,谁只负责派生展示,谁只能读不能写”,Repository 就会慢慢退化成一个状态中转站。它接住了所有复杂度,但没有消掉任何一份复杂度。

Repository 最容易被滥用成“什么都能改”的协调器

很多代码的问题,不在于 Repository 太薄,而在于它太有权力。

一个典型的 Repository 往往同时干这些事:

  • 发网络请求;
  • 读写 Room;
  • 维护内存 map;
  • 拼装 UI 需要的字段;
  • 在失败时回滚 optimistic update;
  • 顺手发事件通知别的模块刷新。

看起来很集中,实际上是把“数据访问层”“状态协调层”“缓存策略层”“领域规则层”揉成了一团。

一旦 Repository 同时承担“读取聚合”和“多源写入协调”,它就会天然进入一个尴尬状态:谁都能通过它改数据,但没人能快速说清一次变更最终会影响哪些观察者、触发哪一条回写路径。

比如一个收藏操作,很多实现是这样的:

suspend fun toggleFavorite(id: String) {
    memory[id] = !(memory[id] ?: false)
    dao.updateFavorite(id, memory[id]!!)
    api.toggleFavorite(id)
}

这段代码短得很顺手,但它把三个层面的语义混在一起了:

  1. UI 想立刻反馈,所以先改内存;
  2. 本地想保持一致,所以立刻写库;
  3. 服务端才是真正裁决者,但结果最后才回来。

问题不在于“先改本地”一定错,而在于失败语义没有被定义

如果接口超时但服务端其实成功了,怎么收敛? 如果本地写成功、远端写失败,谁回滚? 如果两个页面同时点收藏,最后以谁为准?

这些问题一旦没有被明确设计,Repository 就只是把竞态条件藏进了一个看起来很干净的方法里。

Flow 能传播状态,不等于自动保证一致性

Android 这几年很喜欢把 FlowStateFlowSharedFlow 接进 Repository,再向上游暴露一个“响应式数据源”。这当然比到处 callback 好,但它经常制造一种错觉:只要我把数据流起来,一致性问题就会自然消失。

不会。

响应式流解决的是变化怎么传播,不是变化以谁为准

下面这种模式很常见:

val userFlow = combine(
    dao.observeUser(id),
    memoryStateFlow,
    remoteRefreshStateFlow
) { local, memory, remote ->
    mergeUser(local, memory, remote)
}

这段代码最大的风险不在于写法丑,而在于 mergeUser() 往往会悄悄引入业务裁决:

  • 名字以远端为准;
  • 是否在线以内存为准;
  • 是否已读以本地为准;
  • 加载中状态再额外挂在 UI 上。

最后你得到的不是一个稳定的数据模型,而是一个“此刻勉强能渲染页面的拼接结果”。

这类拼接在读路径上很方便,在写路径上却极易失控,因为你已经很难回答:

  • 某个字段到底应该改哪一层;
  • 某一层变了以后,其他层要不要同步;
  • 进程重建后,哪些字段还能重建回来;
  • 离线恢复时,哪些字段会把新值覆盖掉。

所以很多项目的怪现象是:数据流写得越漂亮,状态 bug 越像玄学。根因不是 Flow,而是系统里没有单一可追责的状态源头

真正该控制的,是写入边界

Repository 设计最重要的约束,不是“接口要不要拆”,而是“哪些地方有写权限”。

如果一个业务对象既能被 UI optimistic update 改、又能被 Repository 内存缓存改、又能被数据库 observer 推回来、又能被接口回包覆盖,那你迟早会碰到顺序不一致问题。

比起继续加抽象,我更建议先把写入边界讲清楚:

1. 先选主真相源头

不是所有场景都该“本地数据库为唯一真相源头”,但你必须选一个主源头。

  • 离线优先、列表可恢复的场景,通常应以本地数据库为主;
  • 强实时、不可接受旧值的场景,可能要以远端结果为主;
  • 纯界面交互态,比如展开、选中、输入中,应该明确留在 UI state,不要倒灌回 Repository。

关键不是选谁,而是不要一半字段靠数据库裁决,另一半字段靠内存裁决,出错时再靠 UI 补洞

2. 把“派生状态”和“持久状态”分开

很多混乱来自把临时展示态写回持久层。

例如:

  • isLoading
  • isRefreshing
  • isExpanded
  • pendingRetryCount

这些状态可以决定 UI 怎么画,但不应该和业务真值混在同一个实体里四处传播。

一旦把派生状态放进 Repository 的公共模型,它就会在不同页面、不同生命周期之间被误复用,最后连“为什么这个字段还留着上次页面的值”都说不清。

3. 让写入路径少于读取路径

读取可以聚合,写入要收口。

你可以在读取时把数据库、内存、远端刷新信号拼在一起,给页面一个够用的模型;但写入时最好只走一条受控路径,由它决定:

  • 是否先写本地;
  • 是否需要补偿;
  • 是否允许覆盖旧版本;
  • 是否要带版本号或时间戳;
  • 失败后 UI 应该看到什么语义。

系统允许的写入入口越多,一致性就越依赖“大家别写错”。这不是设计,这是碰运气。

一个常见反例:为了“体验丝滑”到处先改再说

最容易把状态一致性写坏的,不是复杂业务,而是那种“这个交互很简单,我们先本地改一下”的小决定。

比如点赞、收藏、关注、已读,这些动作太容易被当成“先改 UI,失败再说”。问题是它们一旦跨页面、跨列表、跨缓存层,就不再是小决定。

失败案例通常长这样:

  • 详情页点了收藏,按钮立刻变亮;
  • 列表页也监听了同一份 Repository 内存态,于是同步变亮;
  • 接口超时,Repository 触发回滚;
  • 但列表页此时已经因为数据库 observer 拿到旧值,回滚顺序与详情页不同;
  • 用户返回上一级,看到两个页面状态不一致;
  • 杀进程重开后,又恢复成第三种结果。

这类问题最烦人的地方在于,它不总是复现,所以团队很容易归因成“Flow 时序问题”“Compose 重组问题”或者“偶发网络波动”。

其实根因更朴素:你让多个层同时拥有了写最终结果的资格。

分层该服务的是可追责性,不是形式上的整洁

我并不是反对 Repository 分层。没有 Repository,很多 Android 项目会更乱。

但 Repository 真正该提供的,不是“我把所有数据源都藏起来了”,而是:

  • 读路径从哪里来,能不能说清;
  • 写路径经过哪些裁决,能不能追责;
  • 出错时以谁为准,能不能恢复;
  • 页面之间共享的是业务真值,还是临时展示态,能不能分开。

如果这些问题回答不出来,再漂亮的分层也只是视觉上的秩序。

它让代码看起来更像架构图,却不一定让状态更像一个系统。

适用边界

这篇文章主要针对的是:

  • 有本地缓存或 Room;
  • 有多页面共享状态;
  • 同时追求首屏速度、离线恢复和交互即时反馈;
  • 使用 Repository + Flow/StateFlow 组织数据读写。

如果你的应用非常轻,数据几乎都是一次性请求、页面即拿即用,也没有跨页面同步需求,那 Repository 即便写得简单粗暴,状态一致性问题也不会特别突出。

真正麻烦的是“中大型但还没大到能彻底平台化”的项目:功能越来越多,数据源越来越杂,但团队还在用早期那套“先包一层 Repository 再说”的方式硬撑。这个阶段最容易出现结构上很整齐、行为上很混乱的系统。

结尾

Android 里 Repository 分层最常见的误区,是把“统一访问入口”误当成“天然状态一致”。

入口统一,只能减少调用面的混乱;真相源头明确、写入边界收口、失败语义提前定义,才能真正减少状态打架。

否则你得到的不是一个更稳定的架构,而是一套更难追责的整齐分层。