Flutter 基础体系 · 第 58/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 离线缓存:Cache-Aside、同步、冲突、过期和用户隔离

离线缓存不是“把接口结果存到本地,下次直接读取”。一个可用的离线数据层至少要回答六个问题:

  1. 读取时先查哪里,缓存未命中时如何访问网络?
  2. 用户修改数据后,界面显示的是本地结果还是服务器结果?
  3. 网络恢复时,哪些本地修改需要上传,上传失败后如何重试?
  4. 本地和服务器同时修改同一条数据时,谁获胜,为什么?
  5. 缓存什么时候算过期,过期后是否还能显示?
  6. 用户 A 退出、用户 B 登录后,为什么不能读到 A 的数据?

这些问题分别对应 Cache-Aside、同步、冲突、过期和用户隔离。它们不是互相独立的功能:缓存键设计会影响用户隔离,写入策略会影响冲突处理,过期判断会影响同步触发,应用生命周期又会影响同步是否真的执行。


一、先区分缓存、本地数据和服务器事实

缓存是可以被重新获取的数据副本。它的特点是:

  • 丢失后可以重新从服务器恢复;
  • 可能过期;
  • 不能仅凭“本地已有”推断它仍然正确。

本地持久化数据是应用为了离线使用而保存的数据。它可能同时包含两类内容:

  • 从服务器同步来的实体,例如文章、任务、用户资料;
  • 尚未上传的本地操作,例如“修改标题”“删除任务”。

后一类通常称为 待同步操作,也常见于 Outbox(发件箱)设计。

服务器是“事实来源”(source of truth)还是本地是事实来源,取决于业务:

  • 新闻阅读:服务器通常是事实来源,本地只是缓存;
  • 离线记账:本地必须先接受用户操作,服务器负责合并;
  • 草稿编辑:本地草稿可能比服务器数据更重要,不能简单用服务器响应覆盖。

因此,“离线缓存”通常不是一个 Map<String, Object>,而是至少包含:

实体表:          当前本地可展示的数据
元数据:          版本、获取时间、同步状态
待同步操作表:    本地已经接受、但服务器尚未确认的修改
冲突记录表:      无法自动合并、需要用户处理的数据

如果只保存实体,不保存版本、来源和待同步状态,应用无法可靠判断:

  • 这条数据是从未获取过,还是已经过期;
  • 这次本地修改是否已经上传;
  • 上传失败后重试是否会重复执行;
  • 服务器返回旧版本时是否应该覆盖本地内容。

二、Cache-Aside:应用显式管理缓存

2.1 定义

Cache-Aside,也叫旁路缓存,是一种由业务代码显式读写缓存的模式:

  1. 读取时先查缓存;
  2. 缓存命中且可接受时直接返回;
  3. 缓存未命中或不可接受时访问网络;
  4. 网络成功后由应用把结果写入缓存;
  5. 后续读取再从缓存返回。

缓存不会自动拦截所有请求,业务代码必须决定:

  • 什么是缓存键;
  • 什么数据可以缓存;
  • 哪些缓存状态算命中;
  • 网络返回后何时写缓存;
  • 写缓存失败是否影响本次网络结果。

基本流程如下:

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)) 表示缓存是否新鲜。

一个常见的新鲜判断是:

fresh(C(k))    C(k)\存nowfetchedAt<ttlfresh(C(k)) \iff C(k)\存在 \land now - fetchedAt < ttl

读流程可以形式化为:

read(k)={C(k),如果 fresh(C(k))N(k)write(C(k))result,否则read(k)= \begin{cases} C(k), & \text{如果 } fresh(C(k)) \\ N(k)\rightarrow write(C(k))\rightarrow result, & \text{否则} \end{cases}

这里的 N(k) 是网络请求。这个公式没有表达一个重要的实际分支:网络失败时可以返回旧缓存。因此更完整的策略是:

read(k)={C(k),fresh(C(k))N(k)C(k),网络成功C(k),网络失败且允许staleerror,网络失败且不允许staleread(k)= \begin{cases} C(k), & fresh(C(k)) \\ N(k)\rightarrow C(k), & 网络成功 \\ C(k), & 网络失败且允许 stale \\ error, & 网络失败且不允许 stale \end{cases}

“允许 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 内仍然新鲜
}

这段代码有三个重要性质:

  1. 第一次读取没有缓存,因此请求网络,成功后写入缓存。
  2. 第二次读取命中新鲜缓存,不请求网络。
  3. 如果缓存过期但网络失败,代码仍可返回旧值,并通过 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 分钟,则:

fresh    now<t0+5分钟fresh \iff now < t_0 + 5\text{分钟}

TTL 解决的是“多久主动重新检查一次”,不保证服务器在 TTL 内没有变化,也不保证 TTL 到期后数据一定错误。

例如:

  • 一条天气数据 30 秒就可能过期;
  • 一份帮助文档 24 小时内通常变化不大;
  • 银行账户余额不应仅依赖长 TTL 显示为最终金额。

4.2 fetchedAtupdatedAtexpiresAt 不是同一个时间

需要区分三个时间:

  • 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

如果服务器要求:

baseVersion=currentVersionbaseVersion = currentVersion

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
}

客户端收到墓碑后:

  1. 删除或隐藏本地实体;
  2. 保存删除版本;
  3. 参与后续版本比较;
  4. 在安全的同步窗口后再清理墓碑。

如果立即物理删除墓碑,旧客户端可能在下一次上传或拉取时把已经删除的实体重新创建。

本地删除也需要进入 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:修复请求或丢弃不可重试操作;
  • 数据库损坏:停止写入并进入恢复流程。

指数退避可表示为:

delayn=min(Dmax,D0×2n)+jitterdelay_n=\min(D_{max}, D_0 \times 2^n)+jitter

其中:

  • 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     错误类别,而非完整敏感响应

典型故障与诊断路径:

显示了旧数据

检查:

  1. fetchedAt 是否正确保存为 UTC;
  2. TTL 计算是否受设备时间修改影响;
  3. 网络失败时是否启用了 stale-if-error;
  4. 网络成功后是否真的写入数据库;
  5. UI 是否订阅了缓存更新,而不是只读取一次。

本地修改消失

检查:

  1. 实体更新和 Outbox 是否在同一事务;
  2. 重启后是否从数据库重新加载实体;
  3. 同步失败时是否错误地删除了 Outbox;
  4. 服务器冲突响应是否被当成普通成功;
  5. 是否有旧网络响应覆盖本地新修改。

重复创建数据

检查:

  1. 是否每次重试都生成新的 operationId;
  2. 服务端是否保存并识别 operationId;
  3. 客户端是否在响应丢失后安全重试;
  4. “上传成功”是否在写入本地确认状态前发生崩溃。

用户 A 的数据出现在用户 B

检查:

  1. 缓存键是否没有 user ID;
  2. SQL 查询是否漏掉 WHERE user_id = ?
  3. 退出登录时旧请求是否仍能写入当前状态;
  4. 内存仓库是否在切换会话时清空;
  5. 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。