☰
Flutter加载弹窗的工程化封装:从showDialog到Overlay的实践与思考
2026/10/17 4:50:23 网站建设 项目流程

1. 加载弹窗的隐性成本:一个十几行组件引发的线上事故

1.1 一次看起来毫无悬念的"转圈"

先讲个真实发生的事故。我们当时在做一个内部工具类的表单提交流程,功能不复杂:用户填写信息,点提交,调接口,等结果,然后跳转或提示错误。开发时大家觉得加载弹窗这种白菜需求太简单了,于是直接在提交按钮的点击回调里写了一版经典实现——showDialog加一个居中的CircularProgressIndicator,接口返回后再Navigator.pop()关掉。本地跑了一遍,干千净净,转圈正常,关闭正常,代码评审一次通过。当时没人会想到,这个不到二十行的组件,上线后第一个星期就炸出了三个线上问题。

第一起事故的场景是这样:接口在弱网环境下响应很慢,用户点了一次提交,发现按钮没有立刻变灰,也没有任何视觉反馈(因为showDialog是通过Future异步插入的,动画还没起效),于是又点了一次、第三次。结果屏幕上叠了三个遮罩层,同一个表单被提交了三次;有一次请求成功、另一次超时,几条回调里的Navigator.pop()把弹窗关闭逻辑彻底搞乱了,页面卡在半透明灰色遮罩里,点哪儿都没反应。用户最后只能杀掉 App 重进。

这就是加载弹窗最常见的隐性成本:它看起来只是一个"转圈圈",但本质上涉及全局 UI 状态管理、用户交互拦截、异步生命周期协调三件事。任何一个环节处理不当,都会从"小组件"升级成"生产事故"。我后来复盘时总结了一句话:在 Flutter 里,加载弹窗的复杂度从来不在"怎么显示转圈",而在于"谁负责关闭它、什么时候关闭它、以及同时存在多个请求时应该听谁的"。

1.2 加载弹窗的职责边界:不只是"转个圈"

很多人对加载弹窗的定位是有问题的。它的职责不是"告诉用户程序在干活",而是"在业务状态不确定的时间内,阻止用户进行可能造成错误状态的操作"。换句话说,加载弹窗是业务逻辑的一道闸门,不是装饰品。

职责边界可以从三个维度界定:

  1. 阻断维度:加载期间,用户不应该能触发第二次提交、不应该能点击表格里的操作按钮、不应该能切换 Tab 导致当前页面被销毁。
  2. 反馈维度:用户需要知道系统在正常处理,而不是卡死。这要求加载动画本身要稳定、可见,且不会在弱网下"转太久却没反馈"。
  3. 生命周期维度:请求结束、超时、失败、页面被销毁、App 进入后台等不同时机,加载弹窗必须有一种可预期的收敛方式,不能依赖开发人员在每个回调里手动pop。

上文提到的事故,三个维度全都踩了。按钮没有防重,是阻断维度没做;弱网下三次点击,是反馈维度有盲区;回调各自pop,是生命周期维度失控。我后来在团队里给了一个很直白的比喻:你把加载弹窗当成一张"手术中,禁止入内"的告示牌,而不是一个"正在打磨地板"的扫地机器人——前者的核心是纪律,后者的核心只是表演。

1.3 为什么大家都不愿意认真封装 loading

按理说,既然加载弹窗这么容易出问题,应该早就有人抽出时间去搞一套标准封装了。但实际上,很多 Flutter 项目里的加载弹窗都是"每次用到时随手写"的散装代码。原因很现实:

  • 加载弹窗在业务代码里出现频率极高,高到大家觉得"写一次"比"引封装"更快。
  • showDialog提供了几乎为零的上手成本,一个Builder+ 一个CircularProgressIndicator就能跑起来。
  • 团队没有统一的请求层,每个页面自己调接口、自己处理 loading 开关,也就没有地方去做统一拦截。
  • 业务迭代速度快,没人愿意为"一个转圈"写单元测试。

