缓存 Tab 的状态同步:如何避免旧请求覆盖新操作
结合多 Tab 页面保活与列表刷新场景,分析双向通知遗漏、异步回调修改旧对象与慢查询回退新状态的成因及协调方法。
目录
客户端应用里只要同时存在多个缓存保活的 Tab 页面(例如 Flutter 的 IndexedStack / AutomaticKeepAliveClientMixin,或 Jetpack Compose 的 HorizontalPager),并且同一份业务实体(比如某条内容的「收藏」「预约」或「订阅」状态)可以在不同页面被修改,就容易遇到两类状态不同步问题:
- 单向同步遗漏:在「历史记录」Tab 点亮收藏图标后,「我的收藏」Tab 跟着刷新了;但反过来在「我的收藏」里删掉这条记录再切回「历史记录」,那颗星标却还亮着——因为两个缓存 Tab 切换时并不会重新触发首次挂载的初始化查询(比如 Compose 的
LaunchedEffect(Unit)/DisposableEffect(Unit)或 Flutter 的initState)。 - 更隐蔽的请求交错回退:明明已经补齐了跨页通知,但用户连续操作时,偶尔会出现“按钮有点击水波纹动画,状态却没变”,或者“图标刚变亮半秒钟,又自己跳回了未选中”。而且在 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,
];
}
}
这段控制器代码主要处理了四个容易出错的环节:
- 同时保留
_queryGeneration与_writeRevision的原因: 假设页面只发了一次查询Q1,没有发Q2,那么Q1返回时它依然是“最新发出的那次查询”(myGen == _queryGeneration);但在Q1尚未返回的窗口期内,用户点击成功了一次预约。没有_writeRevision版本号比对,Q1作为最新查询仍会覆盖这次已成功的操作。 - 本地覆盖记录的清理时机:
本地覆盖记录只负责保护“与写请求时间重叠的那批在途旧查询”。等到下一轮新查询(
snapshotRevision >= mutation.revision)带回了服务端权威结果且状态一致时,就应当及时移除本地覆盖,避免因为保留局部覆盖记录而忽略服务端的后续真实变更。 - 请求锁按条目
itemId粒度加,而不是全局bool isSaving:全局共用一把锁时,条目 A 的请求还没回来,用户去点条目 B 就会出现“按钮有按压波纹、实际回调直接提前return未发请求”的现象。 - 区分业务内容 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 缓存或接口参数不一致了。