这几个月我一直在折腾一件事:把以前做的一套 Flutter 组件库,原封不动地搬到鸿蒙应用上。页面布局那些都好说,真正让我熬夜排查的,反而是看起来最不起眼的交互提示组件——Toast、SnackBar、Dialog 这一堆“弹个框、冒个泡”的东西。它们在 Android 上跑得好好的,一上鸿蒙就各种不按套路出牌:没弹出来、弹出来被系统拦了、切个页面就消失、动画卡得掉帧。这篇文章就是我把这些坑一个个填掉之后整理的完整记录,包括组件选型、代码实现、鸿蒙平台适配、遇到的高频问题和排查思路,适合正在用 Flutter 开发鸿蒙应用、或者在鸿蒙上做 UI 适配的朋友。
1. 交互提示组件到底选哪套方案
1.1 提示组件的分类和使用场景
交互提示组件不是一个严格的技术名词,它更像是一类“给用户反馈”的 UI 集合。我平时把它们分成四类:
- 轻量瞬时提示:Toast、SnackBar 这类,告诉用户“操作成功”“网络错误”,不需要用户决策,出现一两秒自己消失。
- 确认决策型弹窗:AlertDialog、BottomSheet,需要用户选择“确定”还是“取消”,或者从几个选项里挑一个。
- 主动推送型提醒:Banner、全局通知条,在页面顶部或底部滑出来,比如“版本更新”“当前处于离线状态”,可能带一个跳转按钮。
- 嵌入式状态反馈:按钮 loading、列表底部加载状态、表单校验错误,这些不算严格意义的“提示组件”,但和提示交互紧密相关。
在 Flutter 里,这四类都有对应的原生实现:SnackBar、showDialog、showBottomSheet、MaterialBanner,再加上一个可以自定义几乎所有东西的 Overlay。你不需要引入特别复杂的第三方库,Flutter 自带的 Material 组件已经覆盖了绝大多数场景,而且在 Android 和鸿蒙上渲染效果几乎一致,因为全部是 Flutter 自己的引擎画出来的,和底层系统 UI 没有关系。
1.2 为什么我优先选 Flutter 自绘而不是鸿蒙原生提示
很多人在鸿蒙上用 Flutter,会想着通过 MethodChannel 去调用鸿蒙的 promptAction.showToast 或者 ArkUI 的弹窗,让提示看起来“更原生”。这个思路没有错,但得区分场景。我的建议是:应用内部的提示一律用 Flutter 自绘组件,系统级提示才考虑走原生通道。
原因有三个:
- 跨端一致性和调试成本。Flutter 自绘组件在 Android、iOS、鸿蒙上表现一致,你只需维护一套代码。走原生通道,意味着每个平台都要写一套原生逻辑,而且 Flutter 和鸿蒙之间的异步通信是有延时的,弹窗多了以后容易出现事件丢失、响应顺序错乱。
- UI 灵活度和动画控制。Flutter 自绘的 Dialog、SnackBar 可以完全自定义样式,圆角、阴影、入场动画都能精确控制。鸿蒙原生弹窗虽然能做基础样式,但想实现和 Flutter 完全一样的动画曲线,难度直接往上翻好几倍。
- 性能和稳定性。频繁弹 Toast 时,原生通道每次都需要 Dart -> Native -> 原生 UI 渲染,一路序列化和线程切换,损耗不小。自绘组件直接在 Flutter 渲染线程完成,卡顿概率更低。
我踩过一个典型坑:一开始为了“让鸿蒙用户觉得原生”,所有 Toast 都走了原生通道。后来用户反馈弹窗有时候延迟 200 毫秒才出现,在弱网环境下甚至弹了两次。排查半天发现是原生通道在页面销毁时回调丢失,后来改成 Flutter 自绘 Overlay,问题立刻消失。所以,除非你要弹出的是“应用外”的系统级提示(比如后台下载完成、来电提醒),否则没必要碰原生。
2. 核心组件逐个拆解:属性、事件与适配细节
2.1 SnackBar:最常用的轻量提示
SnackBar 是 Flutter 里最接近“Toast 升级版”的组件,也是我用得最多的。它自带一个可以点击的 Action,非常适合处理“操作完成 + 撤销”这种场景。基础用法很简单:
final snackBar = SnackBar( content: const Text('文件已删除'), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 3), action: SnackBarAction( label: '撤销', onPressed: () { // 执行撤销逻辑 }, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ); ScaffoldMessenger.of(context).showSnackBar(snackBar);这里有一个关键点:不要用 Scaffold.of(context).showSnackBar,而要用 ScaffoldMessenger.of(context).showSnackBar。区别在于 ScaffoldMessenger 是根级组件,它管理的 SnackBar 不依赖某个具体页面是否还在栈里。你在 A 页面弹了一个 SnackBar,马上 Navigator.push 到 B 页面,SnackBar 依然会正常显示。这在鸿蒙上特别重要,因为鸿蒙的手势返回和页面切换频率比 Android 还高,稍不注意 SnackBar 就卡在“半路”。
另外,SnackBar 有队列机制。连续调用多次 showSnackBar,默认会一个一个按顺序展示,不会覆盖。如果我想只保留最后一条,可以用clearSnackBars()或者hideCurrentSnackBar()。我一般这样处理:
final messenger = ScaffoldMessenger.of(context); messenger ..clearSnackBars() ..showSnackBar(snackBar);在鸿蒙真机上,底部如果有手势导航条,SnackBar 的 floating 模式可能会和系统手势条重叠。解决办法是给 SnackBar 设置margin,或者在Scaffold的 bottomNavigationBar 位置上预留 SafeArea。直接贴一个适合鸿蒙的方案:
SnackBar( behavior: SnackBarBehavior.floating, margin: EdgeInsets.only( left: 16, right: 16, bottom: MediaQuery.of(context).padding.bottom + 16, ), ... )2.2 Dialog 与 AlertDialog:需要用户决策时的标准答案
弹窗类组件是交互提示里最容易被过度设计的。我的原则是:只有需要用户停下当前操作、做出明确选择的场景才用 Dialog;普通信息用 SnackBar 或 Toast 就够了。Flutter 的 showDialog 是一个顶层函数,配合 AlertDialog 使用:
Future<bool?> showConfirmDialog(BuildContext context) { return showDialog<bool>( context: context, barrierDismissible: false, // 点击遮罩不关闭,防止误触 builder: (context) { return AlertDialog( title: const Text('清除缓存?'), content: const Text('此操作会删除所有临时下载的记录,不可恢复。'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('确认清除'), ), ], ); }, ); }这里返回true/false给调用方,让页面决定后续动作,而不是在弹窗内部直接执行网络请求或者跳转,这是组件职责分离的基本功。在鸿蒙上,有几个细节需要注意:
- 背景遮罩的阻尼感:鸿蒙上的 Dialog 动画默认比较“直接”,Flutter 的 Dialog 自带 Fade + Scale 效果,在部分鸿蒙设备上会显得偏慢。可以通过
transitionDuration: Duration(milliseconds: 150)调短,但不要低于 80ms,否则眼睛还没反应过来就消失了。 - 圆角裁剪:如果你自定义了 Dialog 背景,记得加上
clipBehavior: Clip.antiAlias,否则圆角外面会出现一圈白色锯齿。这个问题在鸿蒙高分屏上尤其明显,因为字体缩放比例大。 - 内存泄漏:showDialog 返回的 Future 需要正常关闭。页面在加载数据时弹了一个 loading Dialog,然后用户拼命按返回键,可能造成 Navigator 弹出栈异常。稳妥做法是用一个
PopScope或者跟随页面生命周期关闭。
2.3 全局 Toast 与自定义 Overlay
Flutter 没有内置 Toast,最接近的是 SnackBar,但 SnackBar 依赖 ScaffoldMessenger,而且会自动添加一个底部条,不适合做“居中短文案”的轻提示。我自己的方案是直接用 Overlay 封装一个全局 Toast,这样不依赖任何页面结构,只要拿到 root context,任何地方都能弹。
完整实现大概是这个样子:
class Toast { static OverlayEntry? _entry; static void show(BuildContext context, String message) { hide(); _entry = OverlayEntry( builder: (_) => Positioned( top: MediaQuery.of(context).size.height * 0.5 - 40, left: 32, right: 32, child: IgnorePointer( child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.75), borderRadius: BorderRadius.circular(24), ), child: Text( message, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 14), ), ), ), ), ); Overlay.of(context).insert(_entry); Future.delayed(const Duration(seconds: 2), () => hide()); } static void hide() { _entry?.remove(); _entry = null; } }用的时候,从页面 context 调:
Toast.show(context, '提交成功');这段代码有几个经验和细节:
- OverlayEntry 必须插到 Overlay 里,而
Overlay.of(context)依赖 context 所在的位置。如果你在页面顶层拿到 context 去弹 Toast,页面 push 到新路由后,原 context 的 Overlay 被系统栈盖住,Toast 自然就看不到了。解决办法是全局维护一个根 context,或者在 App 根的MaterialAppbuilder 里注入一个 overlay context。后面章节我会讲具体怎么设计一个全局提示组件库。 - 给 Toast 容器包一层
IgnorePointer,是为了防止飘在屏幕中央的 Toast 拦截点击事件。这一点在鸿蒙上容易踩雷——如果不加,用户想点 Toast 下方的按钮,结果完全点不动。 - 连续显示多条 Toast,直接用新 Toast 替换旧 Toast,所以每次 show 之前先 hide。
2.4 横幅提示条与底部弹层
除了 SnackBar 和 Dialog,我还会在重要通知场景用横幅提示条,比如“切到后台联网失败”“电量低”,类似系统级通知条。Flutter 里可以直接用 MaterialBanner,也可以复用刚才的 Overlay 思路,做一个从顶部滑入的横幅。
如果使用 MaterialBanner,注意它默认是嵌在 Scaffold 内部的,会顶起上方内容。如果希望它悬浮在页面内容上面,我建议直接自定义 Overlay 来实现,代码和 Toast 类似,只是位置从居中改成顶部,并加一个 SlideTransition。下面给出适合鸿蒙设备的一个简化版顶部横幅:
void showTopBanner(BuildContext context, String message) { final overlay = Overlay.of(context); late OverlayEntry entry; entry = OverlayEntry( builder: (_) => Positioned( top: MediaQuery.of(context).padding.top, left: 0, right: 0, child: Material( color: Colors.amber, elevation: 4, child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ const Icon(Icons.warning_amber_rounded), const SizedBox(width: 12), Expanded(child: Text(message)), ], ), ), ), ), ), ); overlay.insert(entry); Future.delayed(const Duration(seconds: 3), entry.remove); }底部弹层则建议直接用showModalBottomSheet,它不仅自带从底部滑入效果,还支持拖拽关闭,是“用户从多个选项中选择一个”场景的最佳选择。在鸿蒙上,底部弹层的圆角默认是 Material 的 28,如果感觉和鸿蒙原生风格不够统一,可以自己包一层ClipRRect来调整圆角,但别在 showModalBottomSheet 里直接设置 shape 的同时又设置 backgroundColor 为透明,否则圆角会失效。
3. 鸿蒙适配实录:从工程创建到原生通道
3.1 创建支持鸿蒙的 Flutter 工程
目前我们要在鸿蒙上跑 Flutter 应用,通常需要两个前提:一是鸿蒙系统的开发环境,二是适配 OpenHarmony 的 Flutter SDK 分支。整个流程和常规 Flutter 项目创建没有本质区别,核心步骤是:
- 下载并解压适配鸿蒙的 Flutter SDK,建议从官方或可信社区渠道获取,不要随便在第三方网盘找,省得编译到一半发现版本不对。
- 配置环境变量:
FLUTTER_HOME指向该 SDK,并把bin目录加到 PATH。 - 用命令行创建项目:
flutter create --project-name app_feedback my_app。 - 因为鸿蒙平台不是 Flutter 官方默认支持的 target,所以需要额外添加鸿蒙平台目录。一般通过工具或手动添加
harmony目录,内部包含AppScope、entry/src/main/ets等结构。 - 在鸿蒙工程中引入 Flutter 引擎的依赖,把
flutter_asset目录配置到 build profile 里。
如果是从 Android Studio 起步,创建好 Flutter 项目后,再在鸿蒙开发工具(DevEco Studio)里打开harmony目录,同步依赖后就能编译。这个流程最初看起来有点绕,但只要你理解了“Flutter 引擎在鸿蒙上是一个 lib 库,鸿蒙应用通过入口把 FlutterView 嵌进去”这个概念,后面所有问题都能顺理成章地排查。
3.2 使用 MethodChannel 调起鸿蒙原生提示
虽然前面我说应用内提示尽量自绘,但有些场景绕不开原生通道,比如:
- 应用退到后台时,需要弹出系统通知条。
- 需要调用鸿蒙特有的提醒能力,比如借助系统 UI 展示的权限弹窗。
- 原生层收到了某些系统广播(比如网络切换、存储空间不足),想要主动通知 Flutter 层。
Flutter 侧写法很标准:
class NativeNotifier { static const _channel = MethodChannel('com.example.app/notifier'); static Future<void> showSystemNotification(String message) async { await _channel.invokeMethod('showNotification', {'message': message}); } }鸿蒙端的实现思路,是在鸿蒙工程的 EntryAbility 或特定 UIAbility 里注册这个通道。ArkTS 侧大致逻辑:
import { rpc } from '@kit.IPCKit'; const channel = rpc.MethodChannel('com.example.app/notifier'); channel.setMethodCallHandler((call) => { if (call.method === 'showNotification') { // 调用 promptAction 的能力,给出原生提示 promptAction.showToast({ message: call.arguments['message'] }); } });这段代码只是一个示意,不同 Flutter 鸿蒙适配分支的 API 包名会有差异,但核心是抓住“Channel 注册 -> 解析 method -> 调用原生能力”这条链路。
另外,如果是原生层持续往 Flutter 抛事件,比如“播放状态变化时,前端要弹一个提示条”,优先用 EventChannel,而不是每轮都用 MethodChannel 去轮询。EventChannel 适合单向、持续的事件流,交互提示组件里用到的典型场景是原生连接的蓝牙设备状态变化、下载进度变化,然后触发一个提示更新。
3.3 Impeller 渲染引擎对提示动画的影响
Flutter 新一代渲染引擎 Impeller 在 Android 和 iOS 上已经普遍成为默认后端,鸿蒙适配分支也在跟随。它最大的变化是提前编译 shader,减少了动画首帧的卡顿。直观感受就是 Dialog 打开动画、SnackBar 滑动动作变得非常跟手,不像 Skia 老引擎那样偶尔有一个 sharp 的抖动。
但我也遇到一个反向问题:在部分鸿蒙设备的模拟器上,开启 Impeller 之后,Overlay 里的圆角描边和阴影出现渲染异常。后来排查发现是模拟器的图形驱动对 Vulkan 支持不完整。这种情况下,可以在项目启动时关闭 Impeller 再跑一遍:
flutter run --no-enable-impeller如果项目里确实需要这个参数,记得在构建脚本和真机调试命令之间做好区分。我自己的结论是:真机优先开 Impeller,模拟器优先关 Impeller,交互提示组件对动画流畅度敏感,但比起炫酷动画,更重要的是不要崩溃和不走样。
4. 一个可直接抄作业的全局提示组件库
4.1 项目结构与 Dart 的 part 拆分
当你开始从“单个页面弹 Toast”升级到“全局统一管理提示”时,代码组织就成了重头戏。我建议把提示组件做成一个独立的库,放在lib/feedback/下面:
lib/ ├── feedback/ │ ├── app_feedback.dart │ ├── app_feedback_toast.dart │ ├── app_feedback_banner.dart │ ├── app_feedback_dialog.dart │ └── app_feedback_queue.dart └── main.dart其中app_feedback.dart作为入口,对外只暴露一个统一的类,比如AppFeedback.showToast、AppFeedback.showDialog。这里有一个 Dart 语言细节比较值得聊:part和import的区别。
很多人容易把part当成简单“把文件拆分”的方式,但要注意,part拆分出来的文件属于同一个库,可以互相访问私有成员,但必须在主文件里用part 'xxx.dart';声明。比如我这样写:
// app_feedback.dart library app_feedback; part 'app_feedback_toast.dart'; part 'app_feedback_banner.dart'; part 'app_feedback_dialog.dart'; class AppFeedback { static void showToast(BuildContext context, String message) => showAppToast(context, message); static void showBanner(BuildContext context, String message) => showAppBanner(context, message); }被 part 进来的文件里,直接用顶层函数或私有类:
// app_feedback_toast.dart part of app_feedback; void showAppToast(BuildContext context, String message) { // 内部实现 }这样做的优点是外部只关心AppFeedback这个入口,内部实现随便拆,私有变量也可以互相共享。缺点是part不能让编辑器做很好的模块化提示,甚至很多格式化工具会把 part 文件认成一个部分。所以在小项目中别滥用,像我这样拆出三四个文件已经足够,再多就建议用 Dart 的 export 组合库。
4.2 用 Cubit 管理提示消息队列
提示组件如果只是一个一个独立弹窗,逻辑很简单。但真实业务里经常出现这种情况:用户连续点了几次保存按钮,网络响应回来后一下子冒出五六个提示,弹窗叠弹窗。这时候需要一个消息队列,我推荐用 flutter_bloc 的 Cubit 来做,理由很直接:它比 StreamController 更可控,比 Bloc 少了很多模板,很适合“接收事件 -> 依次弹出”这种逻辑。
简单实现如下:
class FeedbackCubit extends Cubit<FeedbackQueueState> { FeedbackCubit() : super(FeedbackQueueState.empty()); static final _queue = Queue<AppFeedbackItem>(); void push(AppFeedbackItem item) { _queue.add(item); _process(); } void _process() { if (_queue.isEmpty || state.isShowing) return; final item = _queue.removeFirst(); emit(state.copyWith(isShowing: true, currentItem: item)); // 实际弹出提示,等动画结束或用户关闭后,回调 _finish() } void _finish() { emit(FeedbackQueueState.empty()); _process(); } }每个AppFeedbackItem可以定义弹窗类型、文案、按钮回调、自动消失时长、重复 key。这么做的好处是,当用户连续触发错误提示时,不会出现“一条还没消失,另一条已经盖在上面”的情况。Flutter 的 SnackBar 虽然自带队列,但只对同一个 ScaffoldMessenger 有效,自定义 Toast、Dialog、Banner 混在一套队列里的时候,还是自己排更稳。鸿蒙上页面切换频繁,使用一个全局 Cubit 管理队列,可以保证从页面 A 调起的提示,在页面 B 弹出来也不奇怪。
4.3 全局 context 的获取与安全调用
全局提示组件库里最容易被忽略的,是 context 从哪来。你不可能在每个页面都传一遍 context,那样这个库做出来就失去了意义。我常用的方案是在根组件里挂一个全局navigatorKey:
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( navigatorKey: navigatorKey, home: HomePage(), ); } }然后在AppFeedback.showToast内部通过navigatorKey.currentState!.context获取全局 context。这个 context 指向的是 Navigator 的 Overlay,天然在 App 顶层,不受页面 push/pop 影响。这也就解决了前面提到的“切页后 Toast 消失”的问题。不过要注意,如果应用还没有执行 runApp,拿不到 currentState;在真正调用提示之前要做空判断,返回一个静默失败即可。
调用方式就可以非常干净,从任何页面、任何异步回调里直接:
await AppFeedback.showToast('数据加载完成'); await AppFeedback.showConfirmDialog('确定删除吗?', onConfirm: () async { ... });5. 高频问题与避坑清单
5.1 Navigator 切换页面后,提示状态怎么不被吞掉
很多 Flutter 开发者问过同一个问题:用 Overlay 实现的 Toast,在Navigator.push新页面之后看不到了,是不是 Overlay 状态丢了?其实不是状态丢失,而是你使用了“当前页面 context 对应的 Overlay”,它属于当前路由。当前路由被新路由覆盖后,其 Overlay 也被系统藏在栈底层,所以新的提示显示在“旧页面”上,自然看不见。
解决办法有三个:
- 使用全局
navigatorKey获取根 Overlay,所有提示都插在根 Overlay 上,不随页面切换消失。 - SnackBar 用
ScaffoldMessenger.of(context)而不是Scaffold.of(context),因为 ScaffoldMessenger 是 App 根级的,它会自动把 SnackBar 显示在当前的 Scaffold 上。 - 如果用了依赖注入的全局 Dialog 容器,确保它挂在
MaterialApp.builder或home的父节点上,不要挂在某个具体页面树里。
这一点在鸿蒙上尤其重要,因为鸿蒙返回手势很顺滑,用户频繁切页是常态。我就是一开始偷懒把 Toast 的 Overlay 绑到了首页的 context,结果从首页跳详情页再返回途中,点击按钮弹出的提示全部“聊胜于无”,最后改了全局 navigatorKey 才彻底解决。
5.2 TabBar 点击水波纹动画影响提示触发
这个问题属于交互提示里比较细枝末节的:如果页面使用 TabBar,用户点击 Tab 切换时,默认会有水波纹动画。当这个动画和 Overlay 上的 Banner 或 Toast 同时出现时,水波纹会把 Toast 的背景盖掉一层,看起来提示异常闪烁。更严重的是,如果自定义了 TabBar 监听手势,可能会在切换过程中误触发提示队列。
取消 TabBar 点击水波纹动画非常简单:
TabBar( splashFactory: NoSplash.splashFactory, tabs: const [ Tab(text: '首页'), Tab(text: '消息'), ], )如果用的是自定义 TabBar,推荐把所有点击处理都放在onTap回调里,不要在notificationListener里处理切换提示。同时,在TabController.addListener回调里不要直接弹全局提示,先判断当前 tab index 是否已经切换成功,否则用户快速左右滑动 Tab 时会出现提示刷屏。
5.3 鸿蒙网络请求异常导致的提示件失灵
在鸿蒙上调试 Flutter 应用时,最让人头疼的往往是网络层的问题,比如错误码 2300056。现象很典型:同样一段代码在 Android 上请求正常,鸿蒙上却直接抛出这个错误。它不是 Flutter 层能捕获的业务异常,而是鸿蒙网络库的调用错误,常见根因包括:
- 应用没有配置网络权限,或 Android 的
AndroidManifest.xml有权限但鸿蒙的 module.json5 缺了ohos.permission.INTERNET。 - 使用了不受信任的自签名证书,鸿蒙默认的安全校验策略比 Android 更严格。
- 域名没有在后台配置网络白名单,被安全组件拦截。
这类问题如果处理不好,你在 Flutter 侧写的 Toast 提示只会显示一条“请求失败”,根本定位不到是网络问题。我的处理方式是:在网络工具类里统一捕获错误码,如果错误包含2300056,就在提示组件里显示“请检查当前网络环境或稍后重试”,同时在 debug 模式下额外打印原始错误和错误码,而不是只提示通用文案。抓包工具在这时候很有用,能直接确认请求有没有发出去、对方返回了什么,不要只盯着 Flutter 层看。
5.4 打包构建时提示组件相关的断言错误
Flutter 在打包鸿蒙应用时,偶发遇到java.lang.AssertionError: java.lang.Exception: could not close ...这类构建断言错误。出现这个问题时,项目里的交互提示组件往往不是直接原因,而是某个插件在原生侧的资源没有正确发布。
我的排查顺序是:
- 先看是否是 R8 混淆把某个自定义
NotificationBar的类给吞了,尝试关闭混淆跑一次 release。 - 再检查 Flutter 插件和鸿蒙 SDK 的版本是否匹配,很多第三方插件的鸿蒙适配并不完善,打包时资源合并失败就会被错误信息误导到提示组件相关代码。
- 最后,如果代码里大量使用
part拆分文件,确认 part 声明的文件名和磁盘路径完全一致,大小写也不要忽略。Mac 上大小写不敏感容易放过问题,Windows/Linux 打包机上一跑就暴露。
说实话,这类问题没有万能药,只能靠解压构建产物、看 AGP 日志一步步剥。建议把构建日志输出到文件,不要只看终端最后二十行。我在解决一个 banner 相关崩溃时,就是因为只盯着最末行的 AssertionError,没发现在此之前原生产出物缺少一个banner_config.json资源文件。
5.5 模拟器与真机的提示表现差异
鸿蒙模拟器和真机在 Overlay 显示上存在细微差异。模拟器通常没有刘海屏和安全区,MediaQuery.padding.top为 0,导致顶部 Banner 在模拟器上看起来正常,但上了真机直接被时间状态栏盖住。所以我所有顶部提示组件,都会在Positioned的 top 上加上MediaQuery.of(context).padding.top,并且容器内部再用一个SafeArea(bottom: false)包一层。
真机调试时,我推荐使用鸿蒙的无线调试功能,把手机和电脑连同一个局域网,然后用 DevEco Studio 的无线调试工具连接。相比插 USB,无线调试更贴近真实用户场景,特别是测试“切到后台再回到前台”时提示组件是否会被系统回收。真机测试弹窗时,记得把“后台弹出界面”这个权限打开,否则系统级提示几乎一试一个不灵。
6. 写在最后的一句经验
交互提示组件看起来每一小块都很简单,真正把它们拼成系统性组件库的时候,坑点全在“时序”和“作用域”上。我个人的体会是,与其到处找各种高级动画组件,不如先把ScaffoldMessenger、Overlay、navigatorKey这三件事吃透。如果你在鸿蒙上连续遇到提示不显示、动画乱跳、状态错乱,回头检查一下是不是 context 选错了地方、队列逻辑没做好,很多问题能迎刃而解。这个组件库还可以继续扩展的方向,是把鸿蒙原生的来电通知、语音播报能力通过 EventChannel 接进来,做成一个同时覆盖 App 内外、轻量且统一的通知体系。