但这些理由在事故面前都不成立。散装 loading 的维护成本,随着页面数量和并发场景的增加会急剧放大。我见过一个项目里同一个页面有三个不同写法的 loading 实现,一个用showDialog,一个用Stack条件渲染,一个用第三方状态管理库里的showLoading接口。三个组件的barrierDismissible设置还不一样,导致同一个 App 里有的 loading 能点掉、有的点不掉。这种混乱状态在一开始不会显现,等到做权限控制、埋点上报、页面保活的时候,全部会变成阻力。

所以说,如果你想认真对待 Flutter 开发,加载弹窗是值得花一上午去设计的一个点。它不华丽,但它是很多 UI 稳定性的地基。


2. 四种实现方案的取舍逻辑:从写个 Demo 到生产环境

2.1showDialog方案:上手最快,但坑也最密

showDialog+CircularProgressIndicator是 Flutter 新手的第一课,也是散装 loading 的最大源头。写法大致是:

showDialog( context: context, barrierDismissible: false, builder: (ctx) => Center( child: CircularProgressIndicator(), ), ); // 接口完成后 Navigator.of(context).pop();

这段代码在"只有一个页面、一个请求、且页面不会被中途销毁"的 Demo 场景下完全没问题。但放到生产环境,坑是接连出现的:

  • barrierDismissible默认值是true,也就是用户点击遮罩层可以取消弹窗。你如果忘记设为false,loading 转到一半用户一戳就没了,但请求还在飞,用户会误以为操作已被取消,接着再点一次提交,造成重复请求。
  • showDialog使用的是rootNavigator还是nestedNavigator需要小心处理。页面里如果嵌入了Navigator(比如用TabBarView加独立路由的场景),Navigator.of(context)可能调的是内层 Navigator,弹窗会出现在子导航器里,层级和预期不一样。
  • 关闭时机一旦依赖多个回调,就容易出现"我先pop了一个,第二个回调又pop一次"导致把业务页面也给pop掉的情况。Navigator.pop没有"只弹弹窗"的语义,它只管栈。这个坑我在后面会专门展开。

我的建议是:showDialog适合用在不涉及复杂并发的一次性提示场景,比如"上传成功"、“删除确认”,不适合作为全局加载的通用手段。用一句话概括——它把"加载中"这个状态的处理权和业务代码耦合在了一起,业务一多,就是事故温床。

2.2Stack内嵌加载层:局部刷新时的正确姿势

如果加载场景只是页面内的局部区域,比如一个列表下拉刷新、一个图表区的数据更新,用Stack条件渲染一个覆盖层是比全局弹窗更合理的方式。

Stack( children: [ _buildContent(), if (_isLoading) Positioned.fill( child: AbsorbPointer( child: ColoredBox(color: Colors.black26), ), ), ], )

这种方式的好处是:

  • 加载层的作用域被限制在页面内,不会把底部导航栏、侧边栏等全局结构全部遮住。
  • 关闭逻辑就是setState(() => _isLoading = false),和页面生命周期贴合,不存在跨 Navigator 的pop问题。
  • 可以被RepaintBoundary包裹,减少不必要的重绘范围。

但它的缺点也很明显——状态散落。每个页面都要维护isLoading字段,如果多个请求并发,就需要计数,如果切换 Tab,状态可能残留。而且从"用户体验一致性"来看,全局加载和局部加载混用会让用户困惑:为什么有的页面转圈是全屏灰的、有的只卡住那一块?

所以我的判断标准是:加载过程不会超过 1 秒、且用户不需要被完全阻断的场景,用Stack内嵌;否则请走全局方案。以上也叫"作用于局部、服务于局部"。

2.3Overlay全局方案:从小组件到基础设施

Overlay是 Flutter 里常被低估的一个组件。showDialog底层其实也是往Overlay插 entry;但它有过多的上层语义(barrier、route、动画)封装。我们直接操作Overlay,能拿到更灵活的控制权,同时也能绕开 Navigator 栈打架的部分问题。

