客户端开发

缓存 Tab 的状态同步:如何避免旧请求覆盖新操作

结合多 Tab 页面保活与列表刷新场景,分析双向通知遗漏、异步回调修改旧对象与慢查询回退新状态的成因及协调方法。

约 14 分钟读完
目录
  1. 1. 两类典型的异步竞态现场
  2. 现场 A:写请求回调修改了已被替换的旧对象
  3. 现场 B:晚返回的旧查询覆盖了刚成功的写操作
  4. 2. 最小协调器实现:generation + revision + 按条目加锁
  5. 3. 使用 Completer 编写乱序回归测试

客户端应用里只要同时存在多个缓存保活的 Tab 页面(例如 Flutter 的 IndexedStack / AutomaticKeepAliveClientMixin,或 Jetpack Compose 的 HorizontalPager),并且同一份业务实体(比如某条内容的「收藏」「预约」或「订阅」状态)可以在不同页面被修改,就容易遇到两类状态不同步问题:

  1. 单向同步遗漏:在「历史记录」Tab 点亮收藏图标后,「我的收藏」Tab 跟着刷新了;但反过来在「我的收藏」里删掉这条记录再切回「历史记录」,那颗星标却还亮着——因为两个缓存 Tab 切换时并不会重新触发首次挂载的初始化查询(比如 Compose 的 LaunchedEffect(Unit) / DisposableEffect(Unit) 或 Flutter 的 initState)。
  2. 更隐蔽的请求交错回退:明明已经补齐了跨页通知,但用户连续操作时,偶尔会出现“按钮有点击水波纹动画,状态却没变”,或者“图标刚变亮半秒钟,又自己跳回了未选中”。而且在 Debug 模式下因为带调试开销反而不易复现,打包为 Release 版本后复现概率明显变高。

第二类现象如果只盯着按钮 UI 或怀疑后端没存进去,往往越改越乱——多调一次 setState、硬加一个 Future.delayed(300ms) 都只是在碰运气改变时间差。把点击、列表刷新和对象引用拆成时间线之后,才发现冲突点。

1. 两类典型的异步竞态现场

现场 A:写请求回调修改了已被替换的旧对象

不少列表页在点击某一行按钮时,直接把当前行的模型引用 item 传进了异步回调里就地修改字段:

时间线(修复前:直接修改点击时刻捕获的对象引用)
--------------------------------------------------------------------------------
t1  首页发起列表刷新查询 Q1(服务端此时记录条目 A 为:未预约)
t2  用户点击条目 A 的「预约」按钮,闭包捕获当前列表里的对象实例 A1,发出写请求 W1
t3  查询 Q1 返回 -> 页面用新解析出的列表替换旧列表,当前 UI 绑定的实例变成了 A2(未预约)
t4  写请求 W1 返回成功 -> 回调执行 A1.reserved = true 并触发重绘
结果:页面重绘读的是新列表里的 A2,A1 早已不在视图树中;用户看到「点了有动画,状态没变」

在此处假设 t4 检查了 if (!mounted) return;,也还是会出现这个问题:页面从来没有销毁,是列表内数据对象实例发生了变化。

现场 B:晚返回的旧查询覆盖了刚成功的写操作

假设改为了按稳定业务 ID 去更新当前列表,对并发查询采取“谁最后返回就全盘采用谁”的策略,还是会出现问题:

时间线(修复前:慢查询无条件覆盖写操作结果)
--------------------------------------------------------------------------------
t1  管理页取消预约条目 A 成功 -> 通知保活的首页按 ID 把 A 改为「未预约」,同时触发首页刷新 Q1
t2  用户切回首页,顺手再次点击条目 A 重新「预约」,发出写请求 W2
t3  写请求 W2 很快成功 -> 首页按 ID 把 A 更新为「已预约」(按钮变亮)
t4  t1 时刻发出的慢查询 Q1 这时才返回(它读到的是 W2 发生前的旧快照:A 未预约)
t5  Q1 直接覆盖首页列表 -> 条目 A 的状态被回退为「未预约」

要同时修复这两个漏洞,客户端协调层需要把三件事分开处理:按稳定业务 ID 定位数据、用查询序号(generation)丢弃乱序旧查询、用操作版本号(revision)保护查询在途期间刚成功的写操作。

2. 最小协调器实现:generation + revision + 按条目加锁

下面用一段不依赖特定第三方框架的纯 Dart 状态协调代码,把这套防覆盖逻辑完整串起来(同样的数据结构可直接平移到 Kotlin ViewModel + StateFlow 中):

class FeedItem {
  const FeedItem({required this.id, required this.title, required this.reserved});
  final String id;
  final String title;
  final bool reserved;

  FeedItem copyWith({bool? reserved}) =>
      FeedItem(id: id, title: title, reserved: reserved ?? this.reserved);
}

class ReservationListController {
  List<FeedItem> items = const [];

  /// 按业务 ID 记录正在提交写请求的条目,避免整页共用一个全局布尔锁互相阻塞
  final Set<String> pendingIds = <String>{};

  int _queryGeneration = 0;
  int _writeRevision = 0;

  /// 记录每次成功写操作后的目标状态与当时的写版本号
  final Map<String, ({bool reserved, int revision})> _confirmedMutations = {};

