☰
OpenHarmony上Flutter数独游戏主界面开发实战
2026/10/23 11:36:08 网站建设 项目流程

1. 项目背景与整体思路

先说结论:在 OpenHarmony 上跑 Flutter 做数独游戏,主界面看似简单,实际牵涉到的东西比想象中多得多。棋盘初始化、难度切换、计时器刷新、路由跳转,每一块都有坑。这篇文章我把整个"设置主界面"的过程完整拆开,从环境搭建到组件实现再到问题排查,通通记录下来。

1.1 为什么在OpenHarmony上用Flutter做数独

OpenHarmony 这几年生态起来得很快,设备种类从手机、平板一路延伸到智能家居、工业网关。但问题也很现实:每个硬件平台单独写一套 UI 逻辑,维护成本太高。Flutter 的跨端能力正好能解决这个痛点,一套 Dart 代码同时覆盖 Android、iOS、OpenHarmony 等多个平台。

数独这个项目选得也合适。它逻辑清晰、界面固定、交互不复杂,非常适合作为 Flutter + OpenHarmony 的练手项目。特别是棋盘渲染这一块,9x9 格子如果用原生逐个绘制很繁琐,但 Flutter 的 GridView 和 CustomPaint 处理起来就轻松很多。

另一个隐性原因是性能。数独的棋盘重绘频率不低,尤其是计时器和数字高亮动画同时进行时。Flutter 的渲染引擎在 OpenHarmony 上走的是自绘路线,不依赖系统原生控件,帧率表现反而更可控。

1.2 主界面要解决的三个核心问题

主界面不是简单摆几个按钮就完事,它是整个 App 的"门面",需要承载三个核心任务。

第一个是开局引导。用户打开 App 的第一眼,要能明确看到"开始新游戏""选择难度""继续上次进度"这些入口,操作路径不能超过两步。我见过不少数独 App 把入口藏得太深,用户划了半天找不到开始按钮,直接卸载。

第二个是棋盘状态预览。主界面上展示一个迷你棋盘或当前局面的缩略图,能极大提升专业感。这个预览不是装饰,它需要真实反映当前游戏进度,让用户回到主界面时一眼知道"我上次玩到哪儿了"。

第三个是状态持久化的入口。数独一局可能玩半小时甚至更久,中途退出再回来,棋盘必须恢复原样。这就意味着主界面要用 SharedPreferences 或文件存储记录游戏快照,不能只活在内存里。

这三个问题想清楚,主界面的代码结构自然就出来了:一个 HomeScreen 壳子,内部拆成棋盘预览区、难度选择区、底部操作区三个大块,再加一个游戏状态管理器兜底。

提示:如果你只打算跑通流程,可以暂时省略状态持久化,但建议从一开始就把状态管理器的接口设计好,后面加存储逻辑会顺手很多。

2. 环境准备与工程初始搭建

2.1 开发环境的版本搭配

OpenHarmony 的 Flutter 支持和上游 Flutter 版本之间有微妙的对应关系,不是随便装一个 Flutter 就能直接跑。开发 OpenHarmony Flutter 应用,需要关注的是 ffi 内部如何映射到 OpenHarmony 的 API 上,以及原生插件如何通过系统能力 bridge 到 ArkTS 侧。

我实测下来比较稳妥的版本搭配是:OpenHarmony SDK 4.0 Release 配上 Flutter 3.7.x 的 OpenHarmony 分支,或者直接使用 DevEco Studio 内置的 SDK 管理工具拉取配套版本。

版本不匹配时最常见的报错是插件加载失败,比如热搜词里提到的flutter error resolving plugin [id: 'dev.flutter.flutter-plugin-loader']。这个问题多半是工程里的settings.gradle或build.gradle中仓库地址没有指向 OpenHarmony 的 maven 源,导致插件解析器找不到对应构件。

具体操作上,我建议把 OpenHarmony 官方仓库地址同时配置到两个位置:

  1. settings.gradle中的pluginManagement.repositories
  2. 各 module 的build.gradle中的allprojects.repositories
