最近在做Flutter工程适配鸿蒙生态需要在App里补一个统一风格的加载反馈层。市面上的Loading组件要么太重、要么和HarmonyOS的卡片化设计语言不太搭后来干脆自己封装了一个HarmonyLoader正好赶上HarmonyOS 6.0的适配窗口把组件做成了可复用的示例卡片形式。这篇东西不是官方文档复读是我实际搭出来、跑通、又修掉几个坑之后的完整记录从设计思路到核心实现再到真机调试时遇到的那些破事一次讲清楚。1. 整体设计思路1.1 为什么用Flutter × HarmonyOS 6.0做加载弹窗先说明一下背景。我们手上的业务是跨端应用主体逻辑都在Flutter侧但同时要跑在鸿蒙设备上。早期方案是维护两套原生LoadingAndroid一套、鸿蒙一套样式不统一不说每次改文案、调动画都要同步改两遍版本一多就经常漏改。Flutter的优势在于跨端UI一致性和高效的渲染管线但传统Flutter弹窗在鸿蒙设备上总有点“水土不服”默认Material风格的弹窗在鸿蒙系统里观感偏重圆角、阴影、模糊效果跟HarmonyOS的“轻量化卡片”设计语言差异较大。HarmonyOS 6.0上Flutter官方的适配层已经成熟了不少UI线程调度、平台通道的稳定性都比早期版本有明显改善所以用Flutter写UI层、通过平台通道跟系统能力交互成了一个既省成本又保证体验的选择。HarmonyLoader这个示例卡片本质上是一个基于Flutter封装的全屏半透明加载层组件运行在HarmonyOS 6.0真机和模拟器上以卡片式弹窗展示加载动画、状态文案和可选的取消操作。它解决的问题很具体异步请求期间用户不知道该不该等、等多久、能不能退出这个组件把加载状态可视化同时遵循鸿蒙的卡片圆角、对比度、字重等设计倾向。1.2 方案选型Overlay、组件封装还是独立路由页实现一个全局加载弹窗常见有三种做法我列个对比表方案实现方式优点缺点独立路由页Navigator.push一个透明路由生命周期管理清晰转场动画免费页面入栈出栈需要额外维护弹窗场景杀鸡用牛刀全局状态组件显隐在页面顶层塞一个Loading组件用状态机控制visible写法简单直白侵入业务代码每个页面都要挂组件遗漏一个页面就出现“有的页面能弹、有的不能弹”的奇葩问题OverlayEntry动态插入通过Overlay.of(context).insert()挂载全局可用可以挂在整个App最顶层不受路由嵌套影响需要自己处理生命周期和释放否则容易泄漏我最终选了OverlayEntry方式。原因有三加载弹窗本质上是“覆盖型信息展示”跟页面导航没有强关联用路由页反而引入转场动画、返回手势等不必要的复杂度。用Overlay挂载可以实现单例管理无论是从页面A发起请求还是从页面B发起请求都往同一个Overlay容器里插入条目弹出、更新、移除的操作路径是统一的。在HarmonyOS 6.0上Flutter的Overlay机制运行稳定只要注意层级嵌套在处理软键盘弹出、底部导航栏遮挡等问题上比自绘叠加层更可靠。后期我还给这个Overlay加了一个“全局单例门面类”内部维护一个OverlayEntry实例对外暴露show()、update()、dismiss()三个方法。业务层不用关心Overlay怎么插、怎么移除只管调API。2. 加载动画与卡片布局的核心细节2.1 动画选型旋转圆弧比转圈菊花更贴鸿蒙风HarmonyOS 6.0的加载动画风格偏向轻量、线性我自己做了对比测试经典的Material CircularProgressIndicator在鸿蒙视觉下显得“粗鲁”没有呼吸感。我最后用的是自绘圆弧加载器一段圆弧绕中心旋转圆弧中间有个小亮点做渐变色扫动完成的加载反馈是一段300ms的淡出弧线合拢动画。这里介绍一下自绘思路。用CustomPainter每次paint()根据动画控制器的时间t计算圆弧扫过的角度。周期为1.0秒前70%时间圆弧从60度扫到300度后30%时间透明度梯度减少模拟一种“快加载完”的节奏感。核心参数圆弧宽度4 dp在卡片尺寸上显得精致不会抢内容区焦点圆弧颜色主色渐变从Theme主色到半透明同色背景色使用HarmonyOS卡片推荐的遮罩色面板自身带高模糊度半透明效果实现一个Painter的关键代码是这样的class LoaderPainter extends CustomPainter { final double progress; final Color color; LoaderPainter({required this.progress, required this.color}); override void paint(Canvas canvas, Size size) { final center Offset(size.width / 2, size.height / 2); final radius size.width / 2 - 6; final rect Rect.fromCircle(center: center, radius: radius); final startAngle -progress * 2 * pi; final sweepAngle 2 * pi * 0.75; final paint Paint() ..style PaintingStyle.stroke ..strokeWidth 4 ..strokeCap StrokeCap.round ..shader SweepGradient( startAngle: startAngle, endAngle: startAngle sweepAngle, colors: [color, color.withOpacity(0.2)], ).createShader(rect); canvas.drawArc(rect, startAngle, sweepAngle, false, paint); } override bool shouldRepaint(covariant LoaderPainter oldDelegate) oldDelegate.progress ! progress; }注意shouldRepaint里的判断条件这个是性能优化的关键点。如果不做区分每次动画帧回调都会触发paint在低端鸿蒙设备上容易掉帧。我只比较progress变化状态没变就不重绘。2.2 卡片布局圆角、间距、文案区域的“加减法”弹窗的视觉呈现遵循“卡片容器内容区域”的层级结构。卡片本身是一个圆角矩形圆角值按HarmonyOS 6.0新版样式定为24 dp边缘留有柔和阴影面板背景使用半透明加Blur效果。卡片内部从上到下分三个区域动画区固定高度96 dp居中放置自绘Loader。文案区最多支持两行文本字号14或16 sp灰色系颜色通过maxLines和overflow控制溢出行为避免文案过长撑破卡片。操作区可选的取消按钮。这个按钮是TextButton形态字号14 sp使用主色调点击后触发dismiss()回调。一个值得注意的点是弹性间距。卡片宽度设置了一个范围约束minWidth 160maxWidth 280如果文案很短就收缩宽度如果文案很长就限制宽度并通过文本换行来扩展高度这样在手机横屏、平板等不同尺寸屏幕上表现都不错。关于模糊效果HarmonyOS 6.0的Fluid效果在Flutter侧还不完全支持原生实时模糊所以背景遮罩我做了降级处理用半透明黑色一张静态高斯模糊纹理混合出类似系统毛玻璃的质感。这个技巧实测下来在真机上每帧开销很小视觉效果接近原生。3. 核心实现流程3.1 环境准备与工程接入我使用的是Flutter 3.x稳定版配HarmonyOS 6.0 SDK在鸿蒙工程中通过集成Flutter模块来跑所以需要先确认环境变量和SDK路径正确。工程接入分三步在现有HarmonyOS项目中集成Flutter模块可以通过flutter create --org com.example --platforms ohos生成兼容工程。在Flutter工程下创建lib/components/harmony_loader/目录放入该组件的全部源码。在pubspec.yaml中引入需要的依赖核心只有flutter和flutter/services不引入额外第三方包。注意HarmonyOS 6.0的Flutter工程运行需要连接HDC调试工具并且开启USB调试模式调试时如果遇到“module not found”类报错优先检查SDK版本和ohos平台的依赖声明是否匹配。3.2 Loader状态机设计相比直接把Loading做成一个bool变量我更推荐用一个状态枚举来管理加载反馈层。这个示例卡片定义了四种状态状态含义UI表现idle未显示无Overlayloading加载中显示动画可配置文案success加载成功动画区变更为勾选图标淡出后自动移除error加载失败文案区显示错误信息提供重试按钮这个状态机的好处是业务层只需调用LoaderController.showLoading()、LoaderController.showSuccess()、LoaderController.showError()内部自动完成动画切换和Overlay更新不需要手动干涉。我实现时给状态切换加了动画衔接形成流畅的“加载→成败”过渡具体是用一个AnimationController配合状态标识在状态提交时执行一个TweenSequenceloading 状态控制器正转到0.75展示圆弧。success 状态圆弧先快速淡出再让钩子路径绘制的进度从0到1。error 状态圆弧停止并变为半透明的红色再显示文案。这样虽然代码比单纯显示/隐藏多写了一些但用户感知到的反馈非常完整不会觉得页面“死”了一下。3.3 Overlay挂载与更新逻辑Overlay挂载的核心API不多但使用细节需要注意。我设计组件时写了一个全局管理类class HarmonyLoader { static OverlayEntry? _entry; static void show({ required BuildContext context, String text 加载中..., bool cancelable false, }) { if (_entry ! null) return; // 避免重复弹出 _entry OverlayEntry( builder: (context) _LoaderCard( text: text, cancelable: cancelable, onCancel: () dismiss(), ), ); Overlay.of(context, rootOverlay: true).insert(_entry!); } static void update({String? text, LoaderStatus? status}) { if (_entry null) return; _entry!.markNeedsBuild(); } static void dismiss() { _entry?.remove(); _entry null; } }关键点是Overlay.of(context, rootOverlay: true)传入rootOverlay: true确保加载弹窗插入到应用根Overlay上面否则在ModalBottomSheet、Dialog等场景中调用时会发现Overlay被遮挡弹不出来。每次update()之后记得调用markNeedsBuild()通知Overlay重建否则文案和状态改了但UI不刷新这个问题很容易被忽略。3.4 卡片组件搭建_LoaderCard是整个组件的UI核心它引用上面的状态枚举和Painter。整体布局结构用简单的Container Stack Column即可实现Widget build(BuildContext context) { return Positioned.fill( child: Container( color: const Color(0x66000000), alignment: Alignment.center, child: _buildCard(context), ), ); } Widget _buildCard(BuildContext context) { return Container( constraints: const BoxConstraints( minWidth: 160, maxWidth: 280, ), padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), decoration: BoxDecoration( color: _cardColor, borderRadius: BorderRadius.circular(24), boxShadow: const [ BoxShadow(color: Color(0x14000000), blurRadius: 24, offset: Offset(0, 8)), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildIndicator(), const SizedBox(height: 16), Text(text, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis), if (cancelable) const SizedBox(height: 12), if (cancelable) TextButton(onPressed: onCancel, child: const Text(取消)), ], ), ); }这里我用的是Positioned.fill而不是Center嵌套这样在键盘弹出、系统状态栏变化时遮罩层能自适应整个屏幕而不会被顶部SafeArea影响。卡片用内边距控制间距文案行数限制两行避免加载超长文案把卡片撑成“横幅”。3.5 生命周期与内存释放使用OverlayEntry有一个隐藏风险如果你在页面销毁后还持有这个entry会出现内存泄漏。HarmonyLoader在dispose时不能只依赖外部调用dismiss()组件自身也需要处理页面生命周期。我给状态管理类挂一个WidgetsBindingObserver监听AppLifecycleState的变化。当应用进入后台时自动移除Overlay回到前台再由业务层重新触发加载反馈。这样处理不仅省电也避免了Android类设备上组件残留引起的性能问题。另外所有AnimationController都在State的生命周期中创建和释放override void initState() { super.initState(); _controller AnimationController(vsync: this, duration: const Duration(milliseconds: 1200)); _controller.repeat(); } override void dispose() { _controller.dispose(); super.dispose(); }两个细节提醒加载动画在重复执行时要明确repeat()不需要手动每一帧去animate()如果组件支持取消和重试要注意在点击取消后把控制器状态重置到初始避免下次弹出时动画从旧状态继续。4. 常见问题与实战排查4.1 问题速查表我整理了在HarmonyOS 6.0真机和模拟器上遇到过的几类问题按频率排序现象可能原因解决方法Overlay插入后不显示用了Overlay.of(context)而非rootOverlay: true改为Overlay.of(context, rootOverlay: true)弹窗弹出后背景可穿透点击遮罩Container缺少Positioned.fill或者没有设置opacity颜色确保遮罩层使用Positioned.fill并设置半透明背景动画卡顿掉帧每帧都触发markNeedsBuild优化shouldRepaint只在progress变化时才重绘真机上毛玻璃效果失效HarmonyOS 6.0部分设备对BackdropFilter支持不完整使用静态模糊纹理降级方案连续弹出多个Loading全局单例未做开关保护在show()开头判断_entry ! null直接返回页面销毁后仍然弹出OverlayEntry在路由pop时未释放在生命周期观察器里移除Overlay并在业务侧统一调用dismiss()4.2 一个典型的动画跳变问题调试时遇到一个很典型的问题loading动画在反复横跳时而是转了半圈的圆弧时而又从头开始转。排查发现问题出在两个页面共用同一个OverlayEntry页面A移除时把_entry置空页面B的show()又创建一个新entry由于新entry初始化时AnimationController从0开始视觉上看起来就是动画突然回到起点。解决方式是在全局管理类里增加一个reuse策略如果当前entry还在显示就只更新文案或状态而不重建OverlayEntry。这样即使页面切换加载动画的进度也是连续的用户感知不到断裂。4.3 低端机性能优化实践HarmonyOS 6.0跑在低端设备上时Overlay里的全屏半透明遮罩会对GPU产生额外开销。实测在百元级设备上全屏模糊会导致帧率掉到50fps以下。我做了三项优化卡片下层不使用实时模糊而是预置一张静态纹理。遮罩透明度控制在40%以下降低合成压力。动画区域用RepaintBoundary隔离避免Loader动画的重绘扩散到整个卡片。这三项改完后低端机帧率稳定在55fps以上肉眼几乎没有卡顿感。5. 扩展场景与经验沉淀5.1 卡片服务的扩展可能性目前HarmonyLoader是作为App内Overlay组件在工作。HarmonyOS 6.0服务卡片框架也支持类卡片形态的加载反馈后续如果产品要求“桌面卡片上显示加载进度”这个组件的Painter和状态机可以复用很大一部分只是把Overlay容器替换成服务卡片的布局支撑。在Flutter侧做小型动态内容更新时可以通过PlatformChannel把加载进度推送到原生卡片整体链路可以复用当前组件的回调体系这是后续一个很自然的扩展方向。5.2 对Flutter团队协作的启示这个组件做下来我体会比较深的一点是团队做跨端组件时状态机的设计应该先于UI实现。如果一开始就埋头画卡片后面接入真实业务时一定会被各种状态组合逼疯。先定义好idle/loading/success/error四个状态以及它们之间的转移条件再让UI去适配状态这样无论是接接口还是接推送代码都稳如老狗。另外命名和接口设计决定了协作效率。show()、update()、dismiss()这种直白到极致的API业务方接手不需要问任何问题。我在实际项目里见过不少“懂事”的API加了各种炫酷的builder模式结果调用方根本不知道要传什么、回调什么时候触发维护成本反而更高。这一版HarmonyLoader跑完我准备把它继续抽成独立的插件包里面除了加载弹窗把空态、错误态也一起沉淀成一套“状态卡片库”后续新页面直接套用不用再重复造轮子了。