直接在 OpenHarmony 上调试 Flutter 应用,再配合游戏助手这个具体场景做实战,其实是挺有代表性的一件事。
这个项目标题一看就知道:用 Flutter 做的一套跨平台 UI,跑在 OpenHarmony 设备上,具体业务是一个 PUBG 游戏助手 App。市面上聊 Flutter 的教程多,聊 OpenHarmony 的也多,但把两者结合起来,再落到“底部导航栏”这种每个 App 都会用到的骨架结构上,反而没有多少现成的、能直接照着敲的案例。
我这次就把自己在这个项目里的完整实操过程、踩过的坑、以及最终的实现方案整理出来。如果你正好在搞 OpenHarmony 应用开发,或者想把 Flutter 技能迁移到鸿蒙生态里,这篇内容应该能帮你省下不少绕弯的时间。
1. 为什么用 Flutter 做 OpenHarmony 应用,以及底部导航栏的选型思路
先说清楚一个很多人容易搞混的点:OpenHarmony 虽然和 Android 都是基于 Linux 内核的移动操作系统,但它们的应用层 SDK、组件体系和编译打包方式完全不同。你不能把 APK 直接丢到 OpenHarmony 设备上跑,也不能把 Android 的 Flutter 插件随便拿来用。不过好消息是,Flutter 的跨平台渲染引擎本身已经适配了 OpenHarmony 的 UI 框架,所以用 Flutter 写一套逻辑和 UI,再通过 OpenHarmony 的 SDK 打包成 HAP(HarmonyOS Ability Package),是可行的。
我当时选择 Flutter 而不是 ArkTS 原生写这个 PUBG 游戏助手,有几个比较实际的原因:
- Flutter 的热重载(Hot Reload)开发效率确实高。改个颜色、调个间距、微调导航栏图标,直接在模拟器上秒级刷新,比原生 ArkTS 改完重新编译要顺畅不少。
- 这套 UI 代码以后如果还想出 Android 或 iOS 版本,可以直接复用。游戏助手这类工具 App,生命周期很长,跨平台意味着后期维护成本能降低不少。
- Flutter 的 Material 组件体系特别成熟,底部导航栏这种标准交互组件,有现成的控件可以快速集成。
再说底部导航栏的选型。在 Flutter 里做底部导航栏,基本有三个方案:BottomNavigationBar、NavigationBar(Material 3 风格)和CupertinoTabBar(iOS 风格)。我最终用的是NavigationBar,因为它在 Material 3 下的观感更现代、更扁平,配合游戏助手这种偏年轻化的 UI 风格很合适。而且它有一个非常关键的优点——和IndexedStack搭配使用时,能保持每个 Tab 页面的状态,避免每次切换都重新加载页面数据。
这个项目的整体页面架构我规划为四个 Tab:
- 首页:游戏资讯流,最新赛事、皮肤活动、更新公告
- 战绩:查询最近对局数据,KDA、伤害、存活时间统计
- 武器库:枪械属性、配件推荐、弹道图
- 我的:个人设置、绑定游戏账号、消息通知
四 Tab 结构是一个工具类 App 最标准的骨架,既不会因为 Tab 太多导致底部拥挤,也不会因为太少显得功能单薄。下面重点拆解实现过程。
2. 环境准备:Flutter SDK 与 OpenHarmony SDK 的协同配置
实操之前先把环境搭对,否则后面全部白费。这个环节我踩了不少坑,尤其是 OpenHarmony SDK 和 Flutter 版本的匹配问题,单独拎出来详细说。
2.1 下载 OpenHarmony SDK 并配置环境变量
首先你要有一个 OpenHarmony SDK,它负责把 Dart 代码编译成能跑在 OpenHarmony 设备上的产物。我这里用的是某厂商基于 OpenHarmony 的发行版 SDK,版本是 5.0.0 系列。
安装位置随意,比如D:\ohos-sdk。关键是要把 SDK 里的工具链路径配置到系统环境变量里,否则后面执行构建命令时会直接报“找不到 hdc”或“找不到 ohos”之类的错误。
需要添加的环境变量大致如下:
OHOS_SDK_HOME=D:\ohos-sdk DEVECO_SDK_HOME=D:\ohos-sdk PATH=%OHOS_SDK_HOME%\toolchains PATH=%OHOS_SDK_HOME%\hdc PATH=%OHOS_SDK_HOME%\ets这里尤其注意hdc是 OpenHarmony 的设备调试工具,对应 Android 里的adb。真机安装、日志抓取全靠它。
2.2 安装 Flutter SDK 的 OpenHarmony 分支
官方 Flutter SDK 默认是不直接支持 OpenHarmony 的,需要切换到社区维护的 OpenHarmony 分支。这一步因为网络原因容易出问题,稳妥的做法是把仓库克隆到本地再用git checkout切换分支。
我用的分支是flutter_3.7.12_ohos,这是一个比较稳定的版本,对应 OpenHarmony 5.0 的适配。切换完分支后,记得运行一次:
flutter doctor如果环境配置正确,在输出里应该能看到 OpenHarmony 的条目被识别到。如果没有,多半是OHOS_SDK_HOME路径没有生效,重新检查环境变量。
2.3 创建项目和初始化骨架
创建一个 Flutter 工程并用 OpenHarmony 平台初始化:
flutter create --platforms ohos pubg_assistant cd pubg_assistant flutter pub get--platforms ohos这个参数很关键,它会为项目生成ohos目录,目录里有 OpenHarmony 的应用配置文件module.json5、应用图标等原生工程资源。
初始化完成后,项目目录结构大概是这样:
pubg_assistant/ ├── lib/ │ ├── main.dart │ ├── pages/ │ ├── models/ │ └── services/ ├── ohos/ │ ├── entry/ │ │ └── src/main/ │ │ ├── ets/ │ │ ├── resources/ │ │ └── module.json5 ├── android/ ├── ios/ └── pubspec.yaml到这里,开发环境就算基本就绪了。接下来进入正题:底部导航栏的实现。
3. 底部导航栏核心实现:从页面骨架到引擎代码
底部导航栏本身不难,难的是围绕它组织好页面状态和应用架构。这部分我会给出完整代码,并逐个解释关键点。
3.1 定义四个主页面与统一 State 持有者
我先定义了一个MainTab枚举,用来统一管理四个 Tab 的索引、标题和图标:
enum MainTab { home(0, '首页', Icons.home_outlined, Icons.home), stats(1, '战绩', Icons.insights_outlined, Icons.insights), arsenal(2, '武器库', Icons.gps_fixed_outlined, Icons.gps_fixed), profile(3, '我的', Icons.person_outline, Icons.person); const MainTab(this.index, this.title, this.icon, this.activeIcon); final int index; final String title; final IconData icon; final IconData activeIcon; }这样设计的好处是,后续如果要在某个 Tab 上做角标、做红点提醒,可以直接在这个枚举里扩展字段,不会污染页面代码。
3.2 主框架代码:NavigationBar + IndexedStack
主页面我命名为MainShell,承接整个 App 的骨架。关键代码如下:
class MainShell extends StatefulWidget { const MainShell({super.key}); @override State<MainShell> createState() => _MainShellState(); } class _MainShellState extends State<MainShell> { int _currentIndex = 0; static const List<Widget> _pages = [ HomePage(), StatsPage(), ArsenalPage(), ProfilePage(), ]; @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: _currentIndex, children: _pages, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, onDestinationSelected: (index) { setState(() { _currentIndex = index; }); }, destinations: [ for (var tab in MainTab.values) NavigationDestination( icon: Icon(tab.icon), selectedIcon: Icon(tab.activeIcon), label: tab.title, ), ], ), ); } }这里有几个地方值得单独展开:
IndexedStack的index参数会控制当前显示哪个子页面,但所有子页面都会被构建并保留在 Widget 树中。这意味着,当你在“战绩”页往下滚动到一半,切到“武器库”再切回来时,“战绩”页的滚动位置、筛选条件、搜索关键字全部还在,不会丢失。
NavigationBar是 Material 3 的组件,默认自带一个指示器动画,选中的图标和文字会有轻微的切换效果。如果你想要更硬核的游戏风格,可以再包一层NavigationBarTheme调整指示器颜色、图标大小等参数。
3.3 首页与战绩页的真实内容:让骨架“有肉”
底部导航栏只是一个骨架,真正让 App 有价值的是每个 Tab 下的内容。这里以“战绩”页为例,写一个对局列表,让读者感受一下整体业务如何挂载到这个骨架上:
class StatsPage extends StatelessWidget { const StatsPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('最近战绩'), centerTitle: true, ), body: ListView.builder( itemCount: 20, itemBuilder: (context, index) { return Card( child: ListTile( leading: CircleAvatar( child: Icon(index % 2 == 0 ? Icons.emoji_events : Icons.close), ), title: Text('对局 ${index + 1}'), subtitle: Text('排名第${index + 5}名 · 击杀${index + 2}人 · 存活${12 + index}分钟'), trailing: index % 2 == 0 ? const Text('胜利', style: TextStyle(color: Colors.orange)) : const Text('失败', style: TextStyle(color: Colors.grey)), ), ); }, ), ); } }这个页面的数据目前是本地模拟的,后续接入真实的战绩 API 只需要把ListView.builder的数据源替换为从网络拉取的数据模型即可。IndexedStack保证了你在这个页面滚动、筛选的状态在 Tab 切换后不丢失,这一点在真实开发里体验差异非常大。
3.4 底部导航栏样式的进阶调整
游戏助手类 App 通常希望 UI 更有个性,不会满足于默认的 Material 3 样式。我当时做了一版暗黑主题,视觉效果更好,同时避免了纯白背景在游戏场景下看着太刺眼。
NavigationBarTheme( data: NavigationBarThemeData( backgroundColor: const Color(0xFF1A1A2E), indicatorColor: const Color(0xFFE94560), iconTheme: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return const IconThemeData(color: Colors.white, size: 26); } return const IconThemeData(color: Colors.white54, size: 22); }), labelTextStyle: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600); } return const TextStyle(color: Colors.white54, fontSize: 12); }), ), child: NavigationBar(...), )WidgetStateProperty.resolveWith这个 API 可以让你根据组件的状态动态返回不同的样式。选中时图标变大、颜色变亮,未选中时压暗,这种微妙的交互反馈能明显提升 App 的品质感。
4. 状态保持与页面切换的机制深度解析
底部导航栏代码看着简单,但如果你只用switch或者PageView切换页面,会出现很多隐性问题。我单独用一章讲讲为什么IndexedStack是底部导航栏的最佳搭档,以及在 OpenHarmony 上这些机制有什么不同。
4.1 IndexedStack 如何保持页面状态
上面代码中,_pages使用了static const列表,这保证了四个页面只在第一次构建时被创建一次。IndexedStack会把所有子 Widget 都挂在渲染树上,只是通过index控制哪一层可见。
用生活里的类比:四个页面就像四个房间,IndexedStack是一个只有一扇展示窗的房子。你每次切换只是把展示窗移到另一个房间门口,房间里堆的东西(页面状态)一直都在。而如果用普通的条件切换,相当于参观完一个房间就把里面的东西全部清空,再进下一个房间时得重新布置,这就是状态丢失的本质。
// 这是反例:切换 Tab 后页面状态会全部重置 body: _pages[_currentIndex], // 这是正例:状态保留 body: IndexedStack( index: _currentIndex, children: _pages, ),这个差异在“武器库”页特别明显。武器库里有枪械分类筛选、配件选择、弹道图缩放等一堆交互状态,如果切走再切回来全部重置,用户体验会很崩溃。
4.2 生命周期回调在 OpenHarmony 上的差异
在 OpenHarmony 环境里,Flutter 页面的生命周期事件和原生 Android 有微调。最典型的是应用退到后台时,不会立刻触发AppLifecycleState.paused,而是要经过 OpenHarmony 自己的 UIAbility 生命周期流转,再到 Flutter 层。
我的处理办法是在主入口监听生命周期事件,并做相应处理:
class MyApp extends StatefulWidget { const MyApp({super.key}); @override State<MyApp> createState() => _MyAppState(); } class _MyAppState extends State<MyApp> with WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // OpenHarmony 后台/前台切换 if (state == AppLifecycleState.resumed) { // 重新加载比赛数据 } else if (state == AppLifecycleState.paused) { // 保存草稿、清缓存 } } @override Widget build(BuildContext context) { return MaterialApp( title: 'PUBG 助手', theme: ThemeData.dark(), home: const MainShell(), ); } }实测下来,大部分原生生命周期逻辑在 OpenHarmony 上都能正常工作,但测试阶段要重点验证退后台再回来时,页面是否会发生不必要的重建。如果发现重建了,优先检查是不是入口配置了错误的 UIAbility 冷启动模式。
4.3 性能优化:避免不必要的重绘
底部导航栏每次切换,setState会触发整个MainShell的build,但IndexedStack已经帮我们隔离了子页面的重建开销。不过如果你的某个子页面内部有很多图片或复杂动画,仍然可能造成帧率波动。
几个我实测有效的优化点:
- 子页面内部有网络请求时,使用
FutureBuilder并配合缓存,避免来回切换时重复请求。 - 图片资源统一走图片缓存库,不要每次从磁盘重新加载。
- 武器库中的弹道图如果是用
CustomPaint绘制的,把绘图逻辑放在RepaintBoundary内部,避免和其他 UI 共用渲染层。
5. 底部导航栏常见问题排查实录
以下是这个项目里真实遇到过的几个问题。每个问题都是我花了不少时间定位才解决的,直接整理成速查表,方便你照着排查。
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| Tab 切换后页面重新加载 | 没有使用IndexedStack,直接切换children[_currentIndex] | 把 body 改为IndexedStack,保持所有页面常驻 Widget 树 |
| 底部导航栏图标不显示 | IconData 在 OpenHarmony 字体库缺失,或图标包加载失败 | 检查 Material Icons 字体是否成功打包,改为本地 iconfont 或者改用 SVG 图标方案 |
| 真机安装 HAP 后点击图标闪退 | OpenHarmony 工程里的module.json5配置错误,导致 UIAbility 无法启动 | 检查abilities里的mainElement路径是否指向正确入口,确认type为entry |
| 切换 Tab 时出现短暂白屏 | IndexedStack子页面首次构建时执行了耗时操作(数据库读取、网络请求) | 把耗时操作移到initState后的异步加载中,或增加骨架屏占位 |
| 返回键直接退出 App、没有回到首页 | 底部导航栏页面没有处理系统返回键 | 使用PopScope拦截返回事件,当前不在第一个 Tab 时先切回首页 |
| 页面底部被系统导航条遮挡 | OpenHarmony 全面屏手势区域高度计算不一致 | 使用SafeArea包裹NavigationBar,或手动设置MediaQuery.padding.bottom偏移 |
其中“图标不显示”这个问题最容易踩。OpenHarmony 的 Flutter 分支对 Material Icons 字体的打包机制和 Android 不太一样,有时候图标会静默丢失。最快的排查方法是用Icon控件的小 Demo 直接在真机上跑,如果显示空白,基本就是字体资源没有随包分发。
“返回键处理”也有一个小细节。游戏助手类 App 的用户习惯是,在任一 Tab 下按返回键应该先切回首页,再按一次才退出 App。我这里用PopScope实现:
PopScope( canPop: _currentIndex == 0, onPopInvokedWithResult: (didPop, _) { if (didPop) return; setState(() { _currentIndex = 0; }); }, child: Scaffold(...), )这个逻辑很简单,但很实用。按返回键时,如果当前不是首页,直接回首页;是首页了,再按一次才退出。实测在 OpenHarmony 上返回键事件能正确冒泡到PopScope,不用做额外的平台兼容处理。
6. 构建 HAP 包并部署到 OpenHarmony 真机
项目和代码都写完了,最终要落到真机上跑起来,这才是 Flutter for OpenHarmony 的完整闭环。
6.1 生成 HAP 包的命令
在项目根目录执行构建命令。注意,这里不能用 Flutter 标准的flutter build apk,而是要走 OpenHarmony 的原生构建工具链:
cd ohos/entry hvigorw assembleHap --mode module -p product=default构建完成后,HAP 包会生成在ohos/entry/build/default/outputs/default/目录下。整个构建过程会先把 Flutter 的 Dart 代码编译成 native 库,再打包进 OpenHarmony 的应用壳里,所以耗时比普通的原生 App 构建要长一些,第一次构建可能需要几分钟,后续增量构建会快很多。
6.2 连接真机安装
用 hdc 连接 OpenHarmony 设备后,安装命令是:
hdc list targets hdc install ohos/entry/build/default/outputs/default/entry-default-signed.haphdc的体验和adb基本一致,只是命令名不同。如果设备没有正确识别,先检查hdc list targets是否能列出设备序列号,网络连接模式下还要保证电脑和设备在同一局域网。
6.3 OpenHarmony 权限声明
PUBG 游戏助手如果需要读取本地的游戏账号缓存、外设信息,或者在“我的”页面绑定设备,需要在module.json5里做权限声明。例如读取存储权限:
{ "name": "ohos.permission.READ_MEDIA", "reason": "读取本地对局截图", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }权限申请方式和 Android 的AndroidManifest.xml声明很像,但使用场景里多了usedScene这一段,用来声明权限在哪一个 Ability 里使用、是前台使用还是后台使用。如果漏掉这段,部分权限在运行时可能无法正常弹窗,造成功能不可用。
最后再分享一个小技巧
这个项目做完以后,我个人最大的一个体会是:不要只把 Flutter 当成 UI 框架来用,它在 OpenHarmony 上其实承担了完整的业务逻辑层职责。
我在“武器库”页做一个枪械弹道图的画布面板时,原本担心 Flutter 的 Canvas 在 OpenHarmony 上的兼容性会出问题,结果实测下来非常流畅。CustomPaint的绘制逻辑用的是 Skia 引擎,而 OpenHarmony 和 Flutter 之间已经打通了图形渲染这一层,所以只要 Flutter 代码里能正常绘制的效果,在 OpenHarmony 真机上基本都能还原。
另外,如果你也在做类似的项目,建议尽早把底部导航栏的骨架搭好。因为后续加页面、加功能、改交互,都会依赖这个骨架的稳定性。一旦骨架稳定了,后面的业务开发会顺很多。这个项目我前前后后改了三四版导航栏结构,最后定下的就是NavigationBar + IndexedStack这套方案,现在回看,当初如果再早一点定下来,能省掉不少返工的时间。