
前段时间在折腾 OpenHarmony 设备我一直琢磨怎么把手上的 Flutter 技能平滑迁移过去。翻了一圈社区资料发现多数教程停在Hello World和登录注册这个层级真正涉及游戏、复杂交互的实战案例少得可怜。于是我干脆自己动手用 Flutter for OpenHarmony 完整做了一个数独游戏 App把最核心的笔记功能从数据模型、UI 交互到平台适配全部走了一遍。这篇文章就是这次实战的完整记录。笔记功能是数独游戏的灵魂。没有笔记功能的数独只适合入门级难度稍微复杂一点的题目你根本没法推理。而笔记这件事恰恰覆盖了数独的候选数算法、格子级状态管理、跨组件通信、以及 Flutter 在 OpenHarmony 平台上的构建调试等一整套技术点。写完这个项目后我最大的感受是这套组合的技术栈已经完全可以支撑真实应用开发缺少的恰恰是有人把细节讲透。下面我会把项目从头拆到尾包括数据模型、UI 实现、平台适配和踩坑记录希望对想在这个方向深入的朋友有帮助。1. 项目背景与方案选型1.1 为什么选 Flutter 做 OpenHarmony 应用在 OpenHarmony 上做应用当前可选的路子不少用 ArkTS 写原生应用用类 RN 的方案做桥接当然也可以用 Flutter 这类跨端框架。我最后选 Flutter核心原因有三点。第一Flutter 是自绘渲染引擎。UI 在不同平台上由同一套渲染管线绘制不像 WebView 套壳那样有明显的浏览器味和滚动卡顿这对游戏类应用来说太重要了。数独的格子、笔记数字、动画切换都需要像素级一致的呈现自绘方案天然占优。第二Dart 语言的表达力在 UI 和业务逻辑混合的场景下非常顺手。尤其是集合操作那一套map、where、difference 这些方法写候选数算法时比 JavaScript 舒服太多。第三点也是关键OpenHarmony 生态里的适配层已经沉淀到可用的状态我在设备上跑起来之后交互表现和 Android 端几乎一致。另外说一句关于框架对比的题外话。现在前端框架各有各的玩法但 Flutter 在复杂交互 跨端一致 高性能这个三角里确实是独一档的存在。尤其当你需要在 OpenHarmony、Android、iOS 上保持同一套数独体验时Flutter 省掉的不是一点点工作量。1.2 为什么偏偏是数独选数独做实战项目是因为它的复杂度卡在一个非常妙的位置向上能触及算法比如候选数推理向下能触及数据结构和 UI 细节比如格子级状态、模式切换、冲突检测。它又不至于像做一整个复杂 App 那样需要大量页面和网络层非常适合验证平台适配能力。尤其笔记这个功能几乎是小功能大工程的教科书。笔记的状态是动态集合用户在 81 个格子间反复横跳笔记模式与数字模式还要互相切换。这里既考验你对 Flutter 状态管理的理解又考验你设计数据模型的能力。我在社区里搜了一圈发现很多人做数独 Demo 只实现输入数字这个动作笔记功能普遍做成在格子里塞一堆小字没有真正考虑候选数联动、自动清理、模式冲突这些细节。这个缺口正是我想填上的。1.3 技术选型清单这是整个项目的技术栈直接列个表给你参考模块方案理由状态管理ChangeNotifier ListenableBuilder纯原生方案不引入额外依赖降低 OpenHarmony 适配风险数据模型二维 List Cell 对象每格承载 value、notes、isFixed、isError语义清晰笔记存储Setint天然去重候选数字的语义与集合完全一致持久化JSON 文件dart:io少依赖平台插件避免 openharmony 适配坑渲染Flutter 默认渲染管线不依赖 Impeller 特有特性保证兼容性状态管理我特意没有选 Provider 或 Riverpod。不是它们不好而是这个项目的状态维度足够集中一个 ChangeNotifier 子类就能完全控制。在 OpenHarmony 适配还不算太成熟的阶段少一个第三方依赖就少一分构建风险这个取舍后面你会看到价值。2. 笔记功能的需求拆解与数据模型设计2.1 三种核心交互场景开始写代码之前我先把笔记功能从用户角度拆成了三个场景。第一种是单格笔记输入点击某个空格切到笔记模式点数字面板上的数字这个格子就增加或移除一条候选数字笔记。第二种是自动笔记游戏开始、或者填入某个错误数字导致候选数变化后一键重算所有空格的可能数字。第三种是联动清理当某个数字被确定为最终值后它所在行、列、宫的所有空格笔记里都要自动移除这个数字。第三种是最容易被忽略的。很多人写的数独笔记只是单纯显示一堆数字用户把一个格子确定后其他格子的笔记还留着旧候选数后续推理直接崩盘。联动清理做起来其实不复杂但它是笔记功能体验好坏的临界点。我最后选择的方式是在每次输入最终数字这个动作后只清理受影响的 3 行、3 列、3 宫而不是全盘重算性能更好逻辑也清晰。2.2 数据模型为什么用 Set 承载笔记我先把核心模型代码贴出来class Cell { int? value; // 最终确定的数字空则为 null Setint notes {}; // 候选笔记集合 bool isFixed false; // 是否是题目给定的种子数字 bool isError false; // 是否冲突 Cell({this.value, this.isFixed false}); bool get isEmpty value null; bool get hasNotes notes.isNotEmpty; MapString, dynamic toJson() { value: value, notes: notes.toList(), isFixed: isFixed, }; factory Cell.fromJson(MapString, dynamic json) { final cell Cell(value: json[value] as int?, isFixed: json[isFixed] as bool); cell.notes (json[notes] as List).map((e) e as int).toSet(); return cell; } }这里最核心的设计决策是用Setint而不是Listint或字符串。你想想笔记的语义一个格里候选数字集合1 到 9 中哪些数字有可能出现。去重本身就是集合的本质属性如果用户反复点击同一个数字Set 可以极简地实现 toggle 效果。同时contains判断是 O(1)在联动清理场景下性能优势很明显。序列化时转成 List 存 JSON读取时再转回 Set整套流程非常顺滑。isFixed是题目给定的数字玩家不能修改UI 上也要用深色区分。isError用于冲突检测一旦某行、某列或某宫出现重复数字相关格子全部标记为红色。这些字段和笔记功能其实紧密相关因为冲突格子的笔记在用户视角里是需要立即重新检查的。2.3 候选数算法自动笔记怎么算自动笔记的核心是候选数计算对当前空白的格子排除同行、同列、同宫已经出现的数字剩下的就是候选集合。Setint _candidatesFor(int row, int col, ListListCell board) { final used int{}; // 排除同行 for (var c 0; c 9; c) { final value board[row][c].value; if (value ! null) used.add(value); } // 排除同列 for (var r 0; r 9; r) { final value board[r][col].value; if (value ! null) used.add(value); } // 排除所在 3x3 宫 final startRow (row ~/ 3) * 3; final startCol (col ~/ 3) * 3; for (var r startRow; r startRow 3; r) { for (var c startCol; c startCol 3; c) { final value board[r][c].value; if (value ! null) used.add(value); } } return {1, 2, 3, 4, 5, 6, 7, 8, 9}.difference(used); }Dart 的集合运算在这里很直观difference直接返回全集减去已用数字的差集也就是候选数。自动笔记就是遍历所有空格逐一调用这个方法void autoFillNotes() { for (var r 0; r 9; r) { for (var c 0; c 9; c) { if (_board[r][c].value null) { _board[r][c].notes _candidatesFor(r, c, _board); } } } notifyListeners(); }这个算法看起来简单但它是后面联动清理的基础。我在实际使用中加了两个保护一个是防止用户在已经确定数字的格子里写笔记这在 UI 层拦截另一个是自动笔记触发时机不要每次状态变化都全盘算而是只在填数字清空数字一键笔记三个动作时触发性能完全撑得住。3. UI 布局与核心代码实现3.1 单元格里的小网格布局笔记功能在 UI 上最直观的体现就是一个格子内部要能展示最多 9 个小数字。我的方案是当格子有确定值时显示大数字当格子为空但有笔记时用 3x3 的小网格展示候选数字。class CellWidget extends StatelessWidget { final Cell cell; final bool isSelected; final VoidCallback onTap; const CellWidget({ super.key, required this.cell, required this.isSelected, required this.onTap, }); override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( decoration: BoxDecoration( border: Border.all(color: _borderColor), color: isSelected ? const Color(0xFFE3F2FD) : cell.isError ? const Color(0xFFFFEBEE) : Colors.white, ), child: cell.value ! null ? _buildNumber() : _buildNotes(), ), ); } Widget _buildNumber() { return Center( child: Text( ${cell.value}, style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, color: cell.isFixed ? const Color(0xFF37474F) : const Color(0xFF1565C0), ), ), ); } Widget _buildNotes() { if (cell.notes.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.all(1), child: GridView.count( crossAxisCount: 3, physics: const NeverScrollableScrollPhysics(), children: List.generate(9, (index) { final number index 1; final hasNote cell.notes.contains(number); return Center( child: Text( $number, style: TextStyle( fontSize: 8, color: hasNote ? const Color(0xFF78909C) : Colors.transparent, ), ), ); }), ), ); } }这里有个细节值得说明笔记数字高度只有 8 号字而且在格子内用 3x3 网格铺满。为什么这样设计因为玩家在快速扫读盘面时笔记的作用是看到候选集合而不是读单个数字。数字过大会互相挤压过小则看不清。我在真机上反复调过字号8 号在绝大多数手机屏幕上是最清晰且不拥挤的。透明颜色那个技巧也提一下笔记模式下没有候选数字的位置仍然占位渲染透明文本这样保证 9 个小数字的位置恒定性。如果只渲染hasNote的数字整个布局会跳动视觉体验很差。3.2 数字面板与模式切换棋盘下方的数字面板是笔记输入的入口。我把模式分成两种InputMode.number和InputMode.note。数字模式下点击面板数字直接填入当前选中格并触发联动清理笔记模式下点击数字则是对当前选中格的笔记集合做 toggle。enum InputMode { number, note } class GameController extends ChangeNotifier { InputMode _mode InputMode.number; int? _selectedRow; int? _selectedCol; void toggleMode() { _mode _mode InputMode.number ? InputMode.note : InputMode.number; notifyListeners(); } void onNumberPressed(int number) { if (_selectedRow null || _selectedCol null) return; final cell _board[_selectedRow!][_selectedCol!]; if (cell.isFixed || cell.value ! null) return; if (_mode InputMode.number) { cell.value number; cell.notes.clear(); _clearNotesInAffectedAreas(_selectedRow!, _selectedCol!, number); _detectConflict(_selectedRow!, _selectedCol!); } else { if (!cell.notes.add(number)) { cell.notes.remove(number); } } notifyListeners(); } }模式切换的 UI 我用了一个很常见的铅笔图标按钮数字模式时图标是灰色的笔记模式时高亮成蓝色配合一段文字提示笔记模式。这个交互设计贴近主流数独 App 的用户心智玩家几乎不需要学习成本。启动应用时默认是数字模式避免新手误触笔记。3.3 组件通信棋盘、面板、状态栏如何协同组件通信这部分是很多人问得最多的。棋盘和数字面板是平级组件如果靠构造函数层层传回调你会发现代码变成一团乱麻棋盘要通知面板选中格变化面板要通知棋盘数字输入状态栏还要同时监听两边。我的解法是让GameController成为组件树上层的共享数据源。class GameBoard extends StatelessWidget { override Widget build(BuildContext context) { final controller GameScope.of(context)!.controller; return ListenableBuilder( listenable: controller, builder: (context, _) { return GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 9, childAspectRatio: 1, ), itemCount: 81, itemBuilder: (context, index) { final row index ~/ 9; final col index % 9; return CellWidget( cell: controller.board[row][col], isSelected: controller.isSelected(row, col), onTap: () controller.selectCell(row, col), ); }, ); }, ); } }GameScope是自建的一个 InheritedWidget用来把 controller 从组件树顶层往下传。它比我用全局变量或单例好管理得多也符合 Flutter 的依赖注入习惯。当数字面板点击按钮时执行流程是面板按钮 -controller.onNumberPressed- 修改 Cell 数据 -notifyListeners- 所有监听controller的ListenableBuilder局部重建。棋盘刷新数字面板的选中高亮也刷新状态栏的剩余空格数也刷新。这个模式的核心是单向数据流所有修改都走 controller 的公开方法组件只负责表现。我之前见过有人把 Cell 对象直接塞进 setState 里改结果就是一个格子输入要重建整个页面性能差且状态容易错乱。跨组件通信的最佳实践永远是共享一个状态源 精准监听而不是事件满天飞。4. OpenHarmony 平台构建与适配记录4.1 创建支持 OHOS 平台的 Flutter 项目OpenHarmony 的 Flutter 支持走的是社区分支。创建项目时你要确保本地的 Flutter SDK 是对应 OpenHarmony 的可构建版本。我在环境准备阶段花了不少时间这里给你一条可复现的路径# 使用支持 openharmony 平台的 flutter 工具链 flutter create --platforms ohos sudoku_app cd sudoku_app # 检查当前工具链是否识别 ohos 平台 flutter config --enable-openharmony flutter doctor如果flutter create没有列出 ohos 平台大概率是工具的版本不对别继续写代码先把环境捣鼓对。目录结构上ohos/目录是 OpenHarmony 应用的壳工程内部结构和 Android 的android/目录地位类似。应用签名、权限声明都在这个工程里配置。我建议新建项目后第一时间跑一次默认模板确认构建链路通了再写业务代码。这一步非常关键它把环境问题和业务问题隔离了。如果模板都跑不起来后面排查的成本会成倍增加。4.2 构建 HAP 与真机调试构建和调试是整个项目中最有 OpenHarmony 特色的部分。Dart 代码最终会以 hap 包的形式运行在 OpenHarmony 设备上命令如下# 打 debug 包 flutter build hap --debug # 连接设备后直接运行 flutter run -d device-id # 查看设备列表 flutter devices签名配置是在 DevEco Studio 打开的ohos/工程里做的。它需要你配置自动签名否则 hap 无法安装到设备上。这一步的坑在于如果你先用了命令行构建再去 DevEco Studio 配置签名可能会因为缓存问题导致签名不生效。我的建议是第一次构建用 DevEco Studio 直接跑通后续迭代再用命令行这样签名配置一次到位。日志输出方面OpenHarmony 上的 Flutter 调试日志和 Android 上很相似常见的是e/flutter开头带进程号。如果你想看更详细的渲染日志可以用 DevEco Studio 的 HiLog 窗口过滤 Flutter 相关标签。这在排查 UI 异常时比命令行 println 靠谱得多。4.3 需要留意的适配细节第一个重点是渲染引擎。Flutter 社区最近在推 Impeller但 OpenHarmony 适配层走的还是相对稳妥的 Skia 兼容路径。所以你的代码里不要依赖 Impeller 特有的渲染特性比如某些高级着色效果。数独这类用基础 Widget 就能搞定的应用完全没有这方面的压力。第二个重点是插件兼容性。我在项目里刻意避免了依赖 Android/iOS 专属的 Flutter 插件所有 IO 操作都用dart:io直接做。这不是退而求其次而是 OpenHarmony 生态的 Flutter 插件还处在补全阶段第三方插件的质量参差不齐。如果你必须用某个插件请先去查它的 ohos 版本实现是否完善我见过太多人卡在插件没有 openharmony 实现这个坎上项目被迫换方案。第三个重点是热重载。Flutter 热重载在 OpenHarmony 上的表现不如 Android 稳定尤其在修改了原生层配置之后。我实测下来纯 Dart 层的 UI 修改热重载问题不大但一旦动了ohos/壳工程直接热重载很容易出现界面残留或偶发崩溃。稳健的操作是改动壳工程后 stop 再 run改动 Dart 层代码再热重载。5. 常见问题与排查速查表5.1 笔记不刷新到底哪里断了我在开发中遇到最典型的 bug 是点击数字面板的笔记按钮棋盘上完全没反应。排查思路其实有固定套路。第一步看notifyListeners有没有被调用在 controller 里加个断点。第二步看棋盘的ListenableBuilder监听的是不是同一个 controller 实例。这里有个很隐蔽的坑如果你在build方法里直接GameController()而不是从顶层拿引用每次 build 都会创建一个新 controllerListenableBuilder监听的是旧实例新实例的修改自然石沉大海。我最终用GameScope这个 InheritedWidget 保证整个组件树共享同一个 controller彻底规避了这个问题。5.2 异步竞态与微任务队列另一个经典问题是快速连续点击数字导致状态互相覆盖。这里就涉及 Flutter 异步执行的一个特性Future.then的回调默认是放入微任务队列的而不是普通事件队列。微任务会在当前同步代码执行完毕后立即执行不会被 UI 帧间隔稀释。如果你在点击回调里用了 async 方法修改状态两个快速点击的异步回调会严格按照微任务顺序排队执行中间状态没有被 UI 及时反映视觉上就像点了没反应过一会儿突然跳两个数字。我的处理方式很干脆所有数独状态修改都是同步的不让异步逻辑碰数据模型。笔记 toggle、数字填入、联动清理全部在事件回调里同步完成并notifyListeners。异步只用于持久化比如保存游戏进度的文件写入操作放到后台但与界面状态完全解耦。这个原则推行后竞态问题就再没出现过。5.3 构建期问题速查表OpenHarmony 构建期也会遇到一些看起来很吓人的报错整理成表格方便你直接对照现象原因处理建议Gradle 插件 apply 警告构建脚本用了旧式 apply 方式应用 Flutter Gradle 插件按提示切换到 plugins DSL不影响 hap 产出HAP 无法安装到设备签名未配置或签名证书过期在 DevEco Studio 重新配置自动签名运行时报unhandled exceptionDart 层未捕获异常日志带完整堆栈抓取堆栈定位首个用户代码帧可在 main 里加 FlutterError.onError 收集现场PlatformView 相关报错使用了不支持的原生视图混合检查插件是否有 ohos 实现必要时改为纯 Widget 方案热重载后界面残留修改了 ohos 壳工程原生层停止进程重新 run不要强行热重载最后那行unhandled exception值得展开说一下。你在 Android 或桌面平台跑 Flutter 时这个日志会出现在 logcat 里OpenHarmony 上用 DevEco Studio 的 HiLog 也能看到同样格式。日志里的e/flutter是 Flutter 引擎的日志标签后面括号里是进程号。定位未捕获异常最有效的方法是看dart_vm_initializer.cc之后的 Dart 堆栈你的业务代码帧通常在 Flutter 框架帧之前。我还习惯在入口处加一个全局兜底void main() { FlutterError.onError (details) { // 记录现场后续上报或写入本地日志 debugPrint(Unhandled Flutter error: ${details.exception}); }; runApp(const SudokuApp()); }这样做的好处是即使线上环境有偶发异常也不会直接白屏。对于数独这类本地单机应用用户最反感的是玩到一半闪退所以我把稳定性的优先级提到了功能之上。我个人在这套项目里的体会是Flutter for OpenHarmony 已经过了能不能跑的阶段进入了怎么跑得顺的深水区。数独笔记功能看起来只是个小功能但它逼着我把数据模型、状态管理、组件通信、平台构建全部串在了一起。如果你也想在 OpenHarmony 上尝试 Flutter 实战建议从这种逻辑清晰、交互适中、算法可挖的项目入手。最后分享一个小技巧遇到平台适配问题时优先检查你的代码里是否引入了不必要的平台依赖纯 Dart 方案永远是 OpenHarmony 上最省心的选择。