快照一致性概念与重要性
说实话,刚开始接触离线优先架构的时候,我根本没把“快照一致性”当回事。那时候就觉得,本地数据库存一份数据,网络回来再同步一下不就完了?结果呢?用户眼睁睁看着屏幕上显示的已删除订单,点进去却报错——数据不一致的坑,踩过才知道疼。
到底什么是快照一致性?
先拆开说。快照,就是某一时刻数据状态的不可变副本。有点像你拍照——咔嚓一下,那个瞬间的所有细节都被固定下来。一致性,指的是系统中多个副本或视图之间,必须逻辑上等价。
那放到离线优先架构里,快照一致性就是:本地数据库的快照、远程服务器的快照、以及Compose UI层正在展示的快照,这三者在同一业务操作周期内,不能互相矛盾。
举个例子。用户离线时删除了一个待办事项,本地数据库马上更新。但Compose如果不从最新的本地快照读取,而是用了上一个缓存的快照,那UI上这个事项还在。这时候用户再点击它,就会触发一个不存在的数据请求——轻则报错,重则导致状态回滚。我当年就因为这个,被测试妹子追着改了三版。
Compose里的快照机制
Android Compose本身有一套Snapshot系统。它本质上是为重组服务的:当状态变化时,Compose会创建一个新的快照,旧的快照被冻结,重组只在新快照上执行。这保证了UI渲染的一致性——同一帧内不会出现一半新一半旧。
但注意,这套机制默认只管理Compose内部状态。一旦涉及到外部持久化存储——比如Room数据库、DataStore、文件缓存——快照就不再由Compose管控了。你得自己保证“数据读取”和“UI渲染”之间的一致性。
传统做法为什么容易崩?
很多团队的做法很简单:ViewModel里监听Flow,数据一变就发射给UI。看起来没问题对吧?但离线场景下,数据库可能同时被多个协程写入(网络同步、本地操作、后台清理),这些写入不是原子的。你想想看:
- 用户删除条目 → 本地DB标记删除
- 网络层恰好同步到一条旧数据,重新插入了该条目
- ViewModel收到新的Flow发射,UI显示条目又出现了
- 用户懵逼:我删了的怎么又回来了?
这就是快照不一致的典型表现。说白了,UI拿到的不是同一个逻辑时刻的数据快照,而是多个时间点的碎片拼凑。
快照一致性的三要素
| 要素 | 说明 | 常见陷阱 |
|---|---|---|
| 时间戳对齐 | 所有快照必须携带统一的版本或时间戳 | 忽略本地时间与服务器时间的偏差 |
| 原子性读取 | UI渲染所需的数据集合必须在同一事务中读取 | 多次独立查询导致跨快照 |
| 冲突可见性 | 同一时刻只能有一个写操作生效 | 并发写导致快照被污染 |
我在一次重构中,就犯过第二个错误。为了图省事,把用户信息和个人设置分两次查询,结果UI上昵称已经改了,头像还是旧的——用户投诉说“我换头像半天没反应”。其实数据早落地了,只是UI快照没对齐。
为什么说它重要?
直接说后果吧:
- 用户信任崩塌:看到一条已读消息又变成未读,用户会立刻觉得你这App不靠谱
- 数据重复提交:UI显示待提交,用户又点击一次,结果服务器收到两条相同的请求
- 离线操作丢失:本地删除被后续快照覆盖,白删了
我记得有个合作伙伴,他们的IM应用离线发消息偶尔会丢。查到最后发现,本地DB写入成功的回调还没回来,网络层就已经把新消息的快照推送到了UI。结果UI直接抛弃了本地写入的结果——快照不一致,消息就“被丢失”了。
设计上的应对思路
要保证快照一致性,我习惯从三个层面入手:
- 读取层:强制使用单一事务构建UI所需快照。Room的
@Transaction注解配合Flow,可以拿到一致性视图。 - 写入层:所有本地写入必须携带逻辑时间戳,Compose重组时基于这个时间戳判断快照是否过期。
- 展示层:Compose的
snapshotFlow只能用于内部状态;外部数据要用derivedStateOf或其他同步机制重新计算快照。
我最近在一个项目里用了个简单方案——每次数据库写入后,给本地表加一个单调递增的snapshot_version字段。UI读取时拿到最大版本号,如果版本没变,就不触发重组。虽然粗暴,但至少保证了“同一版本内的一致性”。
SVG:快照一致性在离线优先架构中的位置
图中最关键的环节是“快照对比 & 合并”。本地数据库和远程服务器产生的两个快照,不能直接送给UI。必须经过版本对齐和冲突解决,才能生成一个逻辑一致的快照。这一步做不好,后面UI再优美也是假象。
一个小细节:快照“年龄”
我建议给每个快照增加一个“产生时间”和“数据来源标记”(本地/远程)。这样在UI层可以判断:如果当前展示的快照超过一定阈值(比如30秒),就显示一个“正在刷新”的提示。这虽然不是严格的一致性保证,但至少用户能感知到数据不是最新的,不会产生错误操作。
避坑指南
- 我曾经在离线删除后,没有等数据库回调就更新了UI缓存,结果网络同步又把数据拉回来,UI闪了一下旧数据。解决方案:所有UI状态必须依赖数据库的Flow,而非手动缓存。
- 千万别在Compose中用
LaunchedEffect去手动读取数据库然后赋值给mutableStateOf——这会脱离Snapshot系统,导致重组时读到的快照和实际数据库不一致。 - 我有一个血的教训:多表联合查询时,如果没用
@Transaction,两个查询之间可能被其他协程篡改数据。后果就是UI上显示了逻辑上不存在的组合(比如收藏了已经下架的商品)。
说到底,快照一致性不是高级理论,而是一个很实际的工程质量问题。你想想看,用户断网时操作一通,网一恢复,看到的结果却跟自己期望的不一样——这种体验一次就足以让人卸载App。把这个概念理解透,你的离线优先架构才算真正站稳了脚跟。