大多数应用都默默假设网络一直可用。然后一个真实用户走进地下室、半建成的公寓楼或电梯——应用就崩溃了。
对房地产销售代理来说,那一刻不是故障。它是丢失的潜在客户,和丢失的佣金。
当我们在Aqarmap(埃及最大的房产平台)构建AM Live CRM时,我们的现场代理正在管理每月10万+潜在客户 — 而他们的大量时间恰恰发生在这些信号盲区:新建区域、地下停车场、信号仅剩一格的偏远地段.
单纯的"缓存最后响应"策略已不足够。我们需要让应用在零网络连接的情况下完全可用。 — 创建一个线索,将其通过管道,记录一个通话 — 并且在网络恢复的瞬间所有这些内容都能干净地同步。
这是我们最终确定的架构,以及值得避免的错误。
核心思想:本地数据库是事实来源
离线优先最大的思维转变是:界面从不直接与网络通信。 它与本地数据库通信。网络只是一个后台进程,用于保持本地存储和服务器最终的一致性。
UI / BLoC -> Repository -> Local DB (SQLite) <-> Sync engine <-> API
每次读取都来自SQLite。每次写入首先发送到SQLite,然后排队等待服务器处理。用户永远不会等待请求,也永远不会看到依赖信号的加载动画。
— 本地写入,意图排队
当代理编辑线索时,我们通过一个事务处理两件事:更新本地行,并记录意图以同步它
class SyncOperation {
final String id; // uuid
final String entity; // 'lead', 'call_log', ...
final String entityId;
final OpType type; // create | update | delete
final Map<String, dynamic> payload;
final int localVersion; // bumped on every local edit
final DateTime createdAt;
const SyncOperation({ /* ... */ });
}
Future<void> updateLead(Lead lead) async {
await db.transaction((txn) async {
await txn.update('leads', lead.toMap(),
where: 'id = ?', whereArgs: [lead.id]);
await txn.insert('sync_queue',
SyncOperation(
id: uuid.v4(),
entity: 'lead',
entityId: lead.id,
type: OpType.update,
payload: lead.toMap(),
localVersion: lead.version + 1,
createdAt: DateTime.now(),
).toMap());
});
}
因为行和队列条目是在写入同一笔交易,你永远不可能处于一个状态,即 UI 显示了永远不会同步的变化。
支柱2—当连接恢复时清空队列
一个监听器监控连接性并启动一个排水操作。排水操作处理操作为了,一次处理一个实体,并且只有在服务器确认后才会从队列中移除操作。
connectivity.onStatusChange
.where((status) => status.isOnline)
.listen((_) => _syncEngine.drain());
Future<void> drain() async {
if (_isSyncing) return; // never run two drains at once
_isSyncing = true;
try {
final ops = await _queue.pending(limit: 50);
for (final op in ops) {
final result = await _push(op);
if (result.isConflict) {
await _resolveConflict(op, result.serverState);
}
await _queue.remove(op.id); // only after success
}
} finally {
_isSyncing = false;
}
}
让我们少了很多痛苦的两个细节:
-
一个守护标志(
_isSyncing) 这样,不稳定的连接在开启和关闭时不会产生重叠的同步,从而避免重复写入. -
确认后删除. 如果应用在同步过程中崩溃,该操作仍然在队列中,下次会简单地重新播放。以操作为键(
id)的幂等服务器端点使重新播放是安全的.
第三支柱 — 有目的地解决冲突,而不是偶然解决
危险情况:一个代理离线编辑一个线索,而同事在同一时间在服务器上编辑同一个线索。如果你盲目地推送,你将无声地覆盖他们的工作.
我们版本化了每条记录。当服务器报告比我们操作所依据的版本更新时,我们不猜测——我们执行一个明确的策略:
Future<void> _resolveConflict(SyncOperation op, Lead serverState) async {
// Field-level merge: keep the server's pipeline stage (authoritative,
// it drives reporting), keep our locally-edited contact notes.
final merged = serverState.copyWith(
notes: op.payload['notes'],
updatedAt: DateTime.now(),
);
await leadRepo.upsertLocal(merged);
await _queue.enqueueUpdate(merged); // push the merged result back
}
对于某些字段来说,“最后写入者胜出”是没问题的。对于其他情况(比如用于管理报告的五阶段管道),服务器保持权威性。关键在于这个规则是你记录的一个决定,而不仅仅是你在生产中发现的紧急行为。
这给我们带来了什么
- 代理商停止了因“没有互联网”而流失客户的情况。管道持续地在线或离线运行。
- 界面变得更快,因为读取操作不再受网络阻塞的影响.
- 同步失败变得不再重要:它们只是自动重试.
我想要分享的经验教训
- 从一开始就决定采用离线优先的策略.将这一策略添加到一个依赖网络的 App 上,那不是功能增强,而是需要重写.
- 让服务器操作幂等 在你信任录像之前。操作ID是你的朋友。
- 在真实的蜂窝网络上进行测试,而不是办公室的WiFi。 在你办公桌上运行正常的演示并不是最终产品。
- 把冲突规则记下来。 将来的你不会记得为什么舞台变化与笔记行为不同。
我是Ahmed (Saqr),一名资深的Flutter工程师——开发过22+生产级应用,服务过200K+用户。我专注于撰写关于实际交付和扩展的移动应用开发内容.
如果这对你有帮助,请在这里和GitHub上关注我,我在那里维护一个开源的Flutter Enterprise Template,已有100+开发者使用。
你在Flutter中的离线同步方法是什么?我很想听听你在评论区的看法。