核心思路:维护一个全局单例或状态管理模块,提供一个show()方法向当前页面顶层的Overlay插入一个OverlayEntry,提供dismiss()方法删除 entry。示例:

class Loading { static OverlayEntry? _entry; static void show(Widget content) { if (_entry != null) return; _entry = OverlayEntry( builder: (ctx) => AbsorbPointer( child: ColoredBox( color: Colors.black45, child: Center(child: content), ), ), ); navigatorKey.currentState?.overlay?.insert(_entry); } static void hide() { _entry?.remove(); _entry = null; } }

这里的navigatorKey是挂在MaterialApp上的全局GlobalKey<NavigatorState>,保证我们能拿到最外层 Navigator 的 Overlay,而不是某页面的子 Navigator。用Overlay的好处:

  • 不依赖某个页面的BuildContext,所以在异步操作完成后也可以安全地调用hide(),不会因为页面dispose而崩溃。
  • 可以完全自定义遮罩透明度和点击行为,想加AbsorbPointer就加,想加IgnorePointer就加,不会被barrierDismissible束缚。
  • 支持叠加多个 entry,方便做"loading 之上再盖一个 toast"这类复杂的视觉层次。

当然,直接操作Overlay也要自己处理一个关键问题:重复 show / 错误 hide。所以真正的封装一定是带引用计数的,不能简单用if (_entry != null) return;一挡了之。等会儿第 4 节我会放一版更完整的。

2.4 状态管理框架自带的 loading 接口:别把控制权完全交给框架

Flutter 生态里有一些状态管理方案内置了"调接口时自动显示 loading"的能力,比如某些全局 store 提供的showLoading接口、控制器里loading状态的自动跟踪等。这类方案很香,因为它把"请求开始 → 置 loading → 请求结束 → 关 loading"这个链路收拢到了框架层,开发人员少写一半代码。

但它有个隐蔽的问题:框架的 loading 往往是全局单例式的,缺乏业务语义分层。同一个页面同时发起三个请求,你希望的是一个 loading 转圈还是"三个 loading 各自管理自己的区域"?框架层的全局 loading 通常不管这些,它只响应一个isLoading布尔值,请求 A 结束就置 false,但请求 B 还在跑,loading 就被提前关掉了。

另外,某些框架的 loading 实现和业务层耦合太重——它在 store 的 action 里自动加了"请求前调 show、请求后调 hide",如果你想在特定业务里禁用 loading(比如静默上传图片),你得再写一套覆盖逻辑,绕来绕去比手动管理还累。

我的建议是:框架自带 loading 可以用,但只用在"单请求单 loading"的简单页面;复杂页面请接管到自定义组件里,保证每个 loading 有明确的归属和收敛逻辑。可以关注框架的 loading 源码,但不要把它当成银弹。


3. 高频问题排查链路:现象、根因与复现路径

3.1 崩溃经典款:"setState() called after dispose()"

这是 Flutter 开发里出现频率最高的报错之一,加载弹窗场景尤其常见。典型链路是:

  1. 页面 A 发起请求,showDialog 弹出 loading。
  2. 用户等待期间点击返回按钮,页面 A 被Navigator.pop销毁。
  3. 请求完成后,异步回调里执行setState(() => _data = xxx),因为页面 A 已经 dispose,直接抛错。

另一个变种是:对话框的context来自builder内部,你在对话框里引用了页面的某个方法,页面销毁后对话框还残留在 Overlay 里,此时setState同样会炸。解决办法不是"别在异步里 setState",而是要在回调前检查mounted:

if (mounted) { setState(() { ... }); }

但要注意,mounted检查的是当前 State 对象是否还在树里,如果你用的是动画控制器、User对象等,还要额外判断。我在项目里见过有人把所有setState都包了一层if (mounted),结果遇到某些页面用GlobalKey替换了子树,mounted依然为 true,但状态已经冗余。所以更稳妥的收口手段是:让 loading 的显示与关闭完全脱离页面上下文,也就是用第 2.3 里的 Overlay 方案,请求层统一维护状态。页面销毁归销毁,请求回调归请求回调,两者互不碰面。