// settings.gradle 示例 pluginManagement { repositories { maven { url 'https://repo.harmonyos.com/maven/' } google() mavenCentral() gradlePluginPortal() } }

注意:OpenHarmony 的 maven 仓库在国内直连速度还行,但如果你的网络环境对境外地址不友好,建议在 gradle 配置里把依赖下载超时时间调大一点,避免构建中途失败。

2.2 创建项目并接入OpenHarmony平台层

创建 Flutter 工程后,需要手动添加 OpenHarmony 的平台目录。这里有个经验:不要让 DevEco Studio 自动转换整个 Flutter 工程,它会生成一堆冗余配置。更干净的做法是只创建 OpenHarmony 壳工程,把 Flutter module 作为依赖挂进去。

大致流程是:

  1. 先用flutter create --org com.example sudoku_app建出纯 Flutter 工程。
  2. 在工程根目录用 DevEco Studio 新建一个 Entry 类型的 HarmonyOS 模块,模块名建议叫entry。
  3. 在entry/oh-package.json5中声明对 Flutter 产物的依赖。
  4. 在entry/src/main/ets/entryability/EntryAbility.ets中调用 Flutter 的加载入口,把 FlutterView 挂到 Ability 的窗口上。

这个方式是社区里跑通的主流玩法。它的好处是 Flutter 侧代码保持跨平台纯净,OpenHarmony 侧只在壳工程里做集成,后续升级 Flutter SDK 时不需要动原生代码。

如果你用的是较新的 Flutter OpenHarmony 分支,官方模板可能已经集成了ohos目录,直接打开就能跑。这种模板工程里已经写好了MainAbility和 Flutter 引擎的初始化逻辑,省掉不少事。

2.3 目录结构与命名规范

项目根目录下的结构我习惯这样组织:

lib/ main.dart pages/ home/ home_screen.dart widgets/ board_preview.dart difficulty_selector.dart status_panel.dart game/ game_screen.dart models/ sudoku_board.dart game_state.dart providers/ game_provider.dart utils/ sudoku_generator.dart sudoku_solver.dart services/ storage_service.dart

这个分层主要考虑两点:一是页面与业务解耦,UI 层只依赖 Provider 暴露的状态;二是所有数独算法集中在utils下,不混进 Widget 代码里。主界面相关的控件全部收在home/widgets下,方便后面做组件级测试。

命名上,所有文件用蛇形命名,类名用 PascalCase,常量全大写。这些规范看着琐碎,但在跨平台项目里特别重要——OpenHarmony 侧的 ArkTS 文件命名规范和 Dart 侧不同,工程一混,如果没有明确规则,找文件能找疯。

3. 主界面布局与视觉设计

3.1 界面结构拆解

主界面我把它从上到下拆成四个区域:顶部标题栏、棋盘预览区、难度选择区、底部按钮区。

顶部标题栏不需要复杂,显示 App 名称和最佳记录即可,用AppBar自带的高度就够,不用自定义。

棋盘预览区是视觉核心,占屏幕宽度 70% 左右。这个比例是我试出来的:太大会让下方操作区拥挤,太小则看不清楚棋盘数字。预览区内部放一个不可交互的缩略棋盘,用IgnorePointer包住,防止用户误触。

难度选择区用横向排列的三个卡片,分别是简单、中等、困难。每个卡片显示难度名称和预估用时,选中状态用颜色高亮加边框表示。

底部按钮区就两个按钮:开始新游戏、继续上次。其中"继续上次"在无存档时置灰。

整个布局用Column+Expanded组合实现,棋盘预览区放在Expanded中间,确保不同屏幕高度下弹性伸缩。这个布局策略在手机和平板上都能自适应,不用单独写两套 UI。

3.2 色彩与主题配置

数独的界面配色有个基本原则:背景要安静,数字要突出,操作要醒目。因为用户要在棋盘上连续观察很久,高饱和度的背景色会加速视觉疲劳。

我的主色配置如下:

class AppTheme { static const Color primary = Color(0xFF3F51B5); static const Color background = Color(0xFFF5F5F5); static const Color boardBg = Color(0xFFFAFAFA); static const Color cellBorder = Color(0xFFBDBDBD); static const Color numberDark = Color(0xFF212121); static const Color numberLight = Color(0xFF757575); static const Color highlight = Color(0xFFFFC107); }

主题在main.dart里统一设置,用ThemeData的colorScheme和textTheme覆盖默认值。这样整个 App 的视觉风格保持一致,后面做深色模式时只需要换一套ThemeData即可。

值得一提的细节是:数独题目的"初始数字"和"用户填入数字"在视觉上必须区分。初始数字用深色加粗,用户填写数字用浅色细体,这样用户才能快速判断哪些格子是题目给的、哪些是自己填的。这个设计在预览棋盘上同样适用。

3.3 尺寸适配策略

OpenHarmony 的设备和 Android 类似,屏幕尺寸千差万别,尤其会出现不少带屏幕圆角或挖孔的设备。布局时要避开安全区,Flutter 里用SafeArea包裹最外层即可。

棋盘格子的尺寸不能写死。我的做法是:获取屏幕宽度,减去左右 padding 和棋盘边框,然后除以 9。这样无论屏幕多宽,格子始终铺满棋盘区域。

double cellSize = (screenWidth - horizontalPadding * 2 - boardBorderWidth * 2) / 9;

对于文字大小,我建议不要用fontSize硬编码,而是在小屏设备上启用MediaQuery.textScaler适配。比如预览棋盘上数字字号设为格子边长的 0.5 倍,这样格子变大时数字也跟着变大,不会出现数字溢出格子的情况。

如果你测试的机器包含折叠屏这类特殊形态,还需要考虑横竖屏切换时的重建逻辑。最简单的方案是强制竖屏,在EntryAbility的配置里锁定 orientation。数独 App 横屏收益本来就不大,锁竖屏能省掉一半适配工作量。

4. 主界面核心功能实现

4.1 游戏初始化逻辑

主界面加载时,第一件事就是初始化游戏核心状态。这里我定义了一个GameProvider,它持有当前棋盘、当前难度、剩余时间、选中的格子等状态。

初始化逻辑的核心代码如下:

class GameProvider extends ChangeNotifier { SudokuBoard? currentBoard; Difficulty currentDifficulty = Difficulty.medium; bool hasSavedGame = false; Future<void> init() async { final storage = StorageService(); final saved = await storage.loadSnapshot(); if (saved != null) { currentBoard = saved.board; currentDifficulty = saved.difficulty; hasSavedGame = true; } else { currentBoard = SudokuGenerator.generate(Difficulty.medium); hasSavedGame = false; } notifyListeners(); } void startNewGame(Difficulty diff) { currentDifficulty = diff; currentBoard = SudokuGenerator.generate(diff); hasSavedGame = false; notifyListeners(); } }

这里有个设计决策值得说一下:进入主界面时自动加载上次游戏,而不是默认生成新局。这样老用户回来时,棋盘预览区直接显示上次未完成的进度,体验上非常连贯。如果是全新用户,再生成一局中等难度作为默认预览。

SudokuGenerator.generate这一步是最耗时的,生成一局难度合适的完整数独需要经过挖洞和唯一解校验,平均耗时约 50 到 200 毫秒,在低端设备上会明显卡顿。所以我在实际项目里把它放进了Isolate后台执行,等生成完再通知 UI 刷新,避免主界面首帧卡顿。

4.2 九宫格棋盘组件

棋盘预览组件是主界面最核心的部分。它和游戏页面的棋盘组件结构相同,只是去掉了交互能力和高亮逻辑。我把共用部分抽成了一个SudokuBoardView,通过参数控制是否可交互。

