异步 UI:加载、错误与空状态
新手写异步页面最常见的毛病是只写了成功分支:loading 转圈、error 空白、empty 什么都不显示,用户看到的就是一个永远转不完的圈或者一片白屏。本章把异步四态(loading / error / empty / data)的写法固化下来,讲清 FutureBuilder 的正确用法、StreamBuilder 的适用场景、Riverpod 的 AsyncValue.when,以及错误分类与重试交互。
异步四态:一个都不能少
| 状态 | 用户看到 | 不处理会怎样 |
|---|---|---|
| loading | 骨架屏或进度指示 | 白屏,用户以为卡死 |
| error | 明确原因 + 重试按钮 | 无限转圈 |
| empty | 空状态图标 + 引导操作 | 空白页,像 Bug |
| data | 正常内容 | —— |
一条硬规则:任何一个异步页面,都要能回答“网络断了会看到什么”。
FutureBuilder:future 不能写在 build 里
最大的坑是把 future 写在 build 里——每次重建都会重新发一次请求,形成“请求 → 重建 → 请求”的死循环。
class _ArticlePageState extends State<ArticlePage> {
// future 存 State;判断顺序必须是 error → waiting → empty → data
late Future<List<String>> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<List<String>> _load() async => ['Flutter 布局', 'Dart 空安全']; // 换成 dio 请求
void _retry() => setState(() => _future = _load()); // 重试 = 换新 future
@override
Widget build(BuildContext context) => FutureBuilder<List<String>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) return ErrorView(message: '加载失败', onRetry: _retry);
if (snapshot.connectionState == ConnectionState.waiting) {
return const SkeletonList(); // 骨架屏优于转圈
}
final data = snapshot.data ?? const [];
if (data.isEmpty) return const EmptyView(text: '还没有内容');
return ListView.builder(
itemCount: data.length,
itemBuilder: (context, i) => ListTile(title: Text(data[i])),
);
},
);
}
ConnectionState | 含义 | 界面表现 |
|---|---|---|
none | future 为 null | 通常不该出现,检查是否忘了赋值 |
waiting | 进行中 | 骨架屏 / 进度条 |
active | 流有数据但未结束 | 仅 StreamBuilder 常见 |
done | 已完成 | 读 snapshot.data / hasError |
配套三个小组件:ErrorView(图标 + 文案 + 重试按钮)、EmptyView(图标 + 引导文案)、SkeletonList(灰色占位块模拟内容形状,比转圈更少焦虑)。
StreamBuilder:持续变化的数据
适合数据库监听、WebSocket 消息、定位等会多次推送的数据;流本身必须存在 State 里或由 Provider 托管。
StreamBuilder<int>(
stream: _countStream, // 写在 build 里会导致每次重建都新建一条流
builder: (context, snapshot) {
if (snapshot.hasError) return Text('异常:${snapshot.error}');
if (!snapshot.hasData) return const CircularProgressIndicator();
return Text('新消息 ${snapshot.data} 条');
},
)
AsyncValue.when:Riverpod 的写法
AsyncValue 把四态建模进类型,when 必须写全分支,漏写编译不过。
final articlesProvider = FutureProvider<List<String>>((ref) async => ['Flutter 布局']);
class ArticleListPage extends ConsumerWidget {
const ArticleListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(articlesProvider);
return async.when(
// 刷新时保留旧数据:isRefreshing 为 true 仍能拿到 value,避免列表闪白
loading: () => async.isRefreshing ? _list(async.value ?? const []) : const SkeletonList(),
error: (e, _) => ErrorView(
message: describeError(e),
onRetry: () => ref.invalidate(articlesProvider), // 重试 = 让 Provider 重新执行
),
data: (list) => list.isEmpty ? const EmptyView(text: '暂无文章') : _list(list),
);
}
}
错误分类与用户可读文案
| 错误类型 | 判断依据 | 文案 | 是否可重试 |
|---|---|---|---|
| 无网络 | DioExceptionType.connectionError | 网络不可用,请检查连接 | 是 |
| 超时 | connectionTimeout/receiveTimeout | 网络较慢,请稍后重试 | 是 |
| 服务端错误 | 状态码 ≥ 500 | 服务暂时不可用 | 是(可加退避) |
| 鉴权失败 | 状态码 401 | 登录已过期,请重新登录 | 跳登录页 |
| 参数/业务错误 | 4xx 或 code != 0 | 用后端返回的 message | 否 |
| 解析失败 | FormatException | 数据异常,请稍后重试 | 是(但要上报) |
String describeError(Object e) {
if (e is DioException) {
final code = e.response?.statusCode ?? 0;
if (e.type == DioExceptionType.connectionError) return '网络不可用,请检查连接';
if (e.type == DioExceptionType.cancel) return ''; // 主动取消不提示
if (code == 401) return '登录已过期,请重新登录';
if (code >= 500) return '服务暂时不可用,请稍后重试';
return '请求失败,请稍后重试';
}
if (e is FormatException) return '数据异常,请稍后重试';
return '出错了,请稍后重试';
}
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
future/stream 写在 build 里 | 每次重建都重新请求,页面抖动 | 存到 State 或 Provider |
只看 hasData 判断加载中 | 首次加载直接显示空态 | 用 connectionState == waiting |
| 刷新前清空列表 | 列表闪一下白 | 保留旧数据,用 isRefreshing 标记 |
| 有超时没有兜底 | 无限转圈 | 设 receiveTimeout,超时落到 error 分支 |
| 把原始异常展示给用户 | 用户看到 SocketException | describeError 统一转可读文案 |
| 空态与无结果文案混用 | 用户误解为没有内容 | 分别给“暂无内容”和“没有找到相关内容” |
小结:异步页面必须写全 loading、error、empty、data 四态;FutureBuilder 的 future 要放在 State 或 Provider 里而不是 build 里,判断顺序是 error → waiting → empty → data;StreamBuilder 的流同样不能在 build 里新建;Riverpod 用 AsyncValue.when 强制覆盖全部分支并配 ref.invalidate 重试;错误按网络、超时、服务端、业务、解析分类给用户可读文案,刷新时保留旧数据,任何转圈都要有超时出口。