复现路径也很简单:弱网 + 快速返回 + 请求完成后恢复页面状态,必现。这个坑的教训是,不要觉得"我页面简单,不会有人中途退出"。一个加载弹窗的存在就意味着页面可能处于"等待外部事件"的状态,而等待期间发生任何导航变化都是正常现象。

3.2 弹窗卡死/无法关闭:Navigator 栈混乱的三种成因

另一种高频线上问题,是 loading 弹窗"怎么都关不掉"。定位时不要先怀疑 Flutter 框架,大概率是你自己的pop行为破坏了路由栈的平衡。我总结过三种常见成因:

成因一:多次 showDialog 但只记录了一个关闭引用。前文的事故就是最典型的例子。用户连点三次提交,三次调用了showDialog,但代码里只持有一个"关闭弹窗用的 context 或某变量",接口回调时只pop了一次,剩下两个弹窗还留在 Overlay 上。解决手段是在调用入口处做防重(if (_loadingActive) return;),或者在 show 方法里用引用计数,保证每次 show 都对应一次 hide。

成因二:嵌套 Navigator 与 rootNavigator 混用。如果你的页面结构是主 Navigator 内嵌了一个 Tab 导航,而你在某个子页面用Navigator.of(context)弹 loading,关闭时却用全局Navigator.pop,它们操作的是两个栈。弹窗可能在子栈的顶层,全局pop会先弹掉当前子路由,弹窗反而被压在下面一点动静都没有。

成因三:barrierDismissible: false但代码分支漏了关闭逻辑。设置成 false 后,用户无法通过点击遮罩关闭,这是符合产品预期的;但如果请求异常分支里没有写Navigator.pop,loading 就会永远挂在那里。最闹心的是这种 bug 往往在正常流程测不出来,只有断网、超时、服务端返回非 200 时才会触发。建议所有异步分支(成功、失败、超时、finally)统一走一个hideLoading()收口,不要在每个分支手写pop。

检查这类问题时,有个很实用的调试技巧:在MaterialApp的navigatorObserver里加一段日志,记录didPush、didPop事件。这样你能清楚地看到路由栈的进出顺序,比对着代码干猜快得多。

3.3 点击遮罩导致误操作:barrierDismissible 只是第一道防线

barrierDismissible控制的是"点击遮罩是否关闭弹窗",但它不能完全禁止事件穿透。如果你用showDialog自带的 barrier,Flutter 的ModalBarrier会吸收点击事件;但如果你用自定义 Overlay 方案,或者在某些嵌套结构里用了半透明ColoredBox做遮罩,点击事件往往会直接打到下层按钮上。

我遇到过一个具体的复现:页面底部有一个"提交订单"按钮,加载弹窗弹出后,用户习惯性去点原来按钮的位置,结果虽然屏幕上有灰色遮罩,事件还是穿透到了按钮上,又触发了一次提交。原因就是我第 2.3 节里展示的初始版本——用了ColoredBox(color: Colors.black45)而不是AbsorbPointer。ColoredBox只是视觉上的遮挡,它本身不拦截任何指针事件。

治本的办法是两层防护:

OverlayEntry( builder: (ctx) => AbsorbPointer( absorbing: true, child: ModalBarrier( color: Colors.black45, dismissible: false, ), ), )

或者更简单,直接用Positioned.fill包裹一个GestureDetector并设置onTap: () {},也能起到拦截事件的效果。这里有个值得记住的原则:加载弹窗的遮罩层,在"事件路由"上一定要是一个"无法穿透的墙",而不仅仅是一层"涂色"。AbsorbPointer是 Flutter 里专门干这个的组件。

3.4 弹窗闪烁/层级被遮挡:Overlay 层级与 Navigator 栈的协作问题

第三个高频问题是"loading 弹窗时有时无地闪烁"或"loading 被其它弹窗压住了"。如果你混合使用showDialog和showSnackBar/showModalBottomSheet,这个问题会很明显。