class SudokuBoardView extends StatelessWidget { final SudokuBoard board; final bool enableInteraction; final void Function(int row, int col)? onCellTap; const SudokuBoardView({ super.key, required this.board, this.enableInteraction = false, this.onCellTap, }); @override Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, child: Container( decoration: BoxDecoration( color: AppTheme.boardBg, border: Border.all(color: AppTheme.cellBorder), ), child: GridView.builder( physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 9, ), itemCount: 81, itemBuilder: (context, index) { final row = index ~/ 9; final col = index % 9; return _CellWidget( value: board.cells[row][col], isInitial: board.initialMask[row][col], emphasized: (row % 3 == 0) || (col % 3 == 0), ); }, ), ), ); } }

这里有个细节点:GridView.builder虽然方便,但在格子太多时会有性能损耗。81 个格子其实很少,也可以用Wrap+ 固定尺寸实现,性能更稳定。不过GridView的优势在于支持后续扩展,比如标记错误、显示笔记时依旧能复用。我最后保留了GridView。

格子的边框是高亮点,数独盘面的 3x3 宫的边界要比普通格线粗。实现方式是在 cell 的装饰里,根据行列位置判断是否为宫的边界,如果是则画加粗边框。这个判断逻辑可以直接写在_CellWidget里,不用额外处理。

4.3 难度选择控件

难度选择我用三个InkWell卡片实现,选中的卡片通过AnimatedContainer做状态切换动画。动画时长设 150 毫秒,不能太长,否则连续切换难度时会感觉拖沓。

每个难度卡片包含两个信息:难度名和预估花费时间。估算时间我是按完成一局的大致时间填的,比如简单 5 分钟、中等 15 分钟、困难 30 分钟。这个信息对用户决策很有帮助,尤其是碎片时间场景下,用户会优先选能在通勤时间内完成的一局。

实现时需要注意点击热区的大小。卡片高度 60 以上就是比较舒服的热区,如果卡片太矮,用户容易点偏,视觉上也不够气派。我的卡片高度设置在 80,宽度根据屏幕三等分,中间留 12 的间距。

当前选中难度会传给GameProvider,作为startNewGame的参数。难度切换时,棋盘预览区要立刻生成对应难度的新局并刷新,让用户直观看到这个难度下的题目密度是什么样。这里要注意防抖,用户连续点击不同难度时,后台生成任务的顺序不能乱,否则会出现在"简单"标签下显示"困难"题目的问题。

经验:生成任务的防抖用Timer延迟 200 毫秒再触发,如果在这期间用户又点了其他难度,取消之前的Timer。这样可以有效避免快速连点时出现生成结果错位。

4.4 顶部状态栏与计时逻辑

顶部状态栏我显示了三项:当前难度、当前用时、最佳记录。难度从 Provider 里读,最佳记录从SharedPreferences读,当前用时则来自一个每分钟触发一次的Timer。

主界面的计时器主要是为了显示"上次游戏进度"的参考时长,精度要求不高。它的逻辑如下:

