搜索框输入“Flutter”,接口开始请求。紧接着改成“Dart”,第二个请求也发出去了。
页面先显示 Dart 的结果,过了一会儿却又跳回 Flutter。日志里两个接口都成功,没有报错,代码看起来也只是正常地给列表赋值。
核心结论:请求的发出顺序,不等于返回顺序。只要多个异步任务都能修改同一份页面状态,就可能发生竞态。
问题是怎么出现的
最常见的代码通常长这样:
Future<void> search(String keyword) async {
final result = await searchRepository.search(keyword);
if (!mounted) return;
setState(() {
_items = result;
});
}每次输入都调用 search。问题不在 setState,而在于每个请求返回后都有资格修改 _items。
网络耗时会受到缓存、连接复用、服务端负载、数据量和重试等因素影响。先发出的请求完全可能后返回。
mounted 为什么解决不了
mounted 只回答一个问题:这个 State 还在组件树中吗?
上面的两个请求返回时,页面可能一直都在,所以两次 mounted 都是 true。它能避免页面销毁后调用 setState,却无法判断哪个结果更新。
换句话说:
mounted处理生命周期;- 请求标识处理异步顺序。
两者不能互相替代。
让最后一次搜索生效
对于搜索、筛选、切换 Tab 这类场景,用户通常只关心最后一次操作。可以给请求增加递增版本号:
int _searchVersion = 0;
Future<void> search(String keyword) async {
final requestVersion = ++_searchVersion;
setState(() {
_loading = true;
_errorMessage = null;
});
try {
final result = await searchRepository.search(keyword);
if (!mounted || requestVersion != _searchVersion) return;
setState(() {
_items = result;
});
} catch (error, stackTrace) {
logger.error('search failed', error, stackTrace);
if (!mounted || requestVersion != _searchVersion) return;
setState(() {
_errorMessage = '搜索失败,请稍后重试';
});
} finally {
if (mounted && requestVersion == _searchVersion) {
setState(() {
_loading = false;
});
}
}
}每次搜索都会生成更大的版本号。请求返回时,只有版本号仍然等于当前值,才有资格更新页面。
注意,数据、错误和加载状态都要做版本判断。否则旧请求虽然不能覆盖列表,却可能提前关闭新请求的 Loading,或者显示已经过期的错误。
清空搜索时也要让旧请求失效
用户点击“清空”后,之前的请求可能仍在执行。如果只清空列表,旧请求回来后还会把数据填回去。
void clearSearch() {
_searchVersion++;
setState(() {
_items = [];
_loading = false;
_errorMessage = null;
});
}递增版本号相当于宣布:此前所有搜索结果都已过期。
防抖和取消请求够不够
防抖能减少请求数量,但不能保证已经发出的请求按顺序返回。因此,防抖不是竞态保护。
部分网络库支持取消请求。取消可以节省网络、解析和服务端资源,值得使用,但仍建议保留版本判断:取消可能发生得太晚,请求也可能已经完成。最终是否允许结果更新页面,应该由页面自己的状态规则决定。
Dart 的普通 Future 没有通用的强制取消能力。是否能真正取消,要看网络库和底层请求是否提供相应机制。
不是所有请求都该“最后一次生效”
版本号方案适合“新操作替代旧操作”的读取场景,但不要到处照搬。
- 搜索、筛选、切换分类:通常只保留最后一次结果。
- 分页加载:每一页都可能需要,应按页码合并、去重并防止重复请求。
- 提交订单、修改资料:页面忽略旧响应,不代表服务端没有执行。需要服务端配合幂等键、数据版本或条件更新保证一致性。
客户端版本号保护的是“页面接受哪个结果”,不是服务端事务。
一套实用的排查方法
遇到数据突然回退,可以先记录:
- 请求编号;
- 查询参数;
- 发出时间与完成时间;
- 是否更新了数据、Loading 或错误状态。
如果日志只打印“接口成功”,很难看出覆盖关系。把同一次请求的编号带进完整链路,顺序问题通常会很直观。
最后记住这几点
- 请求先发不代表先回,成功响应也可能已经过期。
mounted防止生命周期错误,不解决请求竞态。- 版本判断要同时保护数据、Loading 和错误状态。
- 防抖减少请求,取消节省资源,版本号决定结果是否生效。
- 写请求的一致性必须由服务端协议共同保证。
问答复盘
Q1:两个接口都成功,为什么还会出现错误页面?
答: 成功只表示各自完成,不表示返回顺序符合用户操作顺序。后返回的旧请求仍可能覆盖新状态。
Q2:检查 mounted 后还需要请求版本号吗?
答: 需要。mounted 判断页面是否存活,版本号判断响应是否仍然有效。
Q3:加了防抖就不会发生竞态了吗?
答: 不一定。防抖只能减少请求;只要存在两个已发出的请求,返回顺序仍可能变化。
Q4:为什么旧请求不能在 finally 中关闭 Loading?
答: 因为新请求可能仍在执行。旧请求关闭 Loading,会让界面提前表现为加载完成。
Q5:请求版本号可以保证服务端数据一致吗?
答: 不可以。它只控制客户端接受哪个响应;写操作还需要服务端的幂等、版本校验或事务机制。
Q6:分页列表也应该只接受最后一个请求吗?
答: 通常不应该。分页需要按页码管理、合并和去重,不能简单丢弃先发但后返回的有效页面。