原因是:showDialog默认插入到当前 Navigator 的 Overlay 里,层级顺序取决于插入时机。如果你先弹了 loading,再弹了一个底部弹窗,底部弹窗的 route 会 cover 在 loading 之上,用户看到的就是"转圈转到一半,底部滑出来一张卡片"。反过来,如果底部弹窗先出现,你再弹 loading,loading 又会盖住底部弹窗。

解决方案有两个方向:

  1. 统一使用rootNavigator的 Overlay 来承载加载弹窗,让它的层级永远在业务路由之上。
  2. 产品层面明确"加载弹窗优先于一切其他浮层",一旦 loading 处于活跃状态,其它浮层(SnackBar、BottomSheet)统统延后或丢弃。

我在项目里实现过一种粗暴但有效的方式:loading 显示时,用Navigator.of(context, rootNavigator: true)把已有的ModalRoute全部标记为不透明,这样下面的浮层在视觉上自然被遮住。但要注意这个方案会带来一定的性能开销,不适合极端频繁的弹窗场景。更通用的做法,还是维护一个全局浮层管理器,让 loading 永远在最高层级。


4. 可落地的加载弹窗封装:API 设计、幂等控制与生命周期治理

4.1 对外 API:怎么设计调用方式才顺手

经历了前面那些事故后,我抽出半天时间重写了一套可复用的加载弹窗组件,后面一直沿用到几个项目里。设计 API 时我定了三条原则:

  1. 调用方不需要感知关闭细节:页面代码只需要调LoadingHelper.show(),无需保存返回值去pop。
  2. 幂等性由内部保证:重复show()不会堆叠多个弹窗,而是"保持当前 loading 状态,计数 +1"。
  3. 业务可覆盖:允许自定义提示文字、加载图标、遮罩透明度,允许在特定场景下禁用 loading。

对外方法如下:

class LoadingHelper { static void show({String? text, Color? barrierColor, bool? dismissible}); static void hide([int count = 1]); static Future<T>? loadingFuture<T>(Future<T> Function() task, {String? text}); }

其中loadingFuture是我个人最推荐的一个接口,它把"异步任务 + loading 生命周期"绑定在一起:

static Future<T>? loadingFuture<T>(Future<T> Function() task, {String? text}) async { show(text: text); try { return await task(); } catch (e) { rethrow; } finally { hide(); } }

调用方是最舒服的写法:

final result = await LoadingHelper.loadingFuture(() { return Api.submitOrder(payload); }); if (result != null) { // 处理结果 }

这种 API 的妙处在于,它把"转圈"和"业务逻辑"的纠缠降到了最低,业务代码里不再出现showDialog和Navigator.pop这些基础设施调用,它们全部被收拢到了封装内部。

4.2 核心实现:弹窗生成与销毁的幂等控制

组件的核心是 OverlayEntry 的引用计数管理。直接上代码:

class LoadingHelper { static OverlayEntry? _entry; static int _refCount = 0; static void show({String? text, Color? barrierColor, bool? dismissible}) { if (_entry != null) { _refCount++; return; } _refCount = 1; _entry = OverlayEntry( builder: (ctx) => AbsorbPointer( child: Container( color: barrierColor ?? Colors.black45, alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(color: Colors.white), if (text != null) ...[ const SizedBox(height: 12), Text(text, style: const TextStyle(color: Colors.white)), ], ], ), ), ), ); navigatorKey.currentState?.overlay?.insert(_entry!); } static void hide([int count = 1]) { if (_entry == null) return; _refCount = (_refCount - count).clamp(0, 1 << 32); if (_refCount <= 0) { _entry?.remove(); _entry = null; _refCount = 0; } } }

这里有几个细节值得说明:

  • AbsorbPointer必须包裹最外层:这样点击遮罩时事件根本传不到下层。
  • overlay?.insert之前要判空:如果全局navigatorKey还没挂上(比如在main()里过早调用),currentState可能为 null,要主动防御。
  • _refCount管理的是"show 了几次",而不是"当前可见的弹窗数"。这样你并发发三个请求,三次show()只产生一个弹窗;第一个和第二个请求结束后各调用一次hide(),引用计数还剩 1,弹窗保持;第三个请求结束再hide(),才真正关闭弹窗。