class StopwatchTile extends StatelessWidget { final Duration elapsed; String get _formatted { final minutes = elapsed.inMinutes.toString().padLeft(2, '0'); final seconds = (elapsed.inSeconds % 60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } @override Widget build(BuildContext context) { return Text( _formatted, style: Theme.of(context).textTheme.headlineSmall, ); } }

真正完成的游戏计时在游戏页面里做,主界面这里只是展示存档中记录的时间。如果你要在主界面同时显示当前新局生成的倒计时效果,可以用StreamBuilder订阅一个每秒发射一次的Stream,但要注意页面切到后台时取消订阅,否则会有大量无意义的 rebuild。

4.5 底部操作区

底部操作区用Row放两个按钮,左边"继续上次",右边"开始新游戏"。为了让主操作按钮更突出,我把"开始新游戏"做成了FilledButton,"继续上次"用OutlinedButton。

继续按钮的可点击状态绑定hasSavedGame,没有存档时置灰并显示 tooltip 提示"暂无进行中的游戏"。开始按钮始终可用,点击后弹出菜单让用户确认难度,或者直接按当前选中难度开局。

关于开局后的导航,我选择用Navigator.push跳转到GameScreen,并把Difficulty和初始化好的SudokuBoard作为参数传入。这样游戏页面不依赖 Provider 的全局状态,即使后面想支持多局并行也能游刃有余。

5. 页面路由与状态联动

5.1 路由表设计

App 的页面虽然只有两个(主页和游戏页),我还是用了命名路由,而不是匿名Navigator.push。原因很简单:后面一定会加设置页、排行榜页,命名路由在统一处理页面参数和动画上更规范。

final routes = <String, WidgetBuilder>{ '/home': (context) => HomeScreen(), '/game': (context) => GameScreen(), }; void main() { runApp(const SudokuApp()); }

跳转时用Navigator.pushNamed并传arguments:

Navigator.pushNamed( context, '/game', arguments: GameArguments( difficulty: provider.currentDifficulty, board: provider.currentBoard!, ), );

GameArguments是一个轻量的数据类,承担页面间参数传递的职责。它不持有任何 UI 状态,保证页面间的解耦干净。

5.2 游戏状态与主界面同步

游戏进行中用户按返回键回到主界面,主界面的棋盘预览必须立即反映最新进度。这需要一个通知机制。

我的方案是:游戏页面在每次棋盘变化时,同步调用GameProvider.updateBoard(board)更新全局状态。主界面的棋盘预览通过ChangeNotifierProxyProvider或者简单的AnimatedBuilder监听GameProvider,状态一变就重新渲染。

实现时要注意退出游戏页面的时机。我在GameScreen的dispose里调用了一次"全局快照保存",把当前棋盘写入SharedPreferences。这样用户无论从哪个方式退出,主界面拿到的都是最新的。

@override void dispose() { _saveSnapshot(); super.dispose(); }

这条路有一个容易被忽略的坑:dispose在页面路由被替换时也会触发,如果用户在游戏页里点了"重新开始",旧的GameScreen实例 dispose 时会把旧棋盘存成快照,覆盖掉新棋盘。我的解决办法是:在保存前对比GameProvider中的棋盘和当前页面的棋盘是否为同一对象,只有一致时才执行保存。

5.3 返回键处理与二次确认

游戏页面里用户误触返回键会丢失本局进度。虽然我们有快照机制,但快照默认存的是每步的实时状态,如果用户只是试了几步,返回后进度其实保留了,不会丢失太多。

真正需要担心的场景是:用户点击分享或截屏时误触返回。我在PopScope里拦截了一下返回键,弹出"退出本局?"确认对话框,用户确认后才真正退出。如果按取消,则留在游戏页面继续玩。

这里贴一下核心代码:

child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { if (didPop) return; _showExitConfirmDialog(); }, child: const GameScreenContent(), )

对话框的按钮文案注意不要用默认的"确定/取消",换成"继续游戏"和"退出本局",语义更明确,避免用户在紧张游戏氛围下误确认。

6. 踩坑记录与排查技巧

6.1 构建过程的常见报错

OpenHarmony 的 Flutter 开发中,编译阶段的报错占了整个开发周期的大头。我整理了三个典型问题。

第一个是插件解析器报错。前面提到的flutter error resolving plugin,实际上是 Flutter 的插件机制在 OpenHarmony 侧没有对应的ohos平台实现。很多第三方 Flutter 插件只原生支持 Android/iOS,拿到 OpenHarmony 工程里就罢工。解决办法有三个层级的渐进路线:

  • 优先找 OpenHarmony 兼容版本或社区替代品;
  • 其次用条件导入的方式,在 OpenHarmony 平台用系统 API 自实现插件逻辑;
  • 最后实在不行就降级功能或直接不用这个插件。

第二个是settings.gradle中仓库配置不全导致的依赖下载失败。报错信息通常是一大段Could not resolve all files for configuration。排查时按顺序检查:OpenHarmony maven 源是否在首位、网络代理是否拦了 https 请求、本地 gradle 缓存是否损坏。一般前两步就能解决八九成的问题。

