很多 Flutter 页面从三个变量开始:bool loading、T? data、Object? error。它们很快就能表达首次加载,却无法回答刷新时是否保留旧数据、第二次请求先返回怎么办、错误是页面级还是局部、SnackBar 是否已经消费、重试是否允许重复点击。
本文面向正在处理网络、数据库或插件异步调用的中高级开发者。前置知识是 Future、sealed class、ChangeNotifier/Listenable 与 Widget 生命周期。
版本与验证范围
截至 2026-07-16,Flutter SDK 归档列出的稳定版为 Flutter 3.44.6 / Dart 3.12.2,Beta 为 Flutter 3.47.0-0.1.pre / Dart 3.13 beta;本机基线为 3.41.9 / 3.11.5。本文只用两套稳定版共有 API,不涉及 Beta。两段代码均已进入 examples/flutter/lib/async_load_state.dart,通过 flutter analyze 与 flutter test;Repository、插件取消和 Widget 恢复仍是 source-reviewed。
视觉资产记录:封面(1600 × 900)与文中状态图(1200 × 680)均为 WEB/SUN 于 2026-07-16 创作的程序化 SVG;来源/许可为本项目原创自有资产,未使用第三方图片。
状态是“重建 UI 所需的数据”
Flutter 状态指南给出一个实用定义:状态是任何时刻重建 UI 所需的数据。异步页面因此不只有请求是否运行,还包括当前可显示的数据、数据是否过期、上一次失败、当前操作、请求身份和用户是否已经看到某个副作用。
把这些维度直接编码为互斥状态,可以消除 loading=true && error!=null && data=null 到底什么意思的争论:
sealed class LoadState<T> {
const LoadState();
}
final class Idle<T> extends LoadState<T> { const Idle(); }
final class Loading<T> extends LoadState<T> { const Loading({this.previous}); final T? previous; }
final class Ready<T> extends LoadState<T> { const Ready(this.value, {this.refreshing = false}); final T value; final bool refreshing; }
final class Failed<T> extends LoadState<T> { const Failed(this.failure, {this.previous}); final Failure failure; final T? previous; }
LoadState 块已由上述门禁执行。重点是保留 previous:首次加载失败可以显示完整恢复页;已有数据刷新失败则继续显示内容,在局部提示“更新失败”和重试。把旧数据清空再转圈通常会制造不必要的界面闪烁。
图 1(1200 × 680):底层异常经 Result 与 Command 转为 UI 可消费的首次加载、刷新、数据、失败和重试状态。原创程序化 SVG,WEB/SUN,2026-07-16。
FutureBuilder 是快照适配器,不是请求仓库
FutureBuilder API要求 Future 在 initState、didUpdateWidget 或 didChangeDependencies 等更早阶段取得,不能在 build 中新建。build 可能被调用很多次;若每次构造新的 Future,父级重建会重启请求。
FutureBuilder 的 builder 接收的是时间相关快照子序列,不保证你观察到每个中间状态。即使换入一个已经完成的新 Future,也可能先出现一帧 ConnectionState.waiting,因为框架无法同步判断 Future 已完成。AsyncSnapshot 在切换 Future 时还可能暂时保留旧 data。依赖“builder 一定按 none→waiting→done 各跑一次”的业务逻辑不可靠。
FutureBuilder 适合局部、一次性、与 Widget 生命周期一致的异步值,例如读取单个资源。需要刷新、乐观更新、分页、去重或跨页面缓存时,应把 Future 和状态提升到 ViewModel/Controller/Repository,让 Widget 只渲染状态。builder 必须保持无副作用;导航、SnackBar 与日志不能因为一次重建重复触发。
错误在数据边界被分类
官方 Result 模式用 Result<T> 的 Ok/Error 分支显式表达成功与失败,避免未声明异常跨过多层后才被遗漏。Service 可以抛底层 Socket、HTTP 或解析异常;Repository 应把它们映射为业务可理解 Failure,例如 Offline、Unauthorized、NotFound、InvalidPayload、Unknown。
UI 不应直接显示 exception.toString()。它可能泄露内部地址,也无法告诉用户下一步。Failure 应携带可恢复性、稳定错误码和必要上下文;文案在表现层本地化。Unauthorized 可能跳转重新认证,Offline 可以重试,InvalidPayload 更适合保留安全兜底并上报。
Result 也不是要求所有函数永不 throw。编程错误、违反不变量和不可恢复故障仍应快速暴露;预期的 I/O 失败与业务拒绝则适合显式返回。边界清楚比“全部 catch 后返回 null”更重要。
Command 管理动作,而不是吞掉状态
Flutter Command 模式指南把一次用户动作包装为 running、completed、error 等状态,可阻止按钮连点重复执行。加载、保存、删除应各有独立 Command,不能共享一个全局 loading,否则保存头像会让整页文章列表也进入加载。
Command 的 execute 流程需要 try/finally:开始时清旧错误、标 running;成功时发布结果;失败时转换 Failure;无论如何结束 running。视图监听 Command 只重建相关区域。一次性 UI 动作则需要消费机制,例如带递增 id 的 event,视图处理后 acknowledge;仅监听 error != null 会在下一次 notifyListeners 时再次弹 SnackBar。
| 数据与动作状态 | 合适 UI |
|---|---|
| 无数据 + 首次运行 | 骨架、任务说明、可取消返回 |
| 有数据 + 刷新运行 | 保留内容,局部刷新指示 |
| 无数据 + 可恢复失败 | 错误说明、重试、替代入口 |
| 有数据 + 刷新失败 | 内容仍可读,内联错误与重试 |
| 提交中 | 控件局部禁用,保留输入 |
| 提交失败 | 字段或操作附近说明,避免清空草稿 |
竞态:Future 完成顺序不等于用户意图
搜索 “fl” 后又输入 “flutter”,前一个慢请求可能最后返回。普通 Future 没有通用取消能力;即使不再 await,底层工作可能继续。最小防线是给每次请求递增 token,只允许最新 token 提交状态。若 HTTP 客户端或插件支持真实取消,再同时释放网络与计算资源。
int _generation = 0;
Future<void> search(String query) async {
final generation = ++_generation;
state = Loading(previous: state.valueOrNull);
notifyListeners();
final result = await repository.search(query);
if (generation != _generation) return;
state = switch (result) {
Ok(value: final value) => Ready(value),
Error(error: final error) => Failed(mapFailure(error), previous: state.valueOrNull),
};
notifyListeners();
}
search 块也已执行;valueOrNull 与 Result pattern 由 canonical 源定义。token 只避免旧结果覆盖新状态,并不会取消底层请求。页面 dispose 后也要防止通知已释放监听者;Controller 应拥有明确 dispose,并让 Repository/Client 的取消能力贯穿下来。
重复重试需要幂等语义。GET 通常可重新读取,创建订单或支付不能仅靠禁用按钮;请求应使用业务幂等键或服务端去重。客户端 loading 状态不是网络安全边界。
全局错误处理不是全局恢复 UI
Flutter 错误处理文档区分两条路径:框架回调中的 build/layout/paint 错误进入 FlutterError.onError;不在 Flutter 回调栈内的未处理异步错误可由 PlatformDispatcher.instance.onError 接收。自定义 handler 应保留 FlutterError.presentError 等诊断,再交给合规的错误记录系统。
这些 handler 是最后防线,不知道当前业务是否能重试,不能替代 Repository 的 Failure 映射。ErrorWidget.builder 也只适合渲染失败后的安全占位,不能把所有 API 错误都变成灰屏。可恢复错误应在离失败最近、又拥有产品上下文的层处理。
全局日志要去除令牌、个人数据和请求正文,稳定采样并关联版本;不要因为捕获了错误就返回 true 后静默吞掉所有故障。开发环境仍需让堆栈可见。
恢复流程也要测试
Repository 测试覆盖异常到 Failure 的映射;Controller 测试覆盖首次成功、首次失败、保留数据刷新、旧请求晚到、重试成功、dispose;Widget test 用可控 Future 或 fake repository 逐帧断言骨架、内容、内联错误和按钮状态。
不要所有场景都 pumpAndSettle。持续动画、重试定时器或永不结束的 Stream 会让它超时,也会掩盖中间态。用 pump() 启动,用指定 Duration 到关键边界,最后断言状态。一次性副作用要触发两次无关 notify,确认不会重复导航或弹窗。
故障注入要包括离线、超时、无权限、坏数据、空结果和取消,不只测试 Exception。空结果是成功状态,不应显示“网络错误”;超时是否自动重试取决于动作幂等与产品政策。
失败模式
- 在 build 中创建 Future:父级重建会重复请求。
- 用
loading覆盖整个页面:刷新时丢失可用数据。 - catch 后返回 null:空值、失败和无结果无法区分。
- 旧请求覆盖新查询:缺少 token 或取消协议。
- 直接展示 Exception:泄露实现细节,缺少恢复建议。
- 用全局 handler 代替局部 Failure:无法给出正确重试动作。
- 每次 notify 都显示 SnackBar:一次性事件没有消费标识。
结论
异步 UI 的关键不是“加一个加载圈”,而是把数据、运行阶段、失败、请求身份与恢复动作变成明确状态。FutureBuilder 只适配简单 Future;Result 在数据边界显式传递预期失败,Command 管理具体动作,token 或取消协议保护最新用户意图。
当刷新失败仍能阅读旧数据、重复点击不会重复提交、旧响应不会覆盖新查询、错误旁边有正确恢复入口,这个页面才算完成了异步设计。
Sources
- Flutter — Flutter SDK archive,访问于 2026-07-16。
- Flutter API — FutureBuilder class,访问于 2026-07-16。
- Flutter — Differentiate between ephemeral state and app state,访问于 2026-07-16。
- Flutter — Error handling with Result objects,访问于 2026-07-16。
- Flutter — The command pattern,访问于 2026-07-16。
- Flutter — Handling errors in Flutter,访问于 2026-07-16。