最近在做Flutter工程适配鸿蒙生态,需要在App里补一个统一风格的加载反馈层。市面上的Loading组件要么太重、要么和HarmonyOS的卡片化设计语言不太搭,后来干脆自己封装了一个HarmonyLoader,正好赶上HarmonyOS 6.0的适配窗口,把组件做成了可复用的示例卡片形式。这篇东西不是官方文档复读,是我实际搭出来、跑通、又修掉几个坑之后的完整记录,从设计思路到核心实现,再到真机调试时遇到的那些破事,一次讲清楚。
1. 整体设计思路
1.1 为什么用Flutter × HarmonyOS 6.0做加载弹窗
先说明一下背景。我们手上的业务是跨端应用,主体逻辑都在Flutter侧,但同时要跑在鸿蒙设备上。早期方案是维护两套原生Loading,Android一套、鸿蒙一套,样式不统一不说,每次改文案、调动画都要同步改两遍,版本一多就经常漏改。
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 160,maxWidth 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 | 未显示 | 无Overlay |
| loading | 加载中 | 显示动画+可配置文案 |
| success | 加载成功 | 动画区变更为勾选图标,淡出后自动移除 |
| error | 加载失败 | 文案区显示错误信息,提供重试按钮 |
这个状态机的好处是:业务层只需调用LoaderController.showLoading()、LoaderController.showSuccess()、LoaderController.showError(),内部自动完成动画切换和Overlay更新,不需要手动干涉。
我实现时给状态切换加了动画衔接,形成流畅的“加载→成败”过渡,具体是用一个AnimationController配合状态标识,在状态提交时执行一个TweenSequence:
- loading 状态:控制器正转到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跑完,我准备把它继续抽成独立的插件包,里面除了加载弹窗,把空态、错误态也一起沉淀成一套“状态卡片库”,后续新页面直接套用,不用再重复造轮子了。