第三个是签名配置问题。真机调试时如果报sign config invalid,多半是自动化签名没有配置正确。在 DevEco Studio 中重新生成签名证书,然后在build-profile.json5中更新signingConfigs即可。这个配置和 Flutter 侧的签名无关,是 OpenHarmony 壳工程自己的事儿。

6.2 布局与渲染问题

主界面最容易出现的问题是棋盘预览区和难度选择区在不同分辨率下互相挤压。我的处理方式是给棋盘预览外层包一层Flexible而不是Expanded,这样当内容超出屏幕高度时,棋盘预览区会被压缩。

还有一类问题跟渲染有关。如果你在 OpenHarmony 上发现棋盘格子边线模糊,尤其是密度较高的设备上,多半是因为边框宽度使用了奇数像素(1、3 这类),导致在设备像素比不是整数时出现走样。解决方式是把边框宽度统一设为1.0或2.0,配合MediaQuery.devicePixelRatio校正。

另外,GridView.builder与NeverScrollableScrollPhysics组合时,偶尔会出现首帧时格子高度为 0 的异常。这是GridView在无约束高度环境下的老问题。如果遇到,换成显式指定高度的GridView.count或CustomMultiChildLayout就能稳定解决。

6.3 性能与内存问题

OpenHarmony 4.0 系统上,Flutter 的texture和platform view性能表现参差不齐。主界面如果嵌入了原生控件(比如系统选择器),会出现开屏白屏或掉帧。我在主界面完全避免使用原生控件,全部用 Flutter 自绘,稳定性和帧率都很好。

内存方面,棋盘状态类对象不要频繁创建。SudokuBoard内部持有两个 9x9 的二维数组,每次生成新局都会产生新实例,如果用户在难度切换时快速点几十次,GC 压力会明显上升。我加了对象池和生成任务的防抖,实测内存回收稳定很多。

流式布局的 rebuild 控制也很重要。StopwatchTile这种定时刷新组件,我把它独立成StatefulWidget,内部自己维护Timer,不让父级 Widget 因时间变化而频繁 rebuild。时间对主界面来说只是次要信息,不值得为它牺牲整个页面的刷新性能。

6.4 真机与模拟器差异

OpenHarmony 的模拟器在 Flutter 渲染上有延迟,特效动画有时看着卡顿,但真机上没问题。反过来也有真机正常、模拟器白屏的案例。我建议主界面调试以真机为准,模拟器只做快速布局验证。

RK3568 和 RK3588 这两类开发板上跑 Flutter 的效果差异也很大。RK3588 性能强不少,动画流畅度接近中端手机,RK3568 则明显吃力。如果目标设备是低端开发板,棋盘高亮动画尽量用颜色渐变而不是位移动画,位移动画的合成成本更高。

7. 实测心得与后续规划

把主界面从零搭到能跑通真实游戏流程,整体花了两周左右。其中最耗时的不是 UI 代码,而是处理 OpenHarmony 平台侧的集成细节。如果之前没有接触过 OpenHarmony 的工程结构,建议先在官方文档上花半天时间跑通一个"Hello World",再直接上数独项目。

我个人的建议是:主界面代码不要追求一次写完。先把核心框架搭出来,跑通最小闭环,然后逐步加功能。我第一版主界面只放了"开始新游戏"按钮和棋盘预览,难度选择、计时器、继续上次进度都是后面迭代加的。这样每一轮改动都能快速在真机上验证,排错成本低很多。

后面计划做的事有两个方向。一是给棋盘加笔记模式和错误提示,这需要把主界面预览棋盘的数据结构扩展成支持候选数字;二是接入 OpenHarmony 的分布式能力,让手机和平板之间可以接力一局游戏,这涉及状态同步协议的设计,复杂度会上升一个量级。

最后再分享一个小技巧:主界面的棋盘预览不需要追求和游戏页面完全一致,它更重要的职责是"一眼看懂"。如果预览棋盘上用了过多的视觉装饰,反而会干扰用户的判断。保持简洁、克制,把复杂留给真正的游戏画面,这就是我在这个项目里最大的收获。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询