很多 Flutter 页面从三个变量开始:bool loadingT? dataObject? 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 analyzeflutter 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 在 initStatedidUpdateWidgetdidChangeDependencies 等更早阶段取得,不能在 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