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)
}
这段代码短得很顺手,但它把三个层面的语义混在一起了:
- UI 想立刻反馈,所以先改内存;
- 本地想保持一致,所以立刻写库;
- 服务端才是真正裁决者,但结果最后才回来。
问题不在于“先改本地”一定错,而在于失败语义没有被定义。
如果接口超时但服务端其实成功了,怎么收敛? 如果本地写成功、远端写失败,谁回滚? 如果两个页面同时点收藏,最后以谁为准?
这些问题一旦没有被明确设计,Repository 就只是把竞态条件藏进了一个看起来很干净的方法里。
Flow 能传播状态,不等于自动保证一致性
Android 这几年很喜欢把 Flow、StateFlow、SharedFlow 接进 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. 把“派生状态”和“持久状态”分开
很多混乱来自把临时展示态写回持久层。
例如:
isLoadingisRefreshingisExpandedpendingRetryCount
这些状态可以决定 UI 怎么画,但不应该和业务真值混在同一个实体里四处传播。
一旦把派生状态放进 Repository 的公共模型,它就会在不同页面、不同生命周期之间被误复用,最后连“为什么这个字段还留着上次页面的值”都说不清。
3. 让写入路径少于读取路径
读取可以聚合,写入要收口。
你可以在读取时把数据库、内存、远端刷新信号拼在一起,给页面一个够用的模型;但写入时最好只走一条受控路径,由它决定:
- 是否先写本地;
- 是否需要补偿;
- 是否允许覆盖旧版本;
- 是否要带版本号或时间戳;
- 失败后 UI 应该看到什么语义。
系统允许的写入入口越多,一致性就越依赖“大家别写错”。这不是设计,这是碰运气。
一个常见反例:为了“体验丝滑”到处先改再说
最容易把状态一致性写坏的,不是复杂业务,而是那种“这个交互很简单,我们先本地改一下”的小决定。
比如点赞、收藏、关注、已读,这些动作太容易被当成“先改 UI,失败再说”。问题是它们一旦跨页面、跨列表、跨缓存层,就不再是小决定。
失败案例通常长这样:
- 详情页点了收藏,按钮立刻变亮;
- 列表页也监听了同一份 Repository 内存态,于是同步变亮;
- 接口超时,Repository 触发回滚;
- 但列表页此时已经因为数据库 observer 拿到旧值,回滚顺序与详情页不同;
- 用户返回上一级,看到两个页面状态不一致;
- 杀进程重开后,又恢复成第三种结果。
这类问题最烦人的地方在于,它不总是复现,所以团队很容易归因成“Flow 时序问题”“Compose 重组问题”或者“偶发网络波动”。
其实根因更朴素:你让多个层同时拥有了写最终结果的资格。
分层该服务的是可追责性,不是形式上的整洁
我并不是反对 Repository 分层。没有 Repository,很多 Android 项目会更乱。
但 Repository 真正该提供的,不是“我把所有数据源都藏起来了”,而是:
- 读路径从哪里来,能不能说清;
- 写路径经过哪些裁决,能不能追责;
- 出错时以谁为准,能不能恢复;
- 页面之间共享的是业务真值,还是临时展示态,能不能分开。
如果这些问题回答不出来,再漂亮的分层也只是视觉上的秩序。
它让代码看起来更像架构图,却不一定让状态更像一个系统。
适用边界
这篇文章主要针对的是:
- 有本地缓存或 Room;
- 有多页面共享状态;
- 同时追求首屏速度、离线恢复和交互即时反馈;
- 使用 Repository + Flow/StateFlow 组织数据读写。
如果你的应用非常轻,数据几乎都是一次性请求、页面即拿即用,也没有跨页面同步需求,那 Repository 即便写得简单粗暴,状态一致性问题也不会特别突出。
真正麻烦的是“中大型但还没大到能彻底平台化”的项目:功能越来越多,数据源越来越杂,但团队还在用早期那套“先包一层 Repository 再说”的方式硬撑。这个阶段最容易出现结构上很整齐、行为上很混乱的系统。
结尾
Android 里 Repository 分层最常见的误区,是把“统一访问入口”误当成“天然状态一致”。
入口统一,只能减少调用面的混乱;真相源头明确、写入边界收口、失败语义提前定义,才能真正减少状态打架。
否则你得到的不是一个更稳定的架构,而是一套更难追责的整齐分层。