Flutter 基础体系 · 第 58/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 离线缓存:Cache-Aside、同步、冲突、过期和用户隔离
离线缓存不是“把接口结果存到本地,下次直接读取”。一个可用的离线数据层至少要回答六个问题:
- 读取时先查哪里,缓存未命中时如何访问网络?
- 用户修改数据后,界面显示的是本地结果还是服务器结果?
- 网络恢复时,哪些本地修改需要上传,上传失败后如何重试?
- 本地和服务器同时修改同一条数据时,谁获胜,为什么?
- 缓存什么时候算过期,过期后是否还能显示?
- 用户 A 退出、用户 B 登录后,为什么不能读到 A 的数据?
这些问题分别对应 Cache-Aside、同步、冲突、过期和用户隔离。它们不是互相独立的功能:缓存键设计会影响用户隔离,写入策略会影响冲突处理,过期判断会影响同步触发,应用生命周期又会影响同步是否真的执行。
一、先区分缓存、本地数据和服务器事实
缓存是可以被重新获取的数据副本。它的特点是:
- 丢失后可以重新从服务器恢复;
- 可能过期;
- 不能仅凭“本地已有”推断它仍然正确。
本地持久化数据是应用为了离线使用而保存的数据。它可能同时包含两类内容:
- 从服务器同步来的实体,例如文章、任务、用户资料;
- 尚未上传的本地操作,例如“修改标题”“删除任务”。
后一类通常称为 待同步操作,也常见于 Outbox(发件箱)设计。
服务器是“事实来源”(source of truth)还是本地是事实来源,取决于业务:
- 新闻阅读:服务器通常是事实来源,本地只是缓存;
- 离线记账:本地必须先接受用户操作,服务器负责合并;
- 草稿编辑:本地草稿可能比服务器数据更重要,不能简单用服务器响应覆盖。
因此,“离线缓存”通常不是一个 Map<String, Object>,而是至少包含:
实体表: 当前本地可展示的数据
元数据: 版本、获取时间、同步状态
待同步操作表: 本地已经接受、但服务器尚未确认的修改
冲突记录表: 无法自动合并、需要用户处理的数据
如果只保存实体,不保存版本、来源和待同步状态,应用无法可靠判断:
- 这条数据是从未获取过,还是已经过期;
- 这次本地修改是否已经上传;
- 上传失败后重试是否会重复执行;
- 服务器返回旧版本时是否应该覆盖本地内容。
二、Cache-Aside:应用显式管理缓存
2.1 定义
Cache-Aside,也叫旁路缓存,是一种由业务代码显式读写缓存的模式:
- 读取时先查缓存;
- 缓存命中且可接受时直接返回;
- 缓存未命中或不可接受时访问网络;
- 网络成功后由应用把结果写入缓存;
- 后续读取再从缓存返回。
缓存不会自动拦截所有请求,业务代码必须决定:
- 什么是缓存键;
- 什么数据可以缓存;
- 哪些缓存状态算命中;
- 网络返回后何时写缓存;
- 写缓存失败是否影响本次网络结果。
基本流程如下:
sequenceDiagram
participant UI as Flutter UI
participant R as Repository
participant C as Local Store
participant N as Network API
UI->>R: read(id)
R->>C: get(key)
alt 缓存存在且新鲜
C-->>R: entity + metadata
R-->>UI: 返回缓存
else 缓存未命中或已过期
R->>N: GET /resource/id
alt 网络成功
N-->>R: entity + serverVersion
R->>C: 保存实体和元数据
R-->>UI: 返回网络数据
else 网络失败
R-->>UI: 返回过期缓存或错误
end
end
2.2 Cache-Aside 的读路径
设:
C(k)表示缓存键k的记录;now表示当前时间;fetchedAt表示最近一次成功从服务器获取的时间;ttl表示允许的新鲜时间;fresh(C(k))表示缓存是否新鲜。
一个常见的新鲜判断是:
读流程可以形式化为:
这里的 N(k) 是网络请求。这个公式没有表达一个重要的实际分支:网络失败时可以返回旧缓存。因此更完整的策略是:
“允许 stale”意味着允许把过期数据展示给用户,但必须让调用方知道它不是最新数据。否则用户会把旧数据误认为服务器当前状态。
2.3 最小可运行的 Cache-Aside 示例
下面的代码使用内存存储和模拟 API,目的是展示完整控制流。生产环境可以把 MemoryStore 替换为 SQLite、Isar、Hive、Drift 或其他持久化实现;这些库并非 Flutter SDK 内置 API,需要单独评估平台支持和事务能力。
import 'dart:async';
class Note {
const Note({
required this.id,
required this.title,
required this.updatedAt,
});
final String id;
final String title;
final DateTime updatedAt;
@override
String toString() => 'Note(id: $id, title: $title)';
}
class CacheRecord<T> {
const CacheRecord({
required this.value,
required this.fetchedAt,
});
final T value;
final DateTime fetchedAt;
}
abstract interface class CacheStore<T> {
Future<CacheRecord<T>?> get(String key);
Future<void> put(String key, CacheRecord<T> record);
}
class MemoryStore<T> implements CacheStore<T> {
final Map<String, CacheRecord<T>> _data = {};
@override
Future<CacheRecord<T>?> get(String key) async => _data[key];
@override
Future<void> put(String key, CacheRecord<T> record) async {
_data[key] = record;
}
}
abstract interface class NoteApi {
Future<Note> fetch(String id);
}
class FakeNoteApi implements NoteApi {
bool online = true;
@override
Future<Note> fetch(String id) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
if (!online) {
throw StateError('network unavailable');
}
return Note(
id: id,
title: '来自服务器的笔记',
updatedAt: DateTime.now().toUtc(),
);
}
}
class ReadResult<T> {
const ReadResult({
required this.value,
required this.fromCache,
required this.stale,
});
final T value;
final bool fromCache;
final bool stale;
}
class NoteRepository {
NoteRepository({
required CacheStore<Note> store,
required NoteApi api,
this.ttl = const Duration(minutes: 5),
}) : _store = store,
_api = api;
final CacheStore<Note> _store;
final NoteApi _api;
final Duration ttl;
Future<ReadResult<Note>> getNote(String id) async {
final key = 'note:$id';
final cached = await _store.get(key);
if (cached != null) {
final age = DateTime.now().toUtc().difference(cached.fetchedAt);
final fresh = age < ttl;
if (fresh) {
return ReadResult(
value: cached.value,
fromCache: true,
stale: false,
);
}
}
try {
final remote = await _api.fetch(id);
// 网络成功后再写缓存;写入时间使用成功落盘的当前时间更直观。
await _store.put(
key,
CacheRecord(
value: remote,
fetchedAt: DateTime.now().toUtc(),
),
);
return ReadResult(
value: remote,
fromCache: false,
stale: false,
);
} catch (_) {
// 允许 stale-if-error:网络失败时返回已有旧数据。
if (cached != null) {
return ReadResult(
value: cached.value,
fromCache: true,
stale: true,
);
}
rethrow;
}
}
}
Future<void> main() async {
final store = MemoryStore<Note>();
final api = FakeNoteApi();
final repository = NoteRepository(store: store, api: api);
final first = await repository.getNote('n1');
print(first.value); // 来自服务器的笔记,fromCache=false
final second = await repository.getNote('n1');
print(second.value); // 相同缓存记录,fromCache=true
api.online = false;
final third = await repository.getNote('n1');
print(third.stale); // false,因为默认 TTL 内仍然新鲜
}
这段代码有三个重要性质:
- 第一次读取没有缓存,因此请求网络,成功后写入缓存。
- 第二次读取命中新鲜缓存,不请求网络。
- 如果缓存过期但网络失败,代码仍可返回旧值,并通过
stale告诉界面该数据可能过时。
它也有明确限制:
- 内存存储在进程被杀死后丢失;
DateTime.now()受系统时钟调整影响;- 两个并发
getNote可能同时发现缓存未命中并发出两个请求; - 没有处理服务器删除、分页、认证失效和版本冲突。
这些不是语法问题,而是离线数据层必须继续定义的语义。
三、Cache-Aside 中最容易出现的并发问题
3.1 重复请求
两个并发调用同时执行:
请求 A:读取缓存 -> 未命中 -> 请求网络
请求 B:读取缓存 -> 未命中 -> 请求网络
结果是同一资源被请求两次。这通常不会破坏数据正确性,但会浪费网络和电量。
常见解决办法是 single-flight:以缓存键为单位复用正在进行的请求。
class SingleFlight {
final Map<String, Future<Object?>> _running = {};
Future<T> run<T>(String key, Future<T> Function() action) async {
final existing = _running[key];
if (existing != null) {
return await existing as T;
}
final future = action();
_running[key] = future;
try {
return await future;
} finally {
_running.remove(key);
}
}
}
实际使用时,Future 的清理必须放在 finally 中,否则请求异常后,失败的 Future 可能一直留在表中,导致之后所有调用都复用同一个永久失败结果。
3.2 旧请求覆盖新请求
即使有 single-flight,不同键或不同查询参数仍可能出现竞态:
搜索请求 A:关键词 flutter
搜索请求 B:关键词 flutter cache
B 先返回,显示新结果
A 后返回,覆盖界面
处理方式不是“哪个请求最后完成就用哪个”,而是给请求分配序列号:
int _queryGeneration = 0;
Future<void> search(String keyword) async {
final generation = ++_queryGeneration;
final result = await api.search(keyword);
if (generation != _queryGeneration) {
return; // 不是当前请求,丢弃结果
}
state = result;
}
这只适合界面查询结果。对于实体同步,应该依据服务器版本、操作序号或游标判断,而不是依据网络返回时间。
四、缓存过期:TTL 只是时间规则,不是数据真相
4.1 TTL 的定义
**TTL(Time To Live)**是缓存记录允许保持新鲜的时间长度。若一条记录在 t0 成功获取,TTL 为 5 分钟,则:
TTL 解决的是“多久主动重新检查一次”,不保证服务器在 TTL 内没有变化,也不保证 TTL 到期后数据一定错误。
例如:
- 一条天气数据 30 秒就可能过期;
- 一份帮助文档 24 小时内通常变化不大;
- 银行账户余额不应仅依赖长 TTL 显示为最终金额。
4.2 fetchedAt、updatedAt 和 expiresAt 不是同一个时间
需要区分三个时间:
fetchedAt:客户端何时成功获取这条服务器数据;updatedAt:服务器认为实体何时被修改;expiresAt:客户端何时不再把缓存视为新鲜。
不能用服务器实体的 updatedAt 代替客户端的 fetchedAt。服务器数据可能几个月没修改,但客户端刚刚才拿到它;也可能服务器时间与客户端时间不同步。
一个缓存元数据结构可以是:
class CacheMetadata {
const CacheMetadata({
required this.fetchedAtUtc,
required this.expiresAtUtc,
required this.serverVersion,
});
final DateTime fetchedAtUtc;
final DateTime expiresAtUtc;
final String? serverVersion;
}
expiresAt 可以由服务器返回,也可以由客户端根据策略计算:
final expiresAt = fetchedAt.add(const Duration(minutes: 5));
如果服务器返回明确的 Cache-Control 或业务过期时间,应先定义客户端是否信任它,以及离线时是否允许延长显示时间。HTTP 缓存头和业务缓存 TTL 并不自动等价:HTTP 层可能缓存响应,但业务层仍需要保存离线实体和同步状态。
4.3 过期后的三种策略
立即阻塞刷新
过期后必须请求网络,成功前不返回旧数据。
适合:
- 支付确认页;
- 权限和安全策略;
- 必须展示最新状态的操作。
代价是离线时用户看到错误,而不是内容。
stale-while-revalidate
先返回旧数据,同时后台请求网络,成功后更新界面。
适合:
- 列表页;
- 新闻;
- 用户资料;
- 不要求瞬时最新的业务页面。
数据流是:
读取旧缓存 -> 立即渲染 -> 后台刷新 -> 写缓存 -> 通知界面
Flutter 中可以通过 ChangeNotifier、Riverpod、Bloc、ValueNotifier 或其他状态管理方案通知界面。Flutter SDK 并不规定必须使用某一种状态管理库。
stale-if-error
过期后先尝试网络;网络失败时仍显示旧数据,并标记为离线或过期。
这需要把“有数据显示”和“数据新鲜”拆成两个状态:
hasValue = true
isStale = true
syncError = network unavailable
如果只返回一个 AsyncValue 或一个 loading/error 状态,通常会丢失“有旧数据但刷新失败”这一重要状态。
4.4 时间计算的边界
DateTime.now() 是墙上时钟(wall clock),用户手动调整时间、系统自动校时或时区转换都可能影响它。不要把本地时间字符串直接比较:
// 风险:字符串格式、时区和精度可能不一致
if (savedTimeString.compareTo(DateTime.now().toString()) < 0) {
...
}
应统一保存 UTC:
final nowUtc = DateTime.now().toUtc();
对于需要测量“应用运行了多久”的短周期逻辑,单调时钟更可靠;Dart 的 Stopwatch 可以测量经过时间,但不能跨进程重启保存。因此持久化 TTL 通常仍要保存 UTC 时间,同时接受系统时钟变化这一现实边界。
五、从“读缓存”到“离线写入”:同步模型
只读缓存相对简单。真正的离线应用还要允许用户在没有网络时修改数据。
假设用户离线修改了一条任务:
本地任务标题:修复登录问题
服务器任务标题:修复首页问题
应用需要立即让用户看到自己的修改,同时记录这次修改以后再上传。仅更新本地实体是不够的,因为应用重启后不知道这次修改是否上传过。
5.1 Outbox:把本地修改记录为待发送操作
一个 Outbox 项目可以包含:
class PendingOperation {
const PendingOperation({
required this.operationId,
required this.userId,
required this.entityId,
required this.type,
required this.payload,
required this.createdAtUtc,
required this.attempts,
});
final String operationId; // 全局唯一,用于幂等
final String userId;
final String entityId;
final String type; // update / delete
final Map<String, Object?> payload;
final DateTime createdAtUtc;
final int attempts;
}
离线写入应尽量在一个本地事务中完成:
事务开始
1. 修改实体表
2. 写入 Outbox
事务提交
如果只完成第 1 步就崩溃,应用会显示本地修改,但永远不会上传;如果只完成第 2 步,重放操作时又找不到对应的本地实体。数据库必须提供真正的事务,而不是仅仅连续调用两个异步方法。
5.2 本地写入的状态变化
以“编辑任务标题”为例:
stateDiagram-v2
[*] --> Synced: 已有服务器数据
Synced --> LocallyModified: 修改实体并写入 Outbox
LocallyModified --> Syncing: 网络可用,开始上传
Syncing --> Synced: 服务器接受
Syncing --> LocallyModified: 临时失败,保留 Outbox
Syncing --> Conflict: 版本冲突
Conflict --> Synced: 自动合并或用户选择
Conflict --> LocallyModified: 生成新的本地修改
关键点是:“网络可用”不等于“同步成功”。同步仍可能因为认证过期、权限改变、服务器冲突、请求超时或服务端错误而失败。
5.3 推送和拉取的顺序
同步通常包含两个方向:
- Push:上传本地待同步操作;
- Pull:拉取服务器上本地不知道的变化。
一个保守的顺序是:
1. 拉取服务器变化
2. 将变化合并到本地
3. 上传本地 Outbox
4. 处理服务器响应
5. 再次拉取,确认最终状态
但这不是普遍正确的固定顺序。例如聊天消息通常先上传本地消息,再拉取新消息;协作编辑则可能需要持续双向同步。
决定顺序的依据是业务不变量。例如:
- 删除后重新拉取,是否可能把已删除对象恢复?
- 本地编辑必须基于服务器最新版本吗?
- 上传操作是否依赖服务器生成的 ID?
- 服务端是否支持游标和幂等操作?
六、同步协议必须有幂等性和进度
6.1 为什么需要操作 ID
假设客户端上传成功,但在收到响应前网络断开:
客户端:发送 update
服务器:已经写入成功
网络:响应丢失
客户端:认为失败,重新发送 update
如果服务器把每次请求都当成新操作,可能产生重复副作用,例如:
- 重复创建订单;
- 重复发送消息;
- 重复扣款;
- 重复追加日志。
因此每个可重试操作应带有唯一 operationId。服务器保存已经处理过的操作 ID:
operationId = 7f...
第一次请求:执行操作并记录 7f...
第二次请求:发现 7f... 已处理,返回第一次结果
这就是幂等处理。HTTP 方法本身的语义不能自动解决所有业务幂等问题;即使请求使用 PUT,服务端仍要正确设计资源版本和副作用。
6.2 拉取游标
如果每次同步都从头下载全部数据,数据量会不断增长。常见做法是服务器返回游标:
{
"items": [
{"id": "a", "version": 11},
{"id": "b", "version": 8}
],
"nextCursor": "cursor-abc"
}
客户端保存 nextCursor,下一次从该位置继续拉取。
但游标不能只保存在内存中。正确顺序通常是:
1. 请求 cursor = C
2. 在本地事务中应用 items
3. 在同一个事务中保存 nextCursor
4. 提交事务
如果第 2 步提交成功而第 3 步失败,下一次会重复拉取部分数据。因此应用必须让“应用变化”和“推进游标”具有一致性。重复拉取本身仍应安全,这也是服务端实体写入需要幂等的原因。
七、冲突:两个正确修改同时发生时怎么办
7.1 冲突的定义
当本地基于版本 v 修改实体,而服务器已经从 v 变成 v+1,客户端再提交修改时,就发生了版本冲突。
服务器可以用乐观并发控制表达这一条件:
客户端上传:
entityId = task-1
baseVersion = 10
newTitle = "本地标题"
服务器当前版本:
version = 11
如果服务器要求:
则 10 != 11,服务器拒绝本次写入并返回冲突,而不是静默覆盖版本 11。
HTTP API 常见的实现方式包括:
- 请求体中的
baseVersion; If-Match: "etag-value";- 服务器返回新的版本号或 ETag。
具体 API 形式取决于后端协议;Flutter 本身不提供冲突解决协议。
7.2 完整冲突算例
初始状态:
服务器 S0:
title = "原始标题"
version = 10
客户端 A 本地:
title = "原始标题"
baseVersion = 10
客户端 B 在线修改:
B 上传 title = "服务器标题", baseVersion = 10
服务器接受:
title = "服务器标题"
version = 11
此时 A 离线修改:
A 本地 title = "本地标题"
A 的操作仍然基于 baseVersion = 10
A 恢复网络后上传:
A -> server:
baseVersion = 10
title = "本地标题"
服务器发现:
10 != 11
于是返回:
{
"error": "version_conflict",
"server": {
"title": "服务器标题",
"version": 11
},
"client": {
"title": "本地标题",
"baseVersion": 10
}
}
此时有四类处理策略。
7.3 Last-Write-Wins:最后写入获胜
服务器直接接受最后到达的请求:
最终 title = "本地标题"
这种策略简单,但“最后”通常是服务器收到请求的时间,不一定是用户最后修改的时间。网络延迟可能让旧修改晚到,从而覆盖新修改。
因此以下推导是不成立的:
请求晚到 => 用户修改得更晚
如果使用时间判定,还会遇到设备时钟不可靠、时区和精度不同的问题。LWW 适合丢失修改代价较低的字段,不适合金额、库存和重要协作内容。
7.4 字段级合并
如果两个客户端修改的是不同字段,可以按字段合并:
服务器:title = "原始标题", done = false
本地: title = "本地标题", done = false
远端: title = "原始标题", done = true
合并后:
title = "本地标题"
done = true
但字段级合并必须知道每个字段的基线和修改来源。如果本地和远端都修改 title,就不能因为“都是字符串”而安全合并。
7.5 基于操作的合并
对集合、计数器等数据,可以上传操作而非最终值:
increment(1)
addItem("x")
removeItem("y")
两个 increment(1) 可以合并为增加 2,通常比上传“当前总数”更不容易丢失修改。
但删除和添加存在因果关系。例如:
客户端 A:删除 item-x
客户端 B:离线编辑 item-x
如果没有操作顺序、版本或因果信息,服务器不能可靠决定编辑是否应该复活已删除对象。
7.6 用户解决冲突
无法安全自动合并时,应把冲突显式展示:
当前服务器版本:服务器标题
你的本地版本:本地标题
选择:保留服务器 / 保留本地 / 手动合并
用户选择后要生成一个基于最新服务器版本的新操作:
baseVersion = 11
newTitle = 用户最终选择
不能简单重复原来的 baseVersion = 10 请求,否则会再次冲突。
八、删除是同步中最容易被忽略的冲突
如果客户端只保存“当前存在的实体”,服务器删除后,客户端下一次拉取可能无法区分:
服务器真的删除了它
还是客户端从未收到过它
因此同步系统通常需要 tombstone(墓碑),也就是保留删除记录:
{
"id": "task-1",
"deleted": true,
"version": 12
}
客户端收到墓碑后:
- 删除或隐藏本地实体;
- 保存删除版本;
- 参与后续版本比较;
- 在安全的同步窗口后再清理墓碑。
如果立即物理删除墓碑,旧客户端可能在下一次上传或拉取时把已经删除的实体重新创建。
本地删除也需要进入 Outbox:
事务:
1. 标记实体 deleted = true 或写入本地墓碑
2. 写入 delete 操作
在 UI 中隐藏删除项不代表服务器已经删除;同步状态仍应保留。
九、用户隔离:缓存键必须包含身份边界
9.1 错误的缓存键
下面的键对单用户应用可能看似正常:
profile
todos
settings
但如果同一设备可以登录多个账号,用户 A 的数据会与用户 B 共用缓存。
一种典型故障路径是:
1. 用户 A 登录
2. 读取并缓存 key = "profile"
3. A 退出登录,但缓存没有清理
4. 用户 B 登录
5. B 读取 key = "profile"
6. 读到 A 的资料
这不是单纯的显示错误,可能造成隐私泄露。
9.2 命名空间
缓存键至少应包含稳定的用户身份:
user:{userId}:profile
user:{userId}:todos:{page}
user:{userId}:note:{noteId}
更稳妥的设计是让存储层本身带 namespace,而不是让每个调用方手写字符串:
class UserScopedKey {
const UserScopedKey(this.userId);
final String userId;
String entity(String type, String id) => '$userId:$type:$id';
}
如果用户身份来自可变的邮箱地址,不建议直接用邮箱作为唯一键:
- 大小写和规范化可能变化;
- 邮箱可能被修改;
- 可能包含特殊字符;
- 服务端真正的主键通常是不可变 user ID。
9.3 认证令牌和业务缓存不是同一种数据
访问令牌、刷新令牌和业务实体应分开处理:
- 令牌需要更严格的机密性保护;
- 业务缓存通常需要查询、事务和迁移;
- 退出登录时,令牌撤销与业务缓存清理有不同语义。
不要把令牌放入普通业务数据库或明文偏好设置中,除非已经明确评估平台安全边界。Android、iOS 通常有各自的安全凭据存储机制;Flutter SDK 不自动替你选择和配置安全存储方案。
9.4 退出登录的正确顺序
一个安全的退出流程可以是:
1. 阻止新的需要认证的读写请求
2. 取消或标记旧用户的后台同步任务
3. 停止旧用户的内存状态订阅
4. 清理或隔离旧用户的业务缓存和 Outbox
5. 清理认证凭据
6. 清空界面状态
7. 进入未登录路由
必须注意异步竞态:
A 的网络请求尚未返回
用户已经退出并登录 B
A 的响应随后返回
如果直接写入当前仓库,就可能污染 B 的状态
因此请求结果写入前应验证用户会话代数:
int _sessionGeneration = 0;
Future<void> loadForCurrentUser() async {
final generation = _sessionGeneration;
final userId = currentUserId;
final data = await api.fetchProfile(userId);
if (generation != _sessionGeneration || userId != currentUserId) {
return; // 响应属于旧会话
}
state = data;
}
void logout() {
_sessionGeneration++;
currentUserId = null;
}
这只是内存层防护。持久化层仍应把 userId 写入每条实体、Outbox 和同步游标,并在查询条件中强制使用它:
SELECT * FROM notes
WHERE user_id = ? AND id = ?;
不能只依赖调用方“传入了正确的 key”。数据访问接口本身应尽量强制要求 user ID。
十、哪些数据不应缓存,哪些数据不能仅靠缓存
缓存并不自动适合所有响应。
通常不应直接持久化的内容包括:
- 短期认证响应;
- 高敏感个人数据;
- 一次性验证码;
- 依赖实时权限的结果;
- 明确要求不落盘的隐私内容。
即使缓存了普通业务数据,也不应把缓存当作授权依据:
缓存显示“用户是管理员”
不能因此允许用户执行管理员操作。真正的权限检查必须由服务器执行,客户端缓存最多用于界面优化。
对于“余额”“库存”“未读数”等数据,缓存可以改善离线体验,但在执行关键操作前通常需要在线确认,或者明确展示“最后同步时间”。
十一、Flutter 生命周期和平台差异
11.1 Flutter 不提供通用离线数据库
Flutter 框架提供 UI、生命周期和平台通道等能力,但不内置一个跨 Android、iOS、桌面和 Web 的通用离线实体数据库。
因此应用需要选择存储实现:
- SQLite/Drift/sqflite:适合关系查询、事务和迁移;
- Isar、Hive 等:适合特定对象存储场景;
shared_preferences:适合少量简单偏好,不适合复杂实体、分页、事务和 Outbox;- Web 平台:通常需要 IndexedDB 或支持 Web 的第三方存储库;
- 文件:适合导出、快照或简单单文件数据,但并发、事务和查询能力有限。
选择时应先看目标平台和库的实际支持矩阵,而不是因为某个库在移动端可用就假定 Web 和桌面行为相同。
11.2 Android 和 iOS
移动平台可能随时暂停或终止进程:
AppLifecycleState.paused不是“应用一定还有足够时间同步”;- 进入后台后网络和执行时间受到平台调度约束;
- 应在前台时尽快提交本地事务;
- 后台同步应使用各平台允许的后台机制,不能只依赖 Flutter isolate 永久运行。
监听生命周期适合触发“尽快同步”:
class LifecycleObserver with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// 前台恢复:检查过期数据并尝试同步
unawaited(sync());
}
}
Future<void> sync() async {
// 真实代码中处理异常、取消、并发锁和认证状态
}
}
这里的 unawaited 来自 dart:async。调用后台 Future 时仍必须在 sync 内部捕获异常,否则可能出现未处理的异步错误。
11.3 桌面平台
Windows、macOS、Linux 通常具有更稳定的文件系统和更长的前台运行时间,但仍可能发生:
- 用户直接终止进程;
- 多窗口或多实例同时访问数据库;
- 文件权限和路径不同;
- 自动更新导致数据库迁移失败。
不能因为桌面有文件系统,就用“写一个 JSON 文件”替代事务数据库。实体写入和 Outbox 写入需要原子性时,应选择支持事务的实现。
11.4 Web 平台
Web 与移动端有几个重要差异:
- 浏览器存储有配额和清理策略;
- 隐私模式、站点数据清理和浏览器策略可能导致数据丢失;
- 多标签页可能同时修改同一份本地数据;
- 后台执行和网络恢复时机受浏览器控制;
dart:io不适用于 Web。
Web 缓存不应被当成永久本地数据库。需要处理配额异常、版本迁移失败和跨标签页同步。若多个标签页共享同一账号,应设计锁、版本检查或广播机制,避免两个页面同时推进 Outbox 和游标。
十二、网络状态只能触发同步,不能证明网络可用
connectivity_plus 等第三方库可以告诉应用当前网络连接类型,但“连接了 Wi-Fi”不等于:
- DNS 可用;
- 目标 API 可达;
- TLS 握手成功;
- 用户已经认证;
- 服务器接受请求。
因此同步触发条件应是:
网络状态变化 -> 尝试同步
应用回到前台 -> 尝试同步
用户手动刷新 -> 尝试同步
Outbox 有新项目 -> 尝试同步
而同步成功条件必须是实际请求成功并满足业务响应,例如 HTTP 2xx、版本已确认、游标已安全推进。
恢复策略通常需要区分错误类型:
- 超时、临时 DNS 失败、5xx:指数退避重试;
- 401:刷新令牌或要求重新登录;
- 403:不要盲目重试;
- 409:进入冲突处理;
- 400:修复请求或丢弃不可重试操作;
- 数据库损坏:停止写入并进入恢复流程。
指数退避可表示为:
其中:
n是连续失败次数;D0是初始等待时间;Dmax是最大等待时间;jitter是随机扰动,用于避免大量客户端同时重试。
十三、一个更完整的数据层结构
一个实用的分层方式如下:
Widget / State Management
|
v
Repository
- Cache-Aside 读取
- 本地事务
- stale 策略
- 会话校验
|
+--> Local Database
| - entities
| - metadata
| - outbox
| - tombstones
| - sync cursor
|
+--> Remote API
- 版本校验
- 幂等 operationId
- 增量拉取
- 冲突响应
Repository 不应该把“是否在线”当作唯一分支:
if (isOnline) {
return await api.fetch();
} else {
return await cache.read();
}
因为在线请求仍可能失败,离线时也可能存在待同步本地修改。更准确的流程是:
先读取本地状态
根据缓存新鲜度决定是否刷新
刷新失败时根据策略使用旧数据
本地写入始终记录待同步操作
同步器独立处理 Outbox
这样界面不必等待网络才能展示已有数据,网络同步也不会阻塞本地编辑。
十四、诊断:如何判断问题来自缓存、同步还是隔离
生产问题应记录足够的诊断字段,但避免写入敏感数据。每条实体或操作至少可以记录:
userIdHash 当前用户的不可逆标识
entityId 实体 ID
operationId 操作 ID
localVersion 本地版本
baseVersion 上传时基线版本
serverVersion 最近服务器版本
fetchedAt 获取时间
updatedAt 本地更新时间
syncState synced / pending / failed / conflict
attempts 重试次数
lastSyncError 错误类别,而非完整敏感响应
典型故障与诊断路径:
显示了旧数据
检查:
fetchedAt是否正确保存为 UTC;- TTL 计算是否受设备时间修改影响;
- 网络失败时是否启用了 stale-if-error;
- 网络成功后是否真的写入数据库;
- UI 是否订阅了缓存更新,而不是只读取一次。
本地修改消失
检查:
- 实体更新和 Outbox 是否在同一事务;
- 重启后是否从数据库重新加载实体;
- 同步失败时是否错误地删除了 Outbox;
- 服务器冲突响应是否被当成普通成功;
- 是否有旧网络响应覆盖本地新修改。
重复创建数据
检查:
- 是否每次重试都生成新的 operationId;
- 服务端是否保存并识别 operationId;
- 客户端是否在响应丢失后安全重试;
- “上传成功”是否在写入本地确认状态前发生崩溃。
用户 A 的数据出现在用户 B
检查:
- 缓存键是否没有 user ID;
- SQL 查询是否漏掉
WHERE user_id = ?; - 退出登录时旧请求是否仍能写入当前状态;
- 内存仓库是否在切换会话时清空;
- Web 多标签页是否共享了错误的全局缓存。
十五、测试必须覆盖故障路径,而不是只测在线成功
离线缓存的核心正确性来自异常场景测试。至少应覆盖:
缓存读取
缓存不存在 + 网络成功 -> 返回网络数据并写缓存
缓存新鲜 + 网络可用 -> 是否按策略直接返回缓存
缓存过期 + 网络成功 -> 返回新数据
缓存过期 + 网络失败 -> 返回 stale 或错误
缓存损坏 -> 删除坏记录并重新获取
本地写入和同步
实体写入成功 + Outbox 写入成功 -> 状态为 pending
实体写入成功 + Outbox 写入失败 -> 整体回滚
上传超时 -> Outbox 保留
响应丢失后重试 -> 不重复产生服务器副作用
服务器返回 409 -> 状态为 conflict
服务器返回 400 -> 不无限重试
用户隔离
A 登录并缓存数据 -> 退出 -> B 登录 -> 不能读到 A
A 请求晚返回 -> B 登录 -> A 响应不能写入 B
A 的 Outbox -> B 登录 -> 同步器不能以 B 身份发送 A 的操作
时间和平台
系统时钟向后调整 -> 不应把明显旧数据错误判断为永久新鲜
进程在事务中被终止 -> 重启后实体和 Outbox 状态一致
Web 存储配额不足 -> 显示可恢复错误,不丢失内存中的当前编辑
应用从后台恢复 -> 同步可重复执行且不会产生重复操作
十六、常见误解和对应反例
误解一:有缓存就算离线支持
反例:
应用可以离线读取文章,
但离线点赞后没有 Outbox。
用户界面显示“已点赞”,重启后状态消失。这里有离线读取,没有离线写入和同步。
误解二:网络恢复后重试最后一次请求就够了
反例:
用户离线完成了三次编辑:
A -> B -> C
如果只保存最后一次内存请求,应用崩溃后可能完全不知道这些修改;如果每次操作都有副作用,还可能需要按顺序重放。应根据业务选择保存最终状态还是操作日志,并定义合并规则。
误解三:服务器时间晚的版本一定更新
反例:
设备时钟错误,上传的 clientUpdatedAt = 2099 年
如果服务器信任客户端时间,旧修改可能永久覆盖新修改。版本号、服务器生成的修订序列或 ETag 通常比客户端时间更可靠。
误解四:清空当前页面数据就完成退出登录
反例:
旧用户的数据库记录仍在
旧用户请求稍后返回
新用户读取相同的通用 key
页面清空不能替代持久化层隔离和异步会话校验。
误解五:HTTP 200 就代表本地同步完成
反例:
服务器返回 200
客户端在写入“已同步”状态前崩溃
重启后 Outbox 仍存在,客户端会再次发送。若服务端不支持幂等,可能产生重复副作用。因此“客户端最终确认”和“服务端幂等”必须同时设计。
十七、设计时应明确的契约
一个离线实体的同步契约至少应明确:
实体身份:entityId 是否全局唯一
用户边界:userId 是否参与所有存储和查询
本地状态:实体是否允许先于服务器存在
版本机制:整数版本、ETag、修订号还是时间
写入语义:最终状态更新还是操作追加
重试语义:请求是否幂等
删除语义:是否使用 tombstone
冲突策略:覆盖、字段合并、操作合并还是人工选择
过期策略:阻塞刷新、stale-while-revalidate、stale-if-error
同步进度:游标、时间戳还是服务器变更日志
失败处理:哪些错误可重试,哪些必须停止
数据安全:哪些字段允许落盘,退出时如何处理
如果这些问题没有答案,代码即使能在演示环境中“离线显示数据”,也不能说明它在断网、重启、重复请求、切换账号和并发修改下仍然正确。
结语
Cache-Aside 解决的是“如何在缓存和网络之间读取数据”;TTL 解决的是“何时认为缓存需要重新验证”;Outbox 和同步解决的是“离线修改如何最终送达服务器”;版本校验和冲突策略解决的是“本地与服务器同时修改时如何保留正确结果”;用户命名空间和会话校验解决的是“不同用户的数据如何不互相污染”。
一个可靠的 Flutter 离线数据层应把这些语义分别建模:
实体数据
+ 获取和过期元数据
+ 本地待同步操作
+ 服务器版本
+ 删除墓碑
+ 同步游标
+ 用户命名空间
Flutter 负责应用界面和生命周期接入,具体的数据库、后台任务和网络协议需要结合目标平台与业务后端实现。真正的离线能力不在于“断网时还能读到一份旧 JSON”,而在于应用能够在断网、崩溃、重试、冲突、过期和账号切换后,仍然保持数据状态可解释、可恢复且不会越过用户边界。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter JSON 模型:手写、json_serializable、Freezed 和版本演进
- 下一篇:Flutter SQLite 与 Drift:Schema、查询、事务、迁移和响应式数据
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论