  /// 1. 发起列表刷新:记录发出时刻的查询序号与写操作版本号
  Future<void> refresh(Future<List<FeedItem>> Function() fetchRemote) async {
    final myGen = ++_queryGeneration;
    final snapshotRevision = _writeRevision;

    final fetched = await fetchRemote();

    // 若等待期间已有更新的查询发出,直接丢弃本次过期响应
    if (myGen != _queryGeneration) return;

    // 合并本次查询发出之后(revision > snapshotRevision)才成功确认的本地写操作;
    // 对查询发出之前已完成确认的操作,则正常采用服务端最新结果并清理旧覆盖记录。
    items = [
      for (final item in fetched) _reconcileItem(item, snapshotRevision),
    ];
  }

  FeedItem _reconcileItem(FeedItem serverItem, int snapshotRevision) {
    final mutation = _confirmedMutations[serverItem.id];
    if (mutation == null) return serverItem;
    if (mutation.revision > snapshotRevision) {
      return serverItem.copyWith(reserved: mutation.reserved);
    }
    if (serverItem.reserved == mutation.reserved) {
      _confirmedMutations.remove(serverItem.id);
    }
    return serverItem;
  }

  /// 2. 当前页点击或收到相邻缓存 Tab 的成功变更通知
  Future<void> toggleReserve(
    String itemId, {
    required bool targetReserved,
    required Future<void> Function(String id, bool reserved) submitWrite,
  }) async {
    // 仅拦截同一条目的重复点击,不影响用户同时操作列表里的其他条目
    if (!pendingIds.add(itemId)) return;
    try {
      await submitWrite(itemId, targetReserved);
      applyConfirmedMutation(itemId, reserved: targetReserved);
    } finally {
      pendingIds.remove(itemId);
    }
  }

  /// 供本页写成功或跨 Tab 变更回调统一调用:仅在服务端确认成功后推进版本,失败不伪造成功态
  void applyConfirmedMutation(String itemId, {required bool reserved}) {
    final rev = ++_writeRevision;
    _confirmedMutations[itemId] = (reserved: reserved, revision: rev);
    items = [
      for (final item in items)
        if (item.id == itemId) item.copyWith(reserved: reserved) else item,
    ];
  }
}

这段控制器代码主要处理了四个容易出错的环节:

  1. 同时保留 _queryGeneration 与 _writeRevision 的原因: 假设页面只发了一次查询 Q1,没有发 Q2,那么 Q1 返回时它依然是“最新发出的那次查询”(myGen == _queryGeneration);但在 Q1 尚未返回的窗口期内,用户点击成功了一次预约。没有 _writeRevision 版本号比对,Q1 作为最新查询仍会覆盖这次已成功的操作。
  2. 本地覆盖记录的清理时机: 本地覆盖记录只负责保护“与写请求时间重叠的那批在途旧查询”。等到下一轮新查询(snapshotRevision >= mutation.revision)带回了服务端权威结果且状态一致时,就应当及时移除本地覆盖,避免因为保留局部覆盖记录而忽略服务端的后续真实变更。
  3. 请求锁按条目 itemId 粒度加,而不是全局 bool isSaving:全局共用一把锁时,条目 A 的请求还没回来,用户去点条目 B 就会出现“按钮有按压波纹、实际回调直接提前 return 未发请求”的现象。
  4. 区分业务内容 ID 与关联记录 ID:在做「历史页 ↔ 收藏夹」双向同步时,删除收藏接口传的往往是单条收藏记录的 favoriteRecordId,而历史列表里匹配同一部内容靠的是稳定的 contentId。跨页通知需要携带双方都能识别的业务实体 ID,并且查询刷新本身不要再次向外广播“写成功事件”,否则两个缓存 Tab 会互相触发刷新形成循环。

3. 使用 Completer 编写乱序回归测试

网络竞态在真机上靠手动点击很难稳定复现;在单元测试中通过 Completer 控制异步返回顺序,可以稳定重现上述交错场景:

test('在途慢查询晚于写操作返回时,不会把已成功的预约状态回退', () async {
  final controller = ReservationListController();
  final slowQuery = Completer<List<FeedItem>>();
  final fastWrite = Completer<void>();

  // t1: 发起慢查询 Q1(此时 snapshotRevision = 0)
  final refreshFuture = controller.refresh(() => slowQuery.future);

  // t2: 查询尚未返回时,用户预约 item-1 并率先成功(_writeRevision 推进为 1)
  final writeFuture = controller.toggleReserve(
    'item-1',
    targetReserved: true,
    submitWrite: (_, _) => fastWrite.future,
  );
  fastWrite.complete();
  await writeFuture;

  // t3: 慢查询 Q1 终于返回了写操作之前的服务端旧快照(reserved: false)
  slowQuery.complete(const [
    FeedItem(id: 'item-1', title: '示例条目', reserved: false),
  ]);
  await refreshFuture;

  // 验证:列表已加载,且 item-1 依然保持已预约状态,未被旧快照覆盖
  expect(controller.items.single.reserved, isTrue);
});

最后也要留心测试边界:Completer 单测验证的是客户端在各种返回交错顺序下的合并逻辑正确;如果在写操作成功几秒之后,手动重新下拉触发的全新查询依然返回旧状态,那问题就不在客户端时序上,而需要去核对服务端是否存在主从读写延迟、CDN 缓存或接口参数不一致了。