这个幂等设计花了我不少时间,但回头看确实值得。它从根上解决了"多次点击堆积弹窗"和"先完成的请求提前关掉 loading"两个老大难问题。你可以把_refCount理解成"正在等待的请求数量"——等所有请求都返回了,闸门才落下。

4.3 与业务请求层的配合:统一拦截 error/loading

单独一个 LoadingHelper 还是不够的,因为它只能被手动调用。真正的工程化,是要在请求层做一个统一收口,让绝大多数页面不需要手动 show/hide loading。我以项目里用过的简单封装为例:

class ApiClient { final Dio _dio; Future<T> get<T>(String url, {Map<String, dynamic>? query, bool silent = false}) async { if (!silent) { LoadingHelper.show(); } try { final resp = await _dio.get<T>(url, queryParameters: query); return resp.data; } catch (e) { // 统一错误处理、上报 rethrow; } finally { if (!silent) { LoadingHelper.hide(); } } } }

重点在silent参数。有的业务场景(比如上传日志、自动续期 token、静默刷新)不希望弹出 loading 打扰用户,这时调用方传silent: true即可。还有一个进阶用法:按 API 分组控制不同 loading 粒度。比如一个列表页同时加载"列表数据"和"用户收藏状态",你希望两个请求合并显示一次 loading,可以给同一页面下的请求带同一个loadingGroup,由请求层维护组内计数。

多线程或者 Dart 并发下的一个注意点:LoadingHelper是单例,全局只有一个状态,所以它天然不支持"页面 A 显示红色 loading、页面 B 显示蓝色 loading"。如果产品需要按页面定制 loading 视觉,有两个办法:一是 loading 内容里读取当前路由信息做差异化,二是放弃全局单例,改为每个页面持有自己的LoadingController。我建议除非业务确实需要,否则尽量保持全局统一视觉,给用户的一致性体验更好。

4.4 防重提交的关卡:loading 之前的最后一道防线

很多加载弹窗问题,表面是弹窗关闭逻辑错了,实际是不应该有第二个请求被发起。请求层除了负责 loading,还应该负责一个更基础的事——幂等防重。

我实现过一个简单的防重拦截器,思路是按"接口路径 + 参数摘要"作为请求的幂等键。如果某个幂等键的请求还在飞行中,后续相同请求直接丢弃,或者返回上一次的 Future。

class DuplicateGuard { static final Map<String, Future<Object?>> _inflight = {}; static Future<T> run<T>(String key, Future<T> Function() task) { final existing = _inflight[key]; if (existing != null) { return existing as Future<T>; } final future = task().whenComplete(() { _inflight.remove(key); }); _inflight[key] = future; return future; } }

调用方式:

final result = await DuplicateGuard.run( 'submit_order:${payload.orderId}', () => ApiClient.instance.post('/order', data: payload), );

这个防重 + loading 的组合拳,可以把 1.1 节的事故从根上杜绝:用户连点三次,后两次请求根本不会发出,loading 也只会出现一次。防重是闸门的前置,loading 是闸门给用户的反馈,两者配合才是完整的交互闭环。


5. 工程化收尾:测试策略、弱网表现与视觉细节

5.1 给加载弹窗写测试:从"没人管"到"必测项"

加载弹窗这种基础设施,确实值得写测试。我建议至少覆盖三个维度:

