ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

OpenHarmony上Flutter数独游戏主界面开发实战

OpenHarmony上Flutter数独游戏主界面开发实战 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 官方仓库地址同时配置到两个位置settings.gradle中的pluginManagement.repositories各 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 作为依赖挂进去。大致流程是先用flutter create --org com.example sudoku_app建出纯 Flutter 工程。在工程根目录用 DevEco Studio 新建一个 Entry 类型的 HarmonyOS 模块模块名建议叫entry。在entry/oh-package.json5中声明对 Flutter 产物的依赖。在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包住防止用户误触。难度选择区用横向排列的三个卡片分别是简单、中等、困难。每个卡片显示难度名称和预估用时选中状态用颜色高亮加边框表示。底部按钮区就两个按钮开始新游戏、继续上次。其中继续上次在无存档时置灰。整个布局用ColumnExpanded组合实现棋盘预览区放在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; Futurevoid 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并传argumentsNavigator.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 的分布式能力让手机和平板之间可以接力一局游戏这涉及状态同步协议的设计复杂度会上升一个量级。最后再分享一个小技巧主界面的棋盘预览不需要追求和游戏页面完全一致它更重要的职责是一眼看懂。如果预览棋盘上用了过多的视觉装饰反而会干扰用户的判断。保持简洁、克制把复杂留给真正的游戏画面这就是我在这个项目里最大的收获。
返回列表