异步 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含义界面表现
nonefuture 为 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 分支
把原始异常展示给用户用户看到 SocketExceptiondescribeError 统一转可读文案
空态与无结果文案混用用户误解为没有内容分别给“暂无内容”和“没有找到相关内容”

小结:异步页面必须写全 loading、error、empty、data 四态;FutureBuilderfuture 要放在 State 或 Provider 里而不是 build 里,判断顺序是 error → waiting → empty → data;StreamBuilder 的流同样不能在 build 里新建;Riverpod 用 AsyncValue.when 强制覆盖全部分支并配 ref.invalidate 重试;错误按网络、超时、服务端、业务、解析分类给用户可读文案,刷新时保留旧数据,任何转圈都要有超时出口。

笔记加载中…