  1. 幂等性测试:连续调用show()三次,断言只插入了一个 OverlayEntry;调用hide()一次,弹窗还在,调用三次,弹窗消失。这是 4.2 节核心逻辑的关键防线。
  2. 防穿透测试:在WidgetTester里挂一个按钮,弹出 loading 后,尝试tap按钮位置,断言按钮回调没有被触发。对应 3.3 节的AbsorbPointer设计。
  3. 生命周期测试:模拟"页面销毁后请求返回",断言调用hide()或LoadingHelper.hide()不会抛异常。

写 Widget 测试时有个小技巧:用tester.pump()控制动画帧,让 loading 动画稳定后再做断言,否则CircularProgressIndicator在持续动画中会导致测试环境一直处于"未 settled"状态。我会用tester.pumpAndSettle或者给测试设一个timeout来避免卡死。

5.2 弱网与超时:loading 消失的最后兜底

不管请求层设计得多好,总会有"服务器 10 秒没响应"的极端情况。如果加载弹窗一直转下去,用户只会感觉 App 死了。所以超时兜底是加载弹窗工程化里不可缺失的一环。

我的做法是给请求层加统一的超时配置:

  • 全局默认超时:连接超时 5 秒,接收超时 10 秒。
  • 特定接口可覆盖,比如上传类接口给 30 秒。
  • 超时后,finally块一定会执行LoadingHelper.hide(),保证弹窗收敛。
  • 同时在 UI 层做一个保险:如果 loading 显示超过 15 秒且没有任何请求在飞行,视为异常,主动隐藏并上报监控。

这个"UI 层保险"看起来有点傻,但在一些奇怪的真机环境下(比如系统时间跳跃、Dart 事件循环阻塞导致回调延迟)能救回很多用户。不需要做成产品功能,就当基础设施里的安全气囊。

5.3 性能与观感:动画、透明度、加载内容的细节打磨

最后聊几个经常被忽略的细节:

  • 动画开销:CircularProgressIndicator在转圈时会不断触发重绘,如果它被包在一个很大的组件树里,低端设备可能会卡顿。把 loading 组件单独用RepaintBoundary包一层,可以隔离重绘范围,代价很小但收益明显。
  • 颜色适配:纯白背景平台上,如果遮罩也是浅色,白色转圈会看不清。最保险的做法是给 loading 内容套一个半透明深色圆角背景,比如Container配Colors.black87,再放白色动画,这样无论底层页面什么颜色,对比度都足够。
  • 加载文案:超过 3 秒的 loading,建议在转圈下方放一句"正在提交,请勿重复操作"之类的提示。这既是体验设计,也是防重复点击的软提示。
  • 文本超长:如果自定义text很长,记得给 loading 容器的宽度设上限,用ConstrainedBox防止文案撑出屏幕。

我还踩过一个小坑:Android 上如果把 loading 做成了系统弹窗样式(类似showDialog默认的 Dialog 结构),在某些低内存设备上弹窗动画会和页面转场动画冲突,出现白屏闪烁。后来我统一改用 Overlay 方案后在 Android 上没有复现过该问题。

5.4 后期扩展:从"只有 loading"到"加载/Toast/空态"三合一

组件稳定之后,我在它基础上做了一点扩展:把全局浮层管理扩展成"加载弹窗、Toast 轻提示、空态占位"三合一的统一模块。思路是把 OverlayEntry 的分类做进同一个管理器里,然后按下述规则排布层级:

  • loading 优先级最高,永远最高层级。
  • Toast 追随 loading 之后,可叠加在 loading 之上显示(比如"加载失败,请重试")。
  • 空态是页面级组件,不占用 Overlay 层级,但在 loading 隐藏后才允许被用户看到。

这个三合一有个很实际的好处:错误提示和 loading 消失之间不会出现"同时消失"或"先后顺序错乱"的尴尬。比如请求失败时,先 hide loading,再 show Toast "网络异常",用户看到的顺序是:转圈消失、Toast 出现、页面恢复可交互。如果这两个动作没有统一管理,可能出现"Toast 先出现,loading 还在转"或者"loading 和 Toast 一起消失"的鬼畜效果。

我在项目中实测下来,这个扩展让新页面接入加载状态的代码量整体下降了约 40%,因为很多页面只需要一行:

final data = await bus.run(() => Api.list());

内部自动完成了 loading 显示、请求、关闭、失败 Toast 提示。业务代码从"每次七零八落地处理 loading 和 error"变成了"一个 await 搞定"。如果你也在维护 Flutter 项目,我真心建议试一下这个思路,可能比你想象中节省更多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询