Flutter 在 OpenHarmony 上跑起来已经不是新闻了,但真正把某个业务功能做到位、做到能上线,又是一码事。这段时间我正好在搞一个基于 Flutter 的 OpenHarmony 应用,里面有个高频需求——全屏弹窗,用来做登录引导、活动弹窗、视频播放前的广告位。一开始我以为这玩意跟 Android 上一样,直接 showDialog 塞个全屏参数就行,结果真上手才发现,坑比想象中多。
这篇博文就把我在 OpenHarmony 上实现 Flutter 全屏弹窗的完整过程写出来,包括方案选型、环境准备、核心实现、问题排查和性能调优,适合已经在 OpenHarmony 上跑过 Flutter Demo、准备认真做业务的开发者。如果你是刚接触 Flutter 和 OpenHarmony 的小白,也可以照着走一遍,代码和思路我都会展开讲。
1. 全屏弹窗的需求拆解与方案选型
1.1 这个需求背后到底在解决什么问题
先说业务场景。我做的是一个工具类应用,OpenHarmony 端的用户量虽然不大,但留存和活跃指标一直有要求。产品那边提了一个需求:用户冷启动之后,要弹一个全屏的活动页,展示新人礼包,点击按钮跳转落地页,点空白区域或右上角关闭按钮能退出。这个需求在 Android 上很成熟,但在 OpenHarmony 上就涉及到两个核心问题:一是 Flutter 侧的页面栈和原生侧的页面栈怎么协同,二是“全屏”这个视觉概念在两个渲染体系里怎么统一。
全屏弹窗的核心诉求有三个:
- 覆盖到底:从状态栏顶部到系统导航栏底部,不能露出原来的页面边缘,也不能有白边。
- 独立导航:弹窗内部的按钮跳转不能影响 Flutter 的主导航栈,关闭时必须能精确回到弹窗打开前的页面。
- 沉浸式体验:弹窗背景可以是半透明遮罩,也可以是完全不透明的运营页,后者对渲染性能和内存要求更高。
如果只做 Flutter 层级的弹窗,用 Dialog 的 insetPadding 调成零就能做到视觉全屏,但在 OpenHarmony 上,Flutter 的页面渲染是在一个原生容器里进行的,这个容器本身可能带着系统的安全区域边距。你不处理这一层,弹窗就会出现上下黑边,或者盖不住状态栏。
1.2 三条实现路线,我为什么最终选了这条
我在动手之前梳理了三种方案:
路线一:纯 Flutter 层实现,通过 showGeneralDialog 或者 showDialog 加自定义 Route 实现全屏效果。 这种做法实现成本最低,复用性最高,全屏弹窗本质上就是一个全屏路由页面,转场动画可以自己定义。但在 OpenHarmony 上,它会受限于 Flutter 容器所在的原生页面大小,如果原生层设置了安全区域,Flutter 的 MediaQuery 就会拿到不准确的尺寸,导致弹窗底部被导航栏挡住。
路线二:Flutter 调用 OpenHarmony 原生能力,用原生弹窗控件渲染。 这种做法性能和系统集成度最好,但开发工作量直接翻倍。你需要写端侧代码,还要维护 Flutter 和原生两套 UI 逻辑,运营活动页迭代频繁,这种方案没法满足快速出稿的需求,人力和时间成本都撑不住。
路线三:以 Flutter 为主,通过 PlatformView 嵌入原生视图,或者用 Overlay 叠加 Flutter 组件,通过通道控制 Flutter 页面和原生导航栈的协调。 这种做法兼顾了开发效率和原生能力,但需要在技术细节上处理很多边界情况,比如弹窗的层级、原生返回键的事件分发、页面生命周期同步等。
我最终选择的是路线三,但做了精简:弹窗 UI 全部用 Flutter 组件绘制,通过 Overlay 的方式插入到 Flutter 的顶层,同时通过 MethodChannel 让 OpenHarmony 原生侧调整安全区域和系统导航栏状态。这样日常改版只需要改 Dart 代码,原生层只做环境适配和系统级交互,是当前团队规模和节奏下性价比最高的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工程搭建:先说清楚,再动手写代码
2.1 Flutter SDK 和 OpenHarmony SDK 的版本匹配
网上很多教程在讲 Flutter 安装,但到了 OpenHarmony 这边,版本匹配才是真正的分水岭。OpenHarmony 的 Flutter 适配分支由 OpenHarmony SIG 组维护,它跟上游 Flutter 的版本不完全同步。我用的是 OpenHarmony 的 ohos 分支,对应 Flutter 3.7.12 版本,这个组合在 rk3568 开发板上跑得最稳。
版本选定之后,环境变量也要单独配置。你本机可能装了标准 Flutter SDK,用于 Android 和 iOS 开发,同时还要装 OpenHarmony 的 Flutter SDK。两者不能共用同一套 flutter 命令,否则会出现依赖冲突。我是这样处理的:
bash复制# 标准 Flutter SDK 路径
export FLUTTER_STABLE_PATH=/opt/flutter
# OpenHarmony Flutter SDK 路径
export FLUTTER_OHOS_PATH=/opt/flutter_ohos
需要切换时,修改 PATH 环境变量即可。注意 OpenHarmony 分支的 Flutter SDK 下载下来之后,flutter doctor 可能显示部分检查不通过,这是正常的,因为它不需要检测 Android SDK 和 iOS 工具链。
提示:不要试图把两个 SDK 的 bin 目录同时加入 PATH,你一定会遇到版本混乱的问题,最后连 flutter pub get 都会报错。
2.2 创建支持 OpenHarmony 的 Flutter 工程
当 OpenHarmony 的 Flutter SDK 就绪之后,创建工程的命令跟标准 Flutter 工程略有不同。先进入 OpenHarmony SDK 路径下执行:
bash复制flutter create --org com.example --project-name fullscreen_dialog ohos_fullscreen
这里生成的工程结构里,ohos 目录是 OpenHarmony 的工程壳,需要用 DevEco Studio 打开并配置签名;lib 目录下的 Dart 代码是共享的。需要注意,OpenHarmony 的 Flutter 适配至少要求 API 9,建议直接用 API 10 或 API 11,不然部分系统能力接口会缺失。
工程创建之后,修改 ohos 工程里的 build-profile.json5,确保 signingConfigs 已经配置了你的开发者证书。没有签名的情况下,应用可以在模拟器上跑,但真机上装不了,推送和部分系统 API 也调不动。
2.3 设备树怎么选,别再纠结了
热搜词里有一条非常真实:openharmony 的 rk3568 有许多设备树到底咋选。我在这上面也卡了小半天。rk3568 开发板有各种厂家的定制版本,不同板子的屏幕分辨率、触摸芯片、传感器型号都不同,设备树选错最直观的问题就是屏幕不亮或者触摸没反应。
我的建议是分两步判断:
- 找开发板厂家提供的固件和内核源码,看它默认编译的是哪个 dts 文件,这个信息一般在出厂文档里有说明。
- 如果没有文档,把板子接上串口,开机日志里会打印
Kernel command line,里面通常会带root=...和dts相关的信息,能直接看到加载的设备树文件名。
以我手头的板子为例,它用的是 rk3568-evb1-ddr4-v10.dts 编译出来的固件,对应的 dtb 文件在 kernel/arch/arm64/boot/dts/rockchip/ 目录下。选错设备树不要慌,改一下引导配置重新打包即可,不会烧硬件。
3. 核心实现:全屏弹窗的完整代码拆解
3.1 先理清 Flutter 的层级结构:Overlay 才是大杀器
在 Flutter 里,不是只有 showDialog 才能做弹窗。Overlay 是 Flutter 框架里一个非常基础的组件,它负责管理所有需要悬浮在页面之上的组件,像 Tooltip、DropdownMenu、SnackBar 都依赖 Overlay。理解了 Overlay,你就能按需定制任意层级的弹窗逻辑。
全屏弹窗我用的是 Overlay 方案,原因是它有几个 Dialog 方案不具备的优势:
- 弹窗不需要修改 Navigator 的页面栈,避免与业务路由产生耦合。
- 可以灵活控制弹出的层级,比如同时存在多个弹窗实例时,可以控制谁在上、谁在下。
- 关闭时不需要
Navigator.pop,直接OverlayEntry.remove即可,更轻量。
创建 OverlayEntry 的代码如下:
dart复制OverlayEntry _createFullScreenEntry(Widget page) {
return OverlayEntry(
builder: (context) {
return FullScreenPage(
onClose: () {
_entry?.remove();
_entry = null;
},
child: page,
);
},
);
}
这里有个关键点:OverlayEntry.builder 里返回的组件必须具备 Directionality 上下文。如果直接弹出一个复杂的页面组件,会因为缺少本地化环境而报错。所以我在 FullScreenPage 内部主动用 MaterialApp 包了一层,确保每个弹窗内部有独立的主题和本地化上下文。
3.2 全屏尺寸计算:安全区域和状态栏不能靠猜
在全屏弹窗的实现里,最常见的坑就是"以为全屏就是 100% 宽高"。在 OpenHarmony 上,Flutter 的 MediaQuery 拿到的尺寸是容器尺寸,而容器本身受原生页面安全区域影响。如果你的 Flutter 页面嵌在一个原生 Fragment 里,而这个 Fragment 设置了 setAvoidArea,那么 Flutter 得到的最大尺寸就不是整个屏幕。
我的处理方案是在 Flutter 创建页面之前,通过通道让 OpenHarmony 原生侧把安全区域的信息传过来:
dart复制class SystemSafeInfo {
final double top;
final double bottom;
final double left;
final double right;
final double screenWidth;
final double screenHeight;
SystemSafeInfo({
required this.top,
required this.bottom,
required this.left,
required this.right,
required this.screenWidth,
required this.screenHeight,
});
}
然后在弹窗页面布局时做如下处理:
dart复制@override
Widget build(BuildContext context) {
final mediaQueryData = MediaQuery.of(context);
final topPadding = mediaQueryData.padding.top;
final bottomPadding = mediaQueryData.padding.bottom;
return Container(
width: double.infinity,
height: double.infinity,
color: Colors.black,
child: Padding(
padding: EdgeInsets.only(
top: topPadding,
bottom: bottomPadding,
),
child: _buildContent(),
),
);
}
这里的思路是:弹窗的整体背景铺满全屏,但内容区域避开安全区。如果你做的是半透明遮罩弹窗,遮罩铺满全屏没问题,内容依然要走安全区。如果是运营活动页,要求内容一直顶到状态栏背后,那就在 _buildContent 里针对顶部图像区域单独处理,让背景图延伸到状态栏后面,而关闭按钮放在安全区内部。
3.3 状态栏与导航栏控制:走通道搞定原生侧
纯 Flutter 无法直接控制系统状态栏的显示和样式,必须通过通道调原生。在 OpenHarmony 侧,我用的是 window 模块的能力。
Dart 侧的调用代码:
dart复制static const platformChannel = MethodChannel('com.example.fullscreen/window');
Future<void> enterFullScreen() async {
try {
await platformChannel.invokeMethod('enterFullScreen');
} on PlatformException catch (e) {
debugPrint('enterFullScreen failed: ${e.message}');
}
}
Future<void> exitFullScreen() async {
try {
await platformChannel.invokeMethod('exitFullScreen');
} on PlatformException catch (e) {
debugPrint('exitFullScreen failed: ${e.message}');
}
}
OpenHarmony 侧对应的代码写在 MainAbility 的 onWindowStageCreated 里注册通道:
typescript复制import window from '@ohos.window';
let windowStageGlobal: window.WindowStage | null = null;
export function registerFullScreenChannel() {
// 假设通过 abilityContext 或 windowStage 拿到了主窗口
const mainWindow = windowStageGlobal?.getMainWindowSync();
mainWindow?.setWindowLayoutFullScreen(true, (err) => {
if (err.code) {
console.error('setWindowLayoutFullScreen failed: ' + JSON.stringify(err));
}
});
}
这里要强调一个细节:沉浸式全屏状态和弹窗状态要联动控制。 弹窗关闭之后,必须把系统状态栏恢复成之前的样式,而且恢复逻辑要放到弹窗关闭动画完成之后,否则会出现状态栏图标一瞬间错乱的问题。我在实际项目中是通过在 FullScreenPage 的 dispose 里调用 exitFullScreen,同时加了一个 250ms 的延迟,等弹窗的退场动画跑完再恢复系统 UI。
3.4 业务弹窗的通用封装:参数化走天下
同一个全屏弹窗,可能在不同业务场景下复用。运营活动、协议确认、用户调研,虽然文案和背景图不同,但骨架一致。我写了一个通用组件 FullScreenDialogPage,通过构造参数来区分不同场景:
dart复制class FullScreenDialogPage extends StatelessWidget {
final String title;
final String content;
final String confirmText;
final String? backgroundImageUrl;
final VoidCallback? onConfirm;
const FullScreenDialogPage({
Key? key,
required this.title,
required this.content,
required this.confirmText,
this.backgroundImageUrl,
this.onConfirm,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
children: [
Positioned.fill(
child: backgroundImageUrl != null
? Image.network(
backgroundImageUrl!,
fit: BoxFit.cover,
)
: Container(color: Colors.white),
),
SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
ElevatedButton(
onPressed: () {
onConfirm?.call();
Navigator.of(context).pop();
},
child: Text(confirmText),
),
],
),
),
Positioned(
top: 10,
right: 10,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
],
),
);
}
}
注意,FullScreenDialogPage 本身是作为一个全屏页面来用的,所以需要配合 fullscreenDialog: true 的 MaterialPageRoute 来打开,这样系统手势返回会有"关闭"的语义,而不是"返回"。
使用方式:
dart复制void showFullScreenDialog(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
opaque: false,
fullscreenDialog: true,
pageBuilder: (context, animation, secondaryAnimation) {
return FullScreenDialogPage(
title: '新人礼包',
content: '注册即送 100 积分',
confirmText: '立即领取',
backgroundImageUrl: 'https://example.com/banner.png',
onConfirm: () {
// 跳转积分页
},
);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
),
);
}
这段代码里有两个细节值得展开说。第一是 opaque: false,这是让路由底部页面可见的关键。如果不设置,Flutter 默认 opaque 是 true,页面切换时会直接走不透明过渡,底部内容在推入动画期间是黑的。第二是 transitionsBuilder 可以自定义,我上面给了淡入淡出,实际项目中也可以改成从底部滑入,视觉体验差异很大。
3.5 与 OpenHarmony 原生页面的跳转协调
有些业务里,Flutter 全屏弹窗需要调到 OpenHarmony 的原生页面,比如打开系统设置页、拉起支付页面。这时候如果单纯在 Flutter 层用 Navigator 跳转是做不到的,必须通过通道转发给原生侧。
我的做法是定义一套事件协议:
dart复制class NativeRouter {
static const MethodChannel _channel = MethodChannel('com.example.fullscreen/router');
static Future<void> openSystemSettings() async {
await _channel.invokeMethod('openSystemSettings');
}
static Future<void> openPayPage(String orderId) async {
await _channel.invokeMethod('openPayPage', {'orderId': orderId});
}
}
原生侧接收之后,通过 windowStage.loadContent 或者 router.pushUrl 打开对应页面。这里要注意一个时序问题:Flutter 弹窗里点击跳转支付,原生页面弹出后,Flutter 弹窗要自动关闭,不然用户从支付页返回时,会发现弹窗还挂在那边,体验非常割裂。
我的处理方式是:在 Flutter 侧调用原生路由之前,先关闭弹窗,再发跳转请求。这样用户的视觉感知是"点击按钮 -> 弹窗消失 -> 支付页出现",中间没有重叠窗口的闪烁感。
4. 常见问题与排查技巧实录
4.1 弹窗底部被导航栏遮挡,怎么定位
这个问题很隐蔽。表现是弹窗内容区域刚好被系统导航栏盖住大概几十像素,有时只在手势导航模式下出现,三键导航模式下正常。排查思路分三步:
第一步,打印 MediaQuery.of(context).size 和 View.of(context).physicalSize,对比是否一致。如果不一致,说明 Flutter 容器尺寸被限制了。
第二步,检查 OpenHarmony 侧 MainAbility 的 setWindowLayoutFullScreen 是否在弹窗打开前生效。如果只是弹窗打开时调用了全屏,但 Flutter 布局发生在调用之前,那布局用的还是旧尺寸,就必须重新触发一次布局。我通常用:
dart复制Future<void> _rebuildAfterFullScreenChange() async {
await enterFullScreen();
await Future.delayed(const Duration(milliseconds: 50));
// 触发一次 MediaQuery 更新
setState(() {});
}
第三步,确认弹窗页面的根节点不要套 SafeArea,因为 SafeArea 只作用于内容,不会改变容器尺寸。要处理状态栏高度,用 MediaQuery.padding 手动控制。
4.2 原生返回键与 Flutter 手势返回的死循环
OpenHarmony 的返回键操作会先传给原生壳,再由原生壳转发给 Flutter。如果弹窗是在 Flutter 侧用 Overlay 实现的,原生壳可能不知道 Flutter 已经弹了一个全屏页面,它会把返回事件继续传给 Flutter 的根 Navigator,结果弹窗没关,反而退出了整个应用。
解决方案是在原生侧监听返回键,先判断 Flutter 是否处于弹窗状态。我们可以用通道反向通知:
typescript复制import { BusinessError } from '@ohos.base';
import router from '@ohos.router';
// 监听系统返回键(在 MainAbility 或 EntryAbility 中处理)
onBackPressed() {
const isDialogShowing = windowStageGlobal?.getMainWindowSync()?.getWindowProperties().isLayoutFullScreen;
if (isDialogShowing) {
// 通知 Flutter 关闭弹窗
this.flutterEngine?.getAbility()?.getContext()?.getApplicationContext();
}
// 返回 true 表示消费掉事件
return true;
}
实际上 OpenHarmony 侧的返回键监听需要结合生命周期来处理,不同 API 版本写法差异较大。我的经验是:尽量让 Flutter 自己处理返回键,不要原生拦截。具体做法是在 Flutter 的弹窗内部用 PopScope 包裹,设置 canPop 为 false,然后在 onPopInvokedWithResult 里统一接管关闭逻辑。
dart复制PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
_closeDialog();
},
child: FullScreenDialogContent(),
)
这样可以保证:无论用户按系统返回键还是 Flutter 内的关闭按钮,都会走同一套关闭逻辑,不会出现关闭一半的问题。
4.3 状态恢复:从后台回到前台弹窗位置漂移
OpenHarmony 应用切后台再回前台的时候,窗口尺寸可能因为系统 UI 变化而改变(比如状态栏收起又展开)。如果弹窗前缓存了尺寸,回来就会错位。我的做法是在弹窗内部监听 AppLifecycleListener,在应用从后台恢复时重新获取尺寸并触发布局更新:
dart复制late final AppLifecycleListener _listener;
void _initLifecycleListener() {
_listener = AppLifecycleListener(
onStateChange: (state) {
if (state == AppLifecycleState.resumed) {
_refreshSize();
}
},
);
}
_refreshSize 里做的就是重新读取 MediaQuery 并调用 setState。这个细节最容易忽略,但也最容易在测试时暴露,因为测试人员经常在切后台回来之后发现弹窗错位。
4.4 全屏弹窗卡顿排查
如果你的弹窗页面包含大尺寸网络图片、背景模糊效果,在 rk3568 这种中低端设备上很容易掉帧。排查手段有两个方向:
- 开启 Flutter 的性能浮层,
flutter run --profile,观察帧率和 CPU 占用。 - 重点检查图片加载是否做了缓存和降采样。网络图直接塞进
Image.network而不指定缓存宽度,很容易在解码阶段产生极高的内存峰值。
我的优化方案是:
dart复制Image.network(
url,
width: screenWidth,
height: screenHeight,
fit: BoxFit.cover,
cacheWidth: (screenWidth * 1.5).toInt(),
cacheHeight: (screenHeight * 1.5).toInt(),
)
cacheWidth 和 cacheHeight 可以强制图片以目标尺寸解码,大幅减少内存占用。另外,背景模糊不要在弹窗内的 Stack 里直接用 ImageFiltered,建议先用 dart:ui 对图片做一次下采样再模糊,否则每一帧就在做模糊计算,卡顿是必然的。
5. 性能与体验调优的细节
5.1 减少 build 次数,避免全屏页面无意义重建
全屏弹窗页面里如果有动画或定时器,很容易触发不必要的重建,进而导致性能问题。建议对弹窗内容做 const 优化,把不依赖状态的子组件提取出来。如果业务逻辑复杂,也可以用 ValueNotifier 替代 setState,只在需要变更的局部刷新组件。
我写的弹窗基础类里,把背景层和内容层分成两个独立的 Widget,各自通过 const 构造传入参数,父节点重建时子节点不会重建。这样运营人员在后台改文案时,只需要更新内容层,背景图不会跟着闪烁。
5.2 动画曲线与分层加载
全屏弹窗的打开动画不要太花哨,但也不能太生硬。我推荐使用 Curves.easeOutCubic,时间控制在 250ms 到 350ms 之间。动画时间太短显得突兀,太长会让用户觉得卡。关闭动画建议比打开动画稍快,250ms 以内。这个细节在视觉体验上很微妙,但确实会影响整体质感。
另一个技巧是弹窗内容的渐进加载。如果运营页依赖接口数据,可以先弹一个骨架屏,数据回来后再填充内容,而不是弹出一个页面等两三秒才显示数据。骨架屏用 Flutter 内置的 Container 加灰色渐变色即可,不需要额外引入 shimmer 库,减少依赖。
5.3 用分层让网络请求与弹窗解耦
全屏弹窗经常需要拉取活动配置,如果请求逻辑写在弹窗组件内部,那么每次打开弹窗都会重新请求,无法做缓存,也无法在弹窗关闭后取消请求。我的做法是把弹窗内容设计成纯展示组件,数据由上层页面拉取后传入。这样弹窗关闭后请求生命周期跟页面绑定,不会造成内存泄漏。
如果弹窗内容依赖异步数据,我会在打开弹窗之前先发起请求,拿到数据后再推入路由,避免在弹窗内部打转。这个模式也方便做统一的活动配置缓存,比如 5 分钟内不重复拉取。
dart复制Future<void> showActivityFullScreen(BuildContext context) async {
final config = await ActivityConfig.fetch();
if (!context.mounted) return;
// 这里 config 已经拿到,直接展示,不需要 loading。
Navigator.of(context).push(...);
}
5.4 内存泄漏排查与治理
OverlayEntry 如果忘记 remove,会导致弹窗残留且不销毁。排查方法是在弹窗关闭后,用 DevTools 的 Memory 面板抓一次 GC 前后的堆快照,看是否有大量同一个 Widget 的实例残留。另一个泄漏点来自 StreamSubscription 和定时器。在弹窗打开时注册的监听器,关闭时必须取消。我在弹窗组件的 dispose 方法里统一清理:
dart复制@override
void dispose() {
_listener.dispose();
_timer?.cancel();
super.dispose();
}
6. 最后再分享一个小技巧:让全屏弹窗支持多屏场景
OpenHarmony 设备有折叠屏和带扩展屏的场景,全屏弹窗这时候就要注意"全屏"的定义。折叠屏展开前后,窗口尺寸变化会触发 Activity 重建,Flutter 容器也跟着重建,弹窗如果没做状态保存,展开瞬间就消失了。稳妥的做法是打开弹窗时记录业务状态,在 onWindowSizeChange 回调里重新弹出并恢复状态。折叠屏的适配建议单独做一轮测试,不要在普通板子上测完就觉得完全没问题。
从工程角度看,这次实践让我最深的体会是:Flutter 在 OpenHarmony 上做全屏弹窗,技术栈本身没有太大难度,真正的挑战在于对这个新生态的理解——容器怎么管理、安全区怎么传递、原生返回键怎么协同。把这些边界问题摸透,后续再做分享、支付、广告等场景都会顺畅很多。
