在OpenHarmony上用Flutter做数独游戏App,听起来有点小众,但恰恰是这个组合让我踩了不少有意思的坑。这篇文章就围绕"新游戏对话框"这个功能点展开,从需求拆解、UI设计、代码实现到鸿蒙平台适配,完整过一遍我的实战过程。如果你正准备用Flutter for OpenHarmony开发应用,或者想做一个带难度选择的数独游戏,这篇能帮你少走不少弯路。
先说结论:新游戏对话框看起来只是"弹个窗确认一下",真正落地的时候要处理的细节远比想象中多——棋盘状态怎么重置、难度如何同步、鸿蒙上Dialog层叠表现和Android有什么差异、以及Flutter侧的代码怎么组织才不会被后续功能挤爆。这些我都会展开讲。
1. 为什么要在OpenHarmony上做Flutter数独App
1.1 项目背景与方案选型
我最初接到这个项目诉求时,团队内部其实有过一段争论:是直接用ArkUI + ArkTS写原生应用,还是引入Flutter跨平台方案?毕竟OpenHarmony有自己的声明式UI框架,学习成本相对可控,而Flutter在鸿蒙生态里属于"外来户",需要依赖OpenHarmony的Flutter适配层才能跑起来。
最后拍板用Flutter for OpenHarmony,核心原因有三个:
第一,跨端复用诉求强烈。团队同时维护Android、iOS版本,数独游戏的业务逻辑和UI组件栈都是Flutter写的,如果鸿蒙单独用ArkTS重写,意味着要同时维护两套状态管理、两套主题、两套测试用例。而Flutter for OpenHarmony的适配思路比较接近"把Flutter引擎移植到鸿蒙",业务Dart代码几乎不用改,只需要处理少量平台差异即可。
第二,Flutter的计算密集型任务表现稳定。数独生成和求解涉及大量回溯算法,Flutter的Dart代码在AOT编译模式下性能足够好,引擎的渲染层在OpenHarmony上也有对应的适配实现。实际跑下来,生成一局中等难度的数独棋盘耗时在毫秒级,完全够用。
第三,团队已有Flutter积淀。与其从零学一套ArkTS,不如把既有能力平移到新平台。当然这也意味着要承受适配层带来的不确定性,这点后面我会专门讲。
1.2 Flutter for OpenHarmony的现状与局限
在正式写代码之前,你最好对Flutter for OpenHarmony的成熟度有一个清醒认识。OpenHarmony的Flutter适配并非官方主线,而是由开源社区和部分厂商在推动,通常会有独立的仓库和分支。这就带来几个实际问题:SDK版本跟官方Flutter不完全同步,部分Plugin(比如shared_preferences、path_provider)需要鸿蒙专用实现;PlatformView(嵌入原生视图)的兼容性有待验证;热重载在某些场景下会失效,需要手动重启App。
我在项目里用到的几个依赖,鸿蒙侧都有对应的适配版本,但都需要在pubspec里显式指定git地址而不是默认的pub.dev版本。比如路径处理我用了path_provider的OpenHarmony分支,本地存储用的shared_preferences同样走了鸿蒙分支。如果你的项目依赖较多,建议先列一个依赖清单,逐个确认是否有鸿蒙版本,否则编译期会卡在"找不到对应实现"这一步。
注意:Flutter for OpenHarmony的环境变量设置和普通Flutter工程不一样,需要同时配置OpenHarmony SDK路径和Flutter引擎路径,建议用官方提供的环境检测脚本先自检一遍,确认
ohpm、hdc、hvigor都能正常调用后再开始建工程。
小结一下:方案选型没有绝对的对错,关键要看你的团队技术栈、代码复用诉求和上线周期。对我来说,用Flutter for OpenHarmony做数独App是一个"前期配置繁琐、后期业务开发顺畅"的选择。
2. 新游戏对话框这个功能点怎么拆
2.1 先想清楚对话框要解决什么问题
"新游戏对话框"听起来很轻量,但如果只是简单弹一个AlertDialog,那这篇文章就没有写下去的必要了。我在设计这个功能时,先问了自己几个问题:
- 用户在什么时机触发"新游戏"?是菜单栏按钮,还是游戏结束后的提示?
- 点击新游戏后,当前进度怎么处理?要不要二次确认?
- 难度选择放在哪一层?是每次新建游戏都弹,还是只在首次进入时让用户选?
- 对话框关闭后,数独棋盘的状态管理怎么做清理?高亮、计时器、错误计数这些关联状态怎么重置?
一个合格的"新游戏对话框",不仅是一个UI弹窗,它还是游戏状态机里的一个关键节点。按我的设计,它承担了三件事:确认用户意图(防止误触丢进度)、收集难度参数(简单/中等/困难三档)、触发布局重建(点击确认后,整个棋盘、计时、提示次数全部归零)。
结合数独游戏的通用交互习惯,我把触发入口做成两个:一个是主界面右上角的菜单按钮,另一个是游戏结束结算页里的"再来一局"。这两个入口弹出的对话框内容完全一致,但传递的上下文参数不同——后者默认把难度锁定在当前局,前者允许用户重新选择难度。
2.2 对话框的UI结构设计
UI结构上,我没有直接用Flutter自带的AlertDialog,而是自定义了一个Dialog组件,主要原因有二:一是默认Dialog的样式太"Material"了,放在数独这种偏休闲的界面里显得有点生硬;二是自定义Dialog可以更好地控制背景层模糊、圆角、阴影和进出动画,在OpenHarmony上表现也更稳定。
我的对话框布局分三块:
- 标题区:显示"开始新游戏",副标题提示"当前进度将丢失,确定要继续吗?"。
- 难度选择区:三段式控件,简单、中等、困难,默认选中当前局难度。选中的选项有高亮边框和底色区分。
- 按钮区:取消和开始。开始按钮的颜色我用了主题色,用来强化"确认动作"的视觉权重。
这里有一个容易被忽略的细节:对话框弹出时,底层棋盘不应该再响应点击。Flutter的Dialog默认是模态的,会遮挡并拦截点击事件,但如果你用了showGeneralDialog且barrierDismissible设为true,点击遮罩层会直接关掉对话框。考虑到"防误触"的核心目标,我在新游戏确认弹窗里把barrierDismissible设成了false,用户只能通过按钮关闭,避免误点遮罩后连确认都没做就丢进度。
2.3 状态模型先行:不是只有UI
写UI之前我先把状态模型理顺了。数独游戏的核心状态包括:
- 当前盘面:9x9的二维数组,值为0表示空格
- 初始线索盘:记录哪些格子是开局固定的,这些格子不可修改
- 难度等级:1、2、3,分别对应不同数量的挖空
- 计时:从新游戏开始的秒数
- 错误计数:填错格子累计次数(通常是三次上限)
- 提示次数:可用的"提示"操作次数
新游戏对话框确认后,以上所有状态都要重置。我用的状态管理方案是provider+ChangeNotifier,把GameState作为全局单例注入到Widget树中。确认新游戏时,直接调用gameState.reset(difficulty),内部重新生成盘面并通知所有监听者刷新。
很多初学者容易犯的一个错误是:只在UI层清空格子,忘记重置计时器。结果就是"新开了一局但右上角计时还停留在01:23:45",非常尴尬。所以我在reset方法里会强制停止并重建计时器流,同时把错误计数、提示次数一并归零。
3. 核心代码实现:从弹窗到整局重开
3.1 数独盘面生成逻辑梳理
对话框只是入口,真正撑起"新游戏"三个字的是背后的盘面生成逻辑。我不会在这里贴完整实现,但会讲清楚关键路径,因为这直接决定你点击"开始"之后要等多久才有界面反馈。
数独生成通常分两步:先构造一个完整的终盘,再按难度挖空。构造终盘我用的是"随机填充 + 回溯"的方式,先按行随机填入1-9并校验行列宫约束,遇到冲突就回溯重试。这个过程在标准9x9棋盘上通常很快,配合随机打乱种子,能保证每次生成的终盘不一样。
挖空逻辑则更讲究:简单难度挖30-35个,中等挖40-45个,困难挖50-55个。挖空并不是随机挑格子清掉就行,每挖一个都要用求解器验证"当前局面是否有唯一解"。如果清空后出现多解,就要把格子填回去再换一个候选。这个校验非常吃计算,尤其是困难档位,如果求解器写得不够优化,用户点击"开始"后可能会卡顿半秒到一秒。
我的做法是:生成和校验放在计算函数里统一执行,只有在全部完成且通过唯一解校验后才把盘面写入状态并触发UI刷新。由于Dart在主线程跑,为了不让阻塞导致掉帧,我在实际工程里把生成过程放到了compute或Future里执行,生成期间弹窗先关闭,棋盘区域显示一个轻量的loading占位。
3.2 自定义"新游戏对话框"实现要点
下面是我在项目里实际使用的新游戏对话框核心代码,为了突出主干我做了裁剪:
class NewGameDialog extends StatefulWidget { final int currentDifficulty; const NewGameDialog({super.key, required this.currentDifficulty}); @override State<NewGameDialog> createState() => _NewGameDialogState(); } class _NewGameDialogState extends State<NewGameDialog> { int _selectedDifficulty = 1; @override void initState() { super.initState(); _selectedDifficulty = widget.currentDifficulty; } @override Widget build(BuildContext context) { return Dialog( backgroundColor: Colors.transparent, child: Container( width: 320, padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), boxShadow: const [ BoxShadow(color: Colors.black26, blurRadius: 16, offset: Offset(0, 8)) ], ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('开始新游戏', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), const Text('当前进度将丢失,确定要继续吗?', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)), const SizedBox(height: 20), _buildDifficultySelector(), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('取消'), ), FilledButton( onPressed: () { Navigator.of(context).pop( NewGameOption(difficulty: _selectedDifficulty), ); }, child: const Text('开始'), ), ], ), ], ), ), ); } }这段代码里有几个值得展开的点:
- 我先用
StatefulWidget把_selectedDifficulty保存在弹窗内部,这样用户在弹窗里切换难度不会外泄到GameState。 - 返回值用的是
Navigator.pop的一个参数,调用方在await showDialog之后拿到NewGameOption对象。这个对象是普通的数据类,里面只放一个difficulty字段,未来如果要扩展"自定义模式""随机种子"等字段,可以往里面加。 - 我把Dialog背景设为透明,然后在内部用Container自绘卡片样式,这样在OpenHarmony上也能保证阴影和圆角效果,不会受默认Dialog主题影响。
调用侧的代码也很简单:
Future<void> _handleNewGamePressed() async { final option = await showDialog<NewGameOption>( context: context, barrierDismissible: false, builder: (context) => NewGameDialog( currentDifficulty: _gameState.difficulty, ), ); if (option == null) return; await _busyOverlay.show(); await _gameState.reset(option.difficulty); if (mounted) { Navigator.of(context).popUntil((route) => route.isFirst); } await _busyOverlay.hide(); }popUntil((route) => route.isFirst)这行是踩坑后才加上的。因为游戏过程中用户可能会通过"暂停""排行榜"等入口推入次级页面,如果新游戏确认后不清理导航栈,用户会停留在旧的子页面里,体验非常割裂。统一返回到主页面再刷新盘面,是最稳妥的方式。
3.3 难度筛选在OpenHarmony上的表现优化
自定义难度选择器我用的是一个三选一的SegmentedButton变体。Flutter 3.x提供了SegmentedButton组件,样式比较贴近Material 3,但在OpenHarmony的Flutter适配层上,个别按钮点击时的涟漪动画有过渲染异常的情况。后来我干脆自己用ChoiceChip+AnimatedContainer实现了一个轻量版,点击态靠背景色和边框变化来表达,动画用AnimatedContainer自带的隐式动画,整体稳定性好了很多。
这里也给大家留一个建议:在Flutter for OpenHarmony上开发,不要过度依赖Material 3的新组件。适配层的开发节奏往往滞后于Flutter主线,一些新组件在鸿蒙环境可能出现样式缺失、动画卡顿甚至直接崩溃。用最基础的组件拼装,控制在最小必要依赖范围内,才是稳妥路线。
4. 踩过的坑与调试实录
4.1 Dialog在OpenHarmony上的显示层级问题
这是我第一次在OpenHarmony真机上跑新游戏对话框时遇到的最诡异问题。现象是:对话框正常弹出,但偶尔会被底部导航栏"压住"一小条,或者对话框的阴影区域出现闪烁。
排查后定位到原因:Flutter for OpenHarmony的视图层级是由FlutterView + OverlayEntry组合实现的,Dialog本质上挂在Overlay上。在特定版本的适配层里,Overlay的高度计算有时候没有跟随系统安全区域更新,导致底部被遮挡。解决方式是给对话框容器主动加上MediaQuery.of(context).viewInsets.bottom和viewPadding.bottom的适配,而不是依赖默认的安全区行为。
padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewPadding.bottom, ),加了这行之后,真机上就再没出现过底部遮挡问题。这类问题在Android上基本不会遇到,因为Android的Insets处理相对成熟,但在OpenHarmony上你必须假设它可能"不成熟",主动补安全区适配。
4.2 热重载失效后的调试策略
另一个让我记忆犹新的问题是:OpenHarmony适配层的热重载能力不稳定。前几次点击hot restart还能正常刷新,后面直接卡死在启动页,必须手动停掉App再重新安装。排查发现是引擎加载状态和Dart isolate在热重载后没有完全复位,尤其是涉及到自定义Dialog、动画这种有原生层交互的场景。
我的应对策略有三条:
- 先写逻辑后写UI:把数独生成、难度校验等纯Dart逻辑放在独立模块里,用单元测试验证,尽量不依赖热重载调试。
- 小步验证:每次改动只保持一个最小可复现的变更,一旦热重载失败,损失范围可控。
- 保留真机日志输出:用
debugPrint+hdc查看日志,不依赖UI断点,因为断点调试在适配层也偶发失效。
4.3 与原生能力交互:EventChannel传递难度项
如果你的OpenHarmony工程里还需要跟原生侧交互(比如调用鸿蒙的本地通知、文件存储),那就绕不开EventChannel或MethodChannel。我在数独项目里用EventChannel做了一件事:当用户在系统层面切换深色模式时,原生侧通知Flutter侧切换主题,新游戏对话框的颜色也要跟着动态调整。
EventChannel的基本用法是原生侧创建一个事件流,Flutter侧用EventChannel.receiveBroadcastStream监听。
static const EventChannel _themeChannel = EventChannel('com.example.sudoku/theme'); void _initThemeListener() { _themeChannel.receiveBroadcastStream().listen((event) { if (event == 'dark') { // 更新主题状态 } }); }在OpenHarmony侧,对应的原生代码用Ability的EventChannel实现,把系统的Configuration更新转成事件发送给Flutter侧。这个链路看起来简单,但真正调试起来坑不少。比如原生侧发送事件的时序要和Flutter侧监听就绪对齐,否则可能出现"监听器还没注册,事件已经丢了"的情况。我最终的做法是延迟注册,在首帧渲染完成后再建立EventChannel的监听,同时在原生侧做了事件缓存。
注意:EventChannel传值建议只传简单类型(String/int),不要在通道里抛复杂对象。跨语言边界的序列化开销在鸿蒙上比Android更高,传复杂对象容易带来不可预期的延迟和类型解析失败。
4.4 状态管理在"新游戏"过程中的竞态问题
在对话框确认到GameState.reset执行期间,用户其实还留在界面上,理论上可以继续点击棋盘。虽然我弹了_busyOverlay来屏蔽交互,但_busyOverlay.show()本身是异步的,存在一个很短的时间窗口让用户的点击事件穿越。
后来我在GameState.reset里加了一个isResetting标志位,所有棋盘点击逻辑在入口处先判断该标志:
void onCellTap(int row, int col) { if (isResetting) return; // ... }这个标志位能有效防止"重置过程中用户把一个数字填进了新棋盘里"的脏状态。如果你不想引入isResetting,也可以把reset改成同步操作,但那会导致盘面生成时UI卡顿,两者取其一我更推荐异步+标志位的方案。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 对话框底部被遮挡 | 适配层未处理安全区 | 手动加viewPadding + viewInsets |
| 弹窗背景不变暗 | barrierColor设置异常 | 用showGeneralDialog自定barrier |
| 点击"开始"后棋盘刷新慢 | 数独生成阻塞主线程 | 放到compute/Isolate执行 |
| 新游戏后计时未重置 | 状态模型缺少计时重置 | reset方法里统一停止计时流 |
| 热重载卡死 | 适配层引擎状态异常 | 小步修改并用真机日志定位 |
| 返回旧页面残留 | 导航栈未清理 | popUntil推到首页 |
5. 几个值得单独说的细节
5.1 用ActivityPage生命周期管理对话框
OpenHarmony的能力模型是FA/Stage模型,Flutter页面跑在对应的Ability之上。如果你的应用需要在生命周期变化时关闭或重建对话框,记得监听AppLifecycleState。我在新游戏对话框打开状态下切到后台再回来,发现弹窗偶尔会消失但底层状态还在"待确认"。这是因为鸿蒙在某些ROM策略下会回收Activity,导致Flutter引擎重建。
应对方案是:把"是否处于新游戏确认流程中"下沉到App级别的状态字段,页面重建时通过启动参数恢复弹窗状态。这个设计比较重,如果你的应用只做单页游戏,也可以简单处理——直接不恢复,让用户重新发起新游戏,体验损失不大。
5.2 动画细节:进出场不要过于复杂
Flutter对Dialog有默认的进出场动画,OpenHarmony适配层实现了基本版本。我在项目里尝试过自定义ScaleTransition+缩小动画,真机上偶尔会出现动画撕裂。后来我把动画简化为透明度渐变,并且把时长控制在150ms以内,效果反而更干净,也避免了性能问题。如果你想加一点"优雅感",可以考虑让弹窗内容区在透明度变化的同时做一个轻微的向上位移,而不是做缩房动画。
5.3 本地化文案与无障碍支持
一个容易被忽视的点是无障碍。OpenHarmony对Flutter无障碍的支持本身还在完善中,但你已经可以做到的是:给对话框的语义节点设置合理的Semantics标签,让屏幕阅读器能读出"开始新游戏"“难度选择”“取消”“开始”这些关键信息。数独App的用户群体里也有不少依赖读屏的玩家,为他们的体验做设计是专业开发者应有的态度。
6. 写在最后:关于这套方案的一些个人感受
如果你问我在OpenHarmony上用Flutter做数独游戏App到底值不值,我的答案是:值,但要提前管理好预期。Flutter的业务开发效率真的很高,一套Dart代码几乎能在所有平台跑通,OpenHarmony适配层虽然在快速迭代,但和Android的成熟度相比还是有距离。
新游戏对话框这个功能,是我在鸿蒙上完成度最高的一个模块。它麻雀虽小,却把状态管理、导航、平台通道、生命周期、安全区适配这些知识点都串了一遍。做完这个功能,你对Flutter for OpenHarmony的整体开发手感就会有非常清晰的认知。根据我个人经验,后续再去做计时器、排行榜、成就系统,都会顺畅很多。
最后再分享一个小技巧:无论用什么平台,先把"新游戏/重置进度"这类功能性流程的边界条件想清楚——用户误触怎么办、快速连点怎么办、后台切换怎么办。把这些边界场景写进代码里,你交付的不只是一个对话框,而是一个真正可靠的新游戏闭环。
如果你也正在做类似的项目,欢迎按这个思路试试看。有问题的地方,大概率都在你没预料到的边界上。