资讯动态

Flutter异步加载UI:统一状态模型与AsyncLoader壳组件

发布时间:2026/10/27 13:43:05 来源:尧图企业网站定制
1. 异步加载UI到底难在哪里凌晨改完最后一个页面我把 Flutter 项目里二十多处散落的FutureBuilder全删了统一换成一个自建的异步加载UI组件。起因特别朴素测试同学提了一个 bug——列表页在弱网下先白屏三秒然后菊花和内容一起闪一下才出来下拉刷新的时候骨架屏还会整个塌掉重排。同样的现象在首页、详情页、消息页、个人中心都能复现只是出现频率不一样。我一开始打算一个页面一个页面改改到第四个页面就放弃了。因为问题根本不是某个页面写错了而是异步加载这件事在每个页面里都被重新发明了一遍有人用FutureBuilder有人在initState里setState有人套了一层BlocBuilder还有人干脆在build里直接发请求。状态定义不统一占位样式不统一错误处理不统一后面想加缓存、加去重、加重试就只能在每个页面里再复制一遍。所以这次的思路是把异步加载当成项目基建的一层来做定义一套统一的异步状态模型写一个能复用的加载壳组件再往下挂并发去重、内存缓存、重试退避、分页和 Isolate 这些能力。这篇就是这次改造的完整复盘包含状态模型怎么定、壳组件怎么写、参数为什么这么配以及我踩过的那些坑——尤其是骨架屏、setState after dispose、旧请求覆盖新数据这几类问题几乎每个 Flutter 项目都会撞上。适合谁看写过几个 Flutter 页面、被 loading 和 error 状态反复折磨过的人看最合适可以直接抄走这套结构刚入门的同学也能看懂因为我会把AsyncSnapshot、ConnectionState、Isolate.run这些概念用生活化的方式先讲一遍再上代码。全文不涉及任何特定的业务后台都是通用做法。1.1 一次弱网首屏的问题回放那天的复现路径是这样的把模拟器网络调成高延迟低带宽冷启动进列表页屏幕先是一片白大约 3 秒后一个居中的CircularProgressIndicator出现再过 1 秒内容出现同时菊花消失。整个过程里白屏和菊花之间是一个跳变没有过渡。这段跳变在代码里的真实原因很典型页面build返回的是一个FutureBuilderfuture字段写的是fetchOrders()——也就意味着只要这个FutureBuilder的父级重建一次就会重新发一次请求。而首页的父级重建频率高得吓人键盘弹出会改MediaQuery主题变化会改Theme横竖屏切换会改尺寸随便一个setState都会。于是我看到的并不是一次请求三秒而是请求被打断、重来、再打断、再重来直到某个时刻终于没人打扰它才把结果渲染出来。白屏则是另一个原因FutureBuilder在ConnectionState.waiting时返回的是SizedBox.shrink()相当于什么也不画。屏幕背景是白的所以看起来就是白屏。而菊花和内容一起闪一下是FutureBuilder在done那一帧直接把整个子树换成了列表布局从零开始算视觉上就是内容怼上来。这三个现象串起来看其实是一个结论异步加载的问题从来不是怎么显示 loading而是整个异步生命周期的状态没有被统一管理。请求什么时候发、发几次、结果什么时候算数、失败之后能不能重试、重试的时候旧内容要不要留着这些都得有个说法否则每个页面都会用自己的一套临时方案去凑。1.2 判断一套加载方案是否合格的三条标准我后来给自己定了个简单的验收标准凡是做异步加载UI的方案都要过这三关。第一条状态必须可枚举、可断言。一个页面在任意一帧只可能处于这几种状态之一还没开始、正在加载、加载成功有数据、加载成功但数据为空、加载失败。这五种状态互斥且完备不能出现既在加载又有数据还有错误这种叠加态。听起来很废话但AsyncSnapshot里hasData和hasError是可能同时为真的这就是叠加态。第二条请求必须可取消、结果必须可丢弃。用户点进详情页又立刻返回那个还在飞的请求不该在返回后把setState打到一个已经销毁的State上用户快速切换 tabA tab 的慢请求不能在 B tab 的数据落地之后才回来把 UI 覆盖掉。这两件事本质上都是结果过期需要一个令牌机制来判断这个结果还算不算数。第三条加载态、空态、错误态必须能组合进同一个壳里。换句话说页面代码只关心数据来了渲染什么骨架屏长什么样、错误页怎么写、空态怎么排版这些应该由壳统一提供页面想定制就覆盖不想定制就用默认的。这条决定了这套东西能不能在团队里铺开——如果一个页面接入要写三四十行样板代码那没人会愿意用。把这三条标准落到实际结构上就是后面几节要讲的东西一个AsyncStateT数据模型一个AsyncControllerT状态机一个AsyncLoaderT壳组件。2. 方案选型FutureBuilder、状态管理还是自建壳方案选型这块我纠结了两天。项目里已有的东西挺杂一部分页面用FutureBuilder一部分用StreamBuilder一部分接了状态管理库还有几个页面是initState里裸写async函数配setState。我先做了什么都没有直接改而是把现有方案的优缺点列了一遍再决定这次是统一到某个现成方案还是自己写一层。我评估的维度是五个能不能防重复请求、能不能保留旧数据、错误重试好不好做、团队接入成本、以及能不能自然支持分页和缓存。这五个维度基本覆盖了一个中大型 Flutter 项目对异步加载的全部期待如果某个方案在其中三项上都吃力那它就不适合充当基建。2.1 四类方案的能力对照先把对照表列出来这张表我当时贴在群里给组里同学看讨论效率比她一个个解释高很多。维度FutureBuilderStreamBuilder状态管理库AsyncValue 类自建 AsyncState 壳组件防重复请求差future写在build里必崩中取决于 Stream 来源好好令牌丢弃过期结果保留旧数据刷新差重新订阅会清空中好有copyWithPrevious类似能力好isRefreshing标记错误重试需手写且没有退避需重建 Stream好好内建退避与手动重试接入成本极低低高团队要学状态管理范式低一个组件包一层分页与缓存需自己拼不自然中好控制器层统一处理FutureBuilder的问题不在于它差而在于它把订阅一个 Future这件事暴露得太原生态了。它只负责把 Future 的三个状态翻译成三份 Widget剩下的事一概不管你回调里怎么判重、怎么取消、怎么重试它都不参与。小页面用它很舒服一旦要做骨架屏、要保留刷新前的数据、要在错误页上放重试按钮立刻就要在外面套一层StatefulWidget自己管那还不如一开始就把这层管起来。StreamBuilder我用在消息流和长连接推送的场景上那类场景天然就是流。但用它承载一次 HTTP 请求属于杀鸡用牛刀你得手动造一个StreamController把 Future 包成 Stream多出来的复杂度纯粹是负担。状态管理库的方案其实是最省事的AsyncValue那套已经把这五种状态封装得很到位了when、maybeWhen、requireValue用起来很顺刷新时还能把上一次的数据带过来避免 UI 塌陷。2.2 为什么最后选了自建一层不选状态管理库不是因为不好是因为我们组的现状项目里已经有三套东西并存再加一套新的范式等于让所有人重新学一遍数据流。而且对于异步加载这块真正需要的能力其实很窄——发请求、管状态、渲染占位、丢弃过期结果这四件事用一百多行代码就能封完没必要引入一整套状态管理的心智负担。具体做法是分三层职责切得很干净状态层一个不可变的AsyncStateT只描述现在是什么状态、数据是什么、错误是什么控制层一个AsyncControllerT继承ChangeNotifier负责发请求、判令牌、切状态跟 UI 完全解耦视图层一个AsyncLoaderT组件监听控制器按状态渲染骨架屏、错误页、空态或真实内容。这么切的好处是控制层可以被单元测试直接覆盖——不需要跑 Widget 测试直接对一个 mock 的Future调run()断言状态序列是不是loading → success或者快速调两次run()断言第一个结果被丢弃。我们后来加缓存、加重试全都在控制层加视图层一行没动。分层还有一个隐性收益AsyncController可以被多个页面共享。比如首页和搜索结果页共用同一份分类数据那就把同一个控制器传给两个AsyncLoader任何一处触发刷新两处 UI 同时更新。这种共享在FutureBuilder里是想都不要想的。2.3 状态模型长什么样状态模型我刻意做得笨一点能少一个字段就少一个字段。枚举只给五个值多一个idle是为了区分还没发请求和正在发请求——这两个状态在 UI 上的表现完全不同前者应该是透明的占位区域后者才应该是骨架屏。enum AsyncStatus { idle, loading, success, empty, failure } immutable class AsyncStateT { const AsyncState._({ required this.status, this.data, this.error, this.isRefreshing false, }); const AsyncState.idle() : this._(status: AsyncStatus.idle); const AsyncState.loading() : this._(status: AsyncStatus.loading); const AsyncState.success(T data, {required bool empty}) : this._( status: empty ? AsyncStatus.empty : AsyncStatus.success, data: data, ); const AsyncState.failure(Object error) : this._(status: AsyncStatus.failure, error: error); final AsyncStatus status; final T? data; final Object? error; final bool isRefreshing; AsyncStateT refreshing() AsyncState._( status: status, data: data, error: error, isRefreshing: true, ); }empty单独做成一个状态而不是判断data is List data.isEmpty理由是空态的 UI 通常是完全独立的一屏有插图、有引导文案、有去逛逛按钮跟有数据但渲染出来是空的是两回事。把空态判断交给页面自己传一个isEmpty回调控制器只负责把结果翻译成empty状态这样视图层就不需要写任何if (list.isEmpty)。isRefreshing这个字段解决的是另一个痛点下拉刷新的时候如果状态从success直接切回loading页面内容会瞬间被骨架屏顶掉用户手指还在屏幕上内容就没了体验非常糟糕。所以刷新时状态保持success不变只是把isRefreshing置真视图层完全忽略这个标记也不影响正确性RefreshIndicator自己的转圈已经足够表达正在刷新。3. 核心细节快照、令牌与生命周期这一节讲三个最容易翻车的地方。这三个点不涉及什么高深算法但只要有一个没处理对线上就会出现偶发白屏偶发数据错乱偶发崩溃这类难以复现的问题。我调试这些问题花的时间比写整个组件的时间还长。3.1 AsyncSnapshot 的四种连接状态与它的陷阱先把概念理清楚。FutureBuilder会给你一个AsyncSnapshot里面有个ConnectionState一共四个值none还没订阅任何未来通常是future传了nullwaiting已经订阅正在等active只在StreamBuilder里有意义表示流已经开始推数据但还没结束done结束了要么成功要么失败。坑在哪坑在waiting这个状态和done之间是跳变的FutureBuilder不提供任何中间状态所以你没法在waiting阶段展示已完成 60%也没法知道这次刷新和前一次刷新是不是同一批数据。对于需要渐进式渲染的场景——比如先渲染列表骨架数据回来一页渲染一页——FutureBuilder是做不到的。更隐蔽的坑是hasError和hasData可能同时为真。AsyncSnapshot内部维护了data和error两个字段某些情况下尤其是流式场景两者都会保留上一次的值。如果你在 build 里写的是if (snapshot.hasError) return ErrorView();后面紧跟if (snapshot.hasData) return ListView();逻辑上是对的但你要是写反了顺序或者在hasError分支里也读了snapshot.data就会出现错误页上还挂着上次的数据这种怪事。ConnectionState.none更容易被忽略。当你把future从null改成非空的时候FutureBuilder会先给一帧none。如果这一帧你返回的是null很多人的写法是if (!snapshot.hasData) return null;整个子树就不占空间父级布局会跳一下。我在一个带GridView的页面里被这个问题搞过跳一下导致滚动位置重置用户正在看的卡片瞬间跑到屏幕外。结论很直白FutureBuilder适合一次性、简单、不需要干预的展示场景一旦涉及骨架屏、渐进渲染、错误重试、刷新保留旧数据它就不够用了得往上抽一层。3.2 令牌机制让过期结果自己作废取消请求这件事在 Flutter 里其实有两层含义很多人混在一起讲。第一层是真正中止底层连接比如 HTTP 客户端层面的 abort省流量、省连接。第二层是结果作废请求照样发完但结果回来的时候看一眼这个令牌还是不是当前的不是就丢掉。绝大多数业务场景需要的是第二层因为一次请求的资源开销远小于维护一套取消传播链的复杂度。我采用的是第二层实现就是一个自增整数int _token 0; Futurevoid run(FutureT Function() task, {bool refresh false}) async { final token _token; // 每次进入方法就抢一个新号 _emit(refresh _state.data ! null ? _state.refreshing() : AsyncStateT.loading()); try { final value await task(); if (token ! _token) return; // 结果过期直接丢弃 _emit(AsyncState.success(value, empty: isEmpty?.call(value) ?? false)); } catch (error) { if (token ! _token) return; // 出错了也一样要丢弃 _emit(AsyncState.failure(error)); } }四个关键点。进方法先抢号。_token保证每次调用都拿到一个比之前大的号这个号在本次调用全程不变。结果回来先对号。token ! _token为真说明在等待期间又有新的run()进来了当前这次的结果已经没人在乎了直接return不碰状态、不通知 UI。这一步是解决快速切 tab 数据错乱的核心。错误路径也要对号。我见过不少实现只在成功分支做了校验失败分支直接把错误打上去结果就是A 请求超时了、B 请求成功了但页面显示的是 A 的超时报错。这个细节看着小排查起来能耗掉半天。销毁时把号推到头。控制器dispose的时候把_token所有在途请求回来的时候对号必然失败天然就失效了。再配合_disposed标记双保险。想做得更彻底可以引CancelableOperation把底层 Future 也掐掉但那会让每个调用点都包一层收益并不明显。我个人经验是页面级的请求作废就足够了只有上传下载这种长连接、带进度、占带宽的场景才值得做真正的取消。3.3 mounted 与 dispose崩溃的真正来源setState() called after dispose()这个报错几乎每个写 Flutter 的人都见过。它的触发路径很固定async函数里await之后代码继续执行而这个时候State已经因为页面销毁而dispose了setState一执行就炸。为什么await之前没事因为await之前代码还是在同步执行的页面还没机会销毁。await一挂起事件循环就去处理别的事了用户点返回、页面出栈、dispose被调用然后网络请求回来恢复执行报错。标准解法是在每个await之后检查mountedfinal data await fetch(); if (!mounted) return; setState(() _data data);对于ChangeNotifier没有mounted可以用得自己维护一个标记bool _disposed false; void _emit(AsyncStateT next) { if (_disposed) return; _state next; notifyListeners(); } override void dispose() { _disposed true; _token; // 顺手让在途请求全部作废 super.dispose(); }这里有个很容易写错的细节dispose里不要await任何东西。有的人想在dispose里等请求结束再释放资源这是做不到的dispose是同步方法返回后资源就已经该释放了。正确做法是不等待直接标记作废让请求自己跑完自己丢掉。还有一个隐蔽场景AnimatedBuilder和ListenableBuilder监听的对象被dispose之后如果还有帧在排队重建会抛 A ChangeNotifier was used after being disposed。解决办法是在组件的dispose里先removeListener再dispose控制器顺序不能反。这个顺序我在代码评审里纠正过三次。4. 实操手写一个可复用的 AsyncLoader这一节上完整代码。我尽量让代码可以直接复制到自己项目里跑依赖只有 Flutter SDK不引任何第三方包。整体大概一百八十行包含状态、控制器、壳组件和三个默认占位视图。4.1 控制器实现与参数说明控制器的完整实现如下比上一节多了几个成员都是一路踩坑加上的。import package:flutter/foundation.dart; class AsyncControllerT extends ChangeNotifier { AsyncController({this.isEmpty}); /// 判断成功但没有内容交给业务定义比如 (list) list.isEmpty final bool Function(T value)? isEmpty; AsyncStateT _state AsyncStateT.idle(); AsyncStateT get state _state; int _token 0; bool _disposed false; bool get isBusy _state.status AsyncStatus.loading; Futurevoid run(FutureT Function() task, {bool refresh false}) async { final token _token; _emit(refresh _state.data ! null ? _state.refreshing() : AsyncStateT.loading()); try { final value await task(); if (token ! _token) return; _emit(AsyncState.success(value, empty: isEmpty?.call(value) ?? false)); } catch (error) { if (token ! _token) return; _emit(AsyncState.failure(error)); } } /// 静默重试不切 loading 态避免错误页闪成骨架屏 Futurevoid retry(FutureT Function() task) run(task); void reset() { _token; _emit(AsyncStateT.idle()); } void _emit(AsyncStateT next) { if (_disposed) return; _state next; notifyListeners(); } override void dispose() { _disposed true; _token; super.dispose(); } }几个设计决定解释一下。run用FutureT Function()接收任务而不是直接接收FutureT原因和FutureBuilder那个经典坑同源如果传的是已经创建好的 Futurerun被调两次的时候第二次拿到的还是同一个 Future 对象等于重试毫无意义。传闭包每次调用都重新执行一次任务行为符合直觉。isEmpty放在构造函数里而不是run的参数里因为它是这个控制器的数据特征一个控制器对应一种数据源判断逻辑是固定的。放构造函数里接入的时候只用写一次。isBusy这个 getter 是给外部按钮用的。比如提交订单按钮需要在请求中禁用那就在ListenableBuilder里读isBusy决定onPressed是不是null。比在外面自己维护一个_submitting布尔值靠谱得多。reset用得少但必须有。比如用户退出登录所有缓存数据都要清空回到初始态这时候调reset而不是发一次空请求。4.2 壳组件的完整实现壳组件负责三件事监听控制器、按状态选占位视图、处理和外部控制器的生命周期关系。代码不算短但每一段都有明确目的。import dart:async; import package:flutter/material.dart; class AsyncLoaderT extends StatefulWidget { const AsyncLoader({ super.key, required this.task, required this.builder, this.controller, this.isEmpty, this.skeleton, this.emptyBuilder, this.errorBuilder, this.loadingDelay const Duration(milliseconds: 120), this.minLoadingDuration const Duration(milliseconds: 280), this.loadOnMount true, }); final FutureT Function() task; final Widget Function(BuildContext context, T data) builder; final AsyncControllerT? controller; final bool Function(T value)? isEmpty; final Widget? skeleton; final WidgetBuilder? emptyBuilder; final Widget Function(BuildContext context, Object error, VoidCallback retry)? errorBuilder; /// 请求快于这个时长时压根不显示骨架屏避免闪一下 final Duration loadingDelay; /// 骨架屏一旦出现至少驻留这么久避免闪退 final Duration minLoadingDuration; final bool loadOnMount; override StateAsyncLoaderT createState() _AsyncLoaderStateT(); }两个时间参数是我最想强调的部分很多人写加载壳的时候只想到什么时候显示没想到显示多久。loadingDelay解决的是闪一下问题。本地缓存命中、Mock 数据、局域网接口这些场景下请求可能 30 毫秒就回来了如果立刻显示骨架屏用户看到的是骨架闪了一下就没了比不看骨架还难受。设一个 120 毫秒的延迟快于这个时长的请求直接跳过骨架屏从空白平滑过渡到内容。minLoadingDuration解决的是反向问题。假设请求花了 150 毫秒骨架屏刚画出来120 毫秒时显示30 毫秒后内容就顶上来骨架屏存在感极弱视觉上像是抽搐了一下。给它一个最短驻留时间 280 毫秒让骨架屏至少完整地呼吸一次眼睛就舒服多了。这两个值怎么定我的经验是loadingDelay取 100 到 150 毫秒minLoadingDuration取 250 到 350 毫秒。低于 100 毫秒的延迟挡不住闪烁高于 150 毫秒又会让真实的中等耗时请求出现明显空白期。这两个数字不是玄学是人眼对 100 毫秒以内的变化感知为瞬时这个经验值推出来的。State 实现class _AsyncLoaderStateT extends StateAsyncLoaderT { late final AsyncControllerT _controller; late final bool _ownsController; Timer? _delayTimer; bool _showSkeleton false; override void initState() { super.initState(); _ownsController widget.controller null; _controller widget.controller ?? AsyncControllerT(isEmpty: widget.isEmpty); _controller.addListener(_onStateChanged); if (widget.loadOnMount) reload(); } void _onStateChanged() { final loading _controller.state.status AsyncStatus.loading; if (!loading) { _delayTimer?.cancel(); _delayTimer null; if (_showSkeleton) setState(() _showSkeleton false); return; } if (_showSkeleton) return; if (widget.loadingDelay Duration.zero) { setState(() _showSkeleton true); return; } _delayTimer?.cancel(); _delayTimer Timer(widget.loadingDelay, () { if (mounted) setState(() _showSkeleton true); }); } Futurevoid reload({bool refresh false}) { return _controller.run(() _withFloor(widget.task()), refresh: refresh); } FutureT _withFloor(FutureT task) async { final watch Stopwatch()..start(); late T value; try { value await task; } catch (_) { await _keepSkeleton(watch); rethrow; } await _keepSkeleton(watch); return value; } Futurevoid _keepSkeleton(Stopwatch watch) async { if (!_showSkeleton) return; // 骨架没出现过不用补偿 final remain widget.minLoadingDuration - watch.elapsed; if (remain Duration.zero) { await Futurevoid.delayed(remain); } } override void dispose() { _delayTimer?.cancel(); _controller.removeListener(_onStateChanged); // 先摘监听 if (_ownsController) _controller.dispose(); // 再决定是否释放 super.dispose(); } override Widget build(BuildContext context) { return ListenableBuilder( listenable: _controller, builder: (context, _) { final state _controller.state; if (state.status AsyncStatus.idle) { return const SizedBox.expand(); } if (state.status AsyncStatus.loading) { return _showSkeleton ? (widget.skeleton ?? const AsyncDefaultSkeleton()) : const SizedBox.expand(); } if (state.status AsyncStatus.failure) { final error state.error!; return widget.errorBuilder ?.call(context, error, () reload()) ?? AsyncDefaultErrorView(error: error, onRetry: () reload()); } if (state.status AsyncStatus.empty) { return widget.emptyBuilder?.call(context) ?? const AsyncDefaultEmptyView(); } return widget.builder(context, state.data as T); }, ); } }_withFloor里那段逻辑值得单独说。它记了一个秒表任务结束时看看骨架屏是不是真的出现过——只有出现过才需要补足最短驻留时长。如果请求只花了 50 毫秒_showSkeleton还是false那就不等待直接返回用户根本不知道有这回事。这个判断让两个时间参数真正配合起来而不是互相拖后腿。ListBuilder需要 Flutter 3.10 以上老版本换成AnimatedBuilder用法完全一样只是builder签名少一个参数。4.3 三种默认占位的排版要点骨架屏、空态、错误态这三个默认视图我按能被业务覆盖但不覆盖也好看的标准写。骨架屏尤其讲究写不好比菊花还难看。class AsyncDefaultSkeleton extends StatelessWidget { const AsyncDefaultSkeleton({super.key}); override Widget build(BuildContext context) { return ListView.separated( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), itemCount: 8, separatorBuilder: (_, __) const SizedBox(height: 12), itemBuilder: (_, __) Container( height: 72, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), ), ); } }三个细节。用ListView不用Column。这一点后面常见问题里还会提但在这里就要写对骨架屏如果是不可滚动的Column外层套RefreshIndicator的时候用户下拉是拉不动的因为RefreshIndicator需要一个可滚动的子节点来接收手势。用ListView天然可滚再配上AlwaysScrollableScrollPhysics即使内容不满一屏也能下拉。骨架块的数量取 8 而不是撑满。撑满会让屏幕显得密不透风8 个刚好露出一点底部空缺暗示下面还有更多符合真实列表的视觉预期。颜色用surfaceContainerHighest而不是硬编码灰色。深色模式下硬编码的Colors.grey[300]会变成一块刺眼的亮斑用主题色才能自动适配。如果项目里想加呼吸动画外面包一层AnimatedOpacity或者自己写个TweenAnimationBuilder做透明度循环注意控制时长在 1 秒以上太快会晕。错误视图和空态视图相对简单但有个共同的坑错误页必须给重试按钮而且重试的时候不能切回骨架屏。因为错误页通常是全屏的切成骨架屏会闪一下视觉上像是重新打开页面。所以错误页上的重试走的是reload()而不是reload(refresh: true)前者会把状态切成loading——嗯这确实会闪。更好的做法是让错误页自己维护一个小的加载指示重试期间按钮变成转圈class AsyncDefaultErrorView extends StatefulWidget { const AsyncDefaultErrorView({super.key, required this.error, required this.onRetry}); final Object error; final VoidCallback onRetry; override StateAsyncDefaultErrorView createState() _AsyncDefaultErrorViewState(); } class _AsyncDefaultErrorViewState extends StateAsyncDefaultErrorView { bool _retrying false; override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.cloud_off, size: 48, color: Theme.of(context).colorScheme.outline), const SizedBox(height: 12), Text(_friendlyMessage(widget.error), style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 16), FilledButton.tonal( onPressed: _retrying ? null : () async { setState(() _retrying true); widget.onRetry(); await Futurevoid.delayed( const Duration(milliseconds: 600)); if (mounted) setState(() _retrying false); }, child: _retrying ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Text(重新加载), ), ], ), ); } String _friendlyMessage(Object error) { final text error.toString(); if (text.contains(TimeoutException)) return 网络有点慢稍后再试试; if (text.contains(SocketException)) return 网络连接好像断开了; return 加载失败了可以重试一下; } }_friendlyMessage里做的是技术错误翻译成人话。直接把SocketException: Connection refused (OS Error...展示给用户用户只会一脸懵。这里做的是字符串匹配简单粗暴但有效。更好的做法是在数据层就把异常统一包装成业务错误类型UI 层只做字典映射不过那是另一个话题。_retrying加一个 600 毫秒的兜底复位是为了防止父组件在错误状态下没有重新渲染导致按钮永久禁用。这个写法不算优雅但很实用——真实项目里总有那么几个边界情况加个兜底比排查半天划算。4.4 页面接入的实际写法接入一个列表页大概是这样class OrderListPage extends StatefulWidget { const OrderListPage({super.key}); override StateOrderListPage createState() _OrderListPageState(); } class _OrderListPageState extends StateOrderListPage { late final AsyncControllerListOrder _controller; override void initState() { super.initState(); _controller AsyncControllerListOrder(isEmpty: (list) list.isEmpty); } override void dispose() { _controller.dispose(); super.dispose(); } FutureListOrder _fetch

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