1. 为什么要在OpenHarmony上选Flutter画数独棋盘
先交代一下项目背景。我手头有个数独游戏App,目标平台是OpenHarmony。一开始当然想用ArkTS直接写,毕竟那是OpenHarmony的"官方语言",文档全、示例多。但团队之前的主力栈是Flutter,码了好几万行业务逻辑,全部用ArkTS重写一遍成本太高。于是我们试了试Flutter for OpenHarmony这条路,结果发现棋盘绘制这块不仅跑得通,而且体验还不差。
这篇文章就围绕"数独棋盘绘制"这一个点展开。棋盘是所有数独游戏的地基,后端算法再漂亮、难度曲线设计得再合理,棋盘画得别扭,玩家上手第一分钟就流失了。同时棋盘绘制又非常适合用来演示Flutter自定义绘制的完整流程:数据模型设计、Painter绘制、触摸命中、状态联动,一条链路下来,你对Flutter在OpenHarmony上的能力边界会摸得很清楚。
1.1 Flutter for OpenHarmony的生态现状
先说结论:Flutter for OpenHarmony不是把Flutter代码原封不动搬过去就万事大吉。目前OpenHarmony的Flutter适配走的是社区分支,主仓库是openharmony-sig/flutter_flutter,对应引擎层是flutter_engine,再加一个flutter_packages仓库放插件适配。日常开发用的Dart语言、Widget体系、渲染管线跟标准Flutter基本一致,但平台通道、插件引用的细节跟Android/iOS有一些差异。
我自己的体会是:纯Dart层面的代码——比如本文要讲的数独棋盘绘制——在OpenHarmony和标准Flutter上的表现几乎没有区别。但如果你的项目用到了device_info之类需要走平台通道的插件,就得先确认它有没有对应的OpenHarmony实现。棋盘绘制不涉及这些,所以这个项目反而是上手Flutter for OpenHarmony的绝佳切入点。
1.2 棋盘绘制在数独项目中的位置
数独App的完整功能链条是这样的:题目生成 -> 棋盘展示 -> 玩家填入数字 -> 冲突检查 -> 完成判定。棋盘绘制服务于"棋盘展示"和"玩家填入"两个环节,但它同时被"题目生成"的数据结构反向约束。换句话说,绘制层不能自己定义一套棋盘格式,必须跟题目生成模块共用一套数据模型。这个约束贯穿整篇文章,后面所有设计决策都源于它。
2. 棋盘数据的建模:画线之前,先把81个格子组织好
很多新手一上来就画线:九条竖线九条横线,加上四条粗线,棋盘就出来了。这当然能看,但一旦涉及到"点击某个格子"、"这个格子是预置数字"、"这个数字和同一宫里的某个数字冲突"这些需求,没有数据模型支撑的绘制代码就会迅速腐化成一团乱麻。
我建议的建模方式是以格子(Cell)为最小单位,棋盘持有81个Cell,而不是以"线"为最小单位。这个思路跟Flutter的声明式UI也是吻合的:UI是数据的投影,棋盘长什么样,完全由81个格子的状态决定。
2.1 格子与棋盘的数据结构
一个格子需要保存的信息有:行号、列号、当前值、是否为预置数字(即题目初始就给好的数字)、候选数列表。其中行号和列号可以隐含在列表索引里:row = index ~/ 9,col = index % 9,但我倾向于显式存一份,方便排查问题。
class SudokuCell { final int row; final int col; int value; // 0 表示空格 bool isFixed; // 预置数字不可修改 int candidateMask; // 候选数位掩码,bit0~bit8 对应数字1~9 bool isConflict; // 是否与同行/列/宫的数字冲突 SudokuCell({ required this.row, required this.col, this.value = 0, this.isFixed = false, this.candidateMask = 0, this.isConflict = false, }); bool get isEmpty => value == 0; }棋盘类持有一个List<SudokuCell>,长度固定为81。为什么用一维列表而不是二维数组?因为遍历的时候一维列表最方便,for (final cell in cells)直接扫完。需要按行列访问时,用索引换算,成本极低。
class SudokuBoard { static const int size = 9; final List<SudokuCell> cells = List.generate(81, (i) { return SudokuCell(row: i ~/ 9, col: i % 9); }); SudokuCell cellAt(int row, int col) => cells[row * 9 + col]; }cellAt这个方法后面会被频繁调用,绘制要查、触摸命中要查、冲突检测要查。所以它必须足够简单直接,一维数组加乘除法,没有任何花哨的查找逻辑。
2.2 候选数的位掩码设计
候选数(candidate)是数独游戏里一个绕不开的概念。标准数独中每个空格理论上只能填1~9中的某些数字,把那些数字列出来就是候选数。实现候选数有两种常见方案:List<int>或位掩码int。
List<int>直观,但每次都new一个列表,81个格子动不动几百上千个小对象,Dart的垃圾回收会时不时卡一下。位掩码用一个int的9个bit表示数字1~9是否存在,内存占用小、合并求交集快,而且逻辑运算效率高。这是我个人推荐的做法,尤其当你要做难度算法、提示算法时,位掩码的位运算优势会愈发明显。
实践中可以这样操作:
void toggleCandidate(int maskBit) { candidateMask ^= maskBit; } bool hasCandidate(int maskBit) => (candidateMask & maskBit) != 0; static const int maskForDigit = 1 << (digit - 1);给一个格子填入数字时,把value设为该数字,同时把candidateMask清零。清空格子时恢复候选数——至于恢复哪些,可以用"同行同列同宫已存在数字的反集"算出。
2.3 冲突检测为什么要放在数据层
冲突检测逻辑放在数据层而不是绘制层,这是我从一开始就坚持的。原因很简单:绘制层需要高亮冲突格子,游戏逻辑层需要禁止填入冲突数字,难度算法需要利用冲突来调整出题,三层都要用同一套"当前棋盘是否合法"的判断。如果每个层各写一遍,迟早会出现某处判断标准不一致的bug。
bool isConflictAt(int row, int col, int value) { // 检查同行 for (int c = 0; c < 9; c++) { if (c != col && cellAt(row, c).value == value) return true; } // 检查同列 for (int r = 0; r < 9; r++) { if (r != row && cellAt(r, col).value == value) return true; } // 检查所在 3x3 宫 int blockRow = (row ~/ 3) * 3; int blockCol = (col ~/ 3) * 3; for (int r = blockRow; r < blockRow + 3; r++) { for (int c = blockCol; c < blockCol + 3; c++) { if (r != row && c != col && cellAt(r, c).value == value) return true; } } return false; }注意,检查3x3宫时不需要判断值是否等于0,因为value传进来的都是非零数字。空格的value是0,不会有0导致的误判。这段逻辑非常直白,但它是整个数独游戏所有规则判断的核心。
3. 绘制方案选型:CustomPaint与Widget组合的取舍
棋盘绘制大体上有两条路线。第一条是用标准Widget拼装:Table或者GridView生成81个格子,每个格子是一个Container,通过BoxDecoration设置边框,预置数字用Text显示。第二条是用CustomPaint,在Canvas上一次性画出所有线条和数字。
这两条路我都试过,最后选了CustomPaint。但这不是说Widget拼装一无是处,各有各的适用场景,我展开说说。
3.1 Widget组合方案的优点和绊脚石
Widget组合方案最明显的优点是开发速度快、容易调试、天然支持热重载。每个格子就是一个独立的StatefulWidget,点击事件直接挂在InkWell上,数据变更后回调setState刷新局部。对于原型验证,这条路半小时就能跑出一个能点的棋盘。
但它有几个绊脚石。第一,3x3宫粗线的实现非常别扭。用Table的话,你没法给某一行单独加粗底部边框,只能在特定行的Container里加Border的bottom,样式代码会散落到各个Widget里。第二,性能问题。刷新一个数字时如果你没做好局部刷新,很可能会触发整棵Widget树重建。81个格子在桌面端不算什么,但在OpenHarmony的低配设备上,每一帧重建会产生肉眼可见的掉帧。第三,选中态高亮、候选数九宫格小字、冲突标红这些效果叠加起来,Widget嵌套层级会越来越深,代码可读性急剧下降。
3.2 为什么CustomPaint更适合游戏类棋盘
CustomPaint的核心思路是"一次性绘制,需要时重绘"。棋盘本身只有静态线条,加上数字和几种高亮状态,这些完全可以用Canvas的API画出来。每次状态变化(填数字、选中格子、标候选数)时,调用painter.repaint(),只重绘一帧,不涉及Widget树的diff和reconcile,开销要小得多。
更重要的是,CustomPaint的代码结构非常适合表达"棋盘"这个视觉概念。线条是线条、数字是数字、底色是底色,各自的绘制逻辑在paint()方法里按顺序排列,读代码的人一眼就能看懂整个棋盘是怎么画出来的。而Widget组合方案里,棋盘的样子被拆散到81个格子的独立Widget中,全局的视觉结构反而看不清楚。
3.3 绘制层和数据层解耦的接口设计
用CustomPaint时我踩过一个坑:把数据模型直接塞进Painter。最开始我让SudokuBoardPainter持有SudokuBoard的引用,paint方法里直接访问cell.value。后来发现这给测试带来很大麻烦:想单独测试Painter的绘制效果,必须先构造一整套游戏逻辑。更好的做法是定义一个纯绘制的视图模型:
class SudokuBoardPainter extends CustomPainter { final List<int> cellValues; // 长度81,0表示空 final List<bool> fixedFlags; // 是否预置数字 final List<int> candidateMasks; // 候选数掩码 final int selectedIndex; // 当前选中格,-1表示未选中 final Set<int> conflictIndexes; // 冲突格子集合 SudokuBoardPainter({ required this.cellValues, required this.fixedFlags, required this.candidateMasks, this.selectedIndex = -1, this.conflictIndexes = const {}, }); }这样Painter不关心数据从哪来,只需要把传入的列表渲染到画布上。游戏逻辑层只要在paint回调前把数据捏成这几个List,再调setState触发重绘就行。解耦之后的另一个好处是:我可以直接写一个页面,传入写死的List来预览棋盘效果,完全不依赖游戏启动流程。
4. 数独棋盘的绘制核心:从细线、粗线到数字与候选数
进入正题。这一段我完整走一遍paint方法里从画布初始化到数字绘制的每个环节。我用的逻辑分辨率按设备宽度自适应,棋盘始终是正方形。为方便说明,先定义两个常量:
final double cellSize = size.width / 9; final Offset boardOrigin = Offset.zero;cellSize是每个格子的边长,boardOrigin是棋盘左上角。数独棋盘一定是正方形,这没什么可商量的——9x9标准数独本身就是一个大正方形分成9个3x3小正方形。
4.1 棋盘底色与选中格高亮
第一步画底色。整个棋盘用一个圆角矩形填白,或者填上适合夜间模式的深色。我倾向于棋盘的底色跟页面背景做个轻微区分,这样棋盘有一个"浮起来"的视觉层次。
选中格高亮在数据层之后画,这样高亮色会被后续的线条和数字覆盖。选中的格子用浅蓝灰色填充,同行同列和同宫用更淡的颜色,这样玩家能一眼看出当前选中格的影响范围——这是数独App的标配交互。
// 高亮同行同列同宫 for (int r = 0; r < 9; r++) { for (int c = 0; c < 9; c++) { bool inSameRow = r == selectedRow; bool inSameCol = c == selectedCol; bool inSameBlock = (r ~/ 3 == selectedRow ~/ 3) && (c ~/ 3 == selectedCol ~/ 3); if (inSameRow || inSameCol || inSameBlock) { paint.fillRect(...) // 淡色,0x0F000000 这类低透明度 } } } // 再单独画选中格,颜色稍深这里有个性能上的小优化:用Canvas画矩形时,不要一个格子一个格子地saveLayer,尽量合并同颜色的绘制操作。如果一行的高亮颜色都一样(同行),可以先算出这行的矩形区域,一个drawRect搞定。实测中这种优化在低端鸿蒙设备上能省下不少GPU填充时间。
4.2 九宫格细线与3x3宫粗线的画法
线条是棋盘绘制中最容易画得"脏"的部分。我的经验是:先画细线,再画粗线,最后画外框。为什么这个顺序?因为粗线会覆盖细线在宫边界处的断口,让交叉点看起来干净利落。
细线用Paint()..color = 细线颜色..strokeWidth = 0.8,画法是从第1条到第8条竖线和横线,不画第0条和第9条——这两条留给粗线处理。竖线的x坐标是i * cellSize,横线的y坐标是i * cellSize。
粗线用strokeWidth = 2.2,只在i等于3和6的位置画,对应3x3宫的边界。外框再用strokeWidth = 3画一个完整的矩形。
final Paint thinLine = Paint() ..color = const Color(0xFFB0B0B0) ..strokeWidth = 0.8; final Paint thickLine = Paint() ..color = const Color(0xFF404040) ..strokeWidth = 2.2; for (int i = 1; i < 9; i++) { final double x = i * cellSize; canvas.drawLine(Offset(x, 0), Offset(x, size.width), thinLine); final double y = i * cellSize; canvas.drawLine(Offset(0, y), Offset(size.width, y), thinLine); } for (int i = 3; i < 9; i += 3) { final double x = i * cellSize; canvas.drawLine(Offset(x, 0), Offset(x, size.width), thickLine); final double y = i * cellSize; canvas.drawLine(Offset(0, y), Offset(size.width, y), thickLine); } canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.width), thickLine);注意粗细线的颜色搭配:细线的颜色不要太深,灰度大概在#B0B0B0附近即可,粗线颜色深一个档次,#404040,外框接近纯黑。这样层次分明,玩家不会把粗线和细线看混。
4.3 数字用TextPainter绘制,注意对齐和字号
数字绘制是TextPainter的典型应用。这里有个新手常犯的错误:直接把TextPainter.textDirection写成TextDirection.ltr但忘了TextAlign.center,结果数字在格子里偏上偏左。
我的做法是先算好文本布局,再按中线对齐绘制:
void _drawNumber(Canvas canvas, String text, Rect cellRect, Color color, double fontSize) { final TextPainter tp = TextPainter( text: TextSpan(text: text, style: TextStyle(color: color, fontSize: fontSize)), textDirection: TextDirection.ltr, )..layout(); final Offset offset = Offset( cellRect.center.dx - tp.width / 2, cellRect.center.dy - tp.height / 2, ); tp.paint(canvas, offset); }字号怎么定?我的经验值是cellSize * 0.55。再大会顶到格子边框,再小看起来留白太多。预置数字用黑色,玩家填入的数字用主题色(比如蓝色),冲突数字用红色,这三种颜色足以表达数独棋盘的全部语义信息。
4.4 候选数字的九宫格小字布局
候选数如果只是纯文本拼在数字下面,会乱。标准做法是:空格里用3x3的小九宫格布局,1到9分别落在对应的位置上。这样玩家一眼就能看出某格还缺哪个数。
实现思路是:把格子Rect均分成3x3,每个小矩形中心放一个候选数字。字号通常是cellSize * 0.24左右,颜色用浅灰。位掩码在这里就派上用场了——不需要循环9次判断,直接现查bit位:
for (int digit = 1; digit <= 9; digit++) { if ((candidateMask & (1 << (digit - 1))) != 0) { int subRow = (digit - 1) ~/ 3; int subCol = (digit - 1) % 3; Rect subRect = Rect.fromLTWH( cellRect.left + subCol * cellSize / 3, cellRect.top + subRow * cellSize / 3, cellSize / 3, cellSize / 3, ); _drawNumber(canvas, '$digit', subRect, candidateColor, candidateFontSize); } }这里有个重要的视觉细节:如果格子填了数字(非0),就不画候选数。逻辑上数字和候选数是互斥的,绘制时也要有这个判断,不然数字底下会浮现一层小字,看起来脏。
5. 点击交互与选中高亮:让棋盘真正能玩起来
棋盘能画出来只是第一步,能点、能选中、能填数才是真正的交互闭环。CustomPaint的点击事件不像Widget那样有现成的InkWell,需要自己在手势回调里做坐标换算。
5.1 触摸坐标换算成格子索引
给CustomPaint外包一层GestureDetector,onTapDown回调里拿到TapDownDetails.localPosition,也就是相对于CustomPaint左上角的坐标。格子索引计算:
int col = (tapPosition.dx / cellSize).floor(); int row = (tapPosition.dy / cellSize).floor(); if (row < 0 || row > 8 || col < 0 || col > 8) return;为什么要用floor()而不是四舍五入?因为格子的边界是[i * cellSize, (i+1) * cellSize)这种左闭右开区间。用户点到格子的左边线时,dx/cellSize正好等于i,floor之后落在第i格,符合直觉;如果用round,靠近左边线时会错位到前一格,体验很怪。
5.2 数字键盘与格子数据联动
选中格子后,底部弹出一个数字键盘,点击数字后要做三件事:
- 如果格子是预置数字,拒绝修改(用震动或轻提示反馈)。
- 检查冲突,给
isConflict赋值。 - 更新
cellValues,触发setState重绘。
void _onDigitSelected(int digit) { SudokuCell cell = _board.cellAt(_selectedRow, _selectedCol); if (cell.isFixed) return; cell.value = digit; cell.candidateMask = 0; cell.isConflict = !_board.isValidPlacement(_selectedRow, _selectedCol, digit); setState(() {}); }冲突检测的调用时机要特别注意:不要在每个格子填完后去全盘扫描81个格子,而是只检查当前格子与同行同列同宫已有数字的冲突。全盘扫描在棋盘有大量预置数字时会误伤——预置数字之间理论上不会冲突,但你新填的数字可能会让"行里出现两个4",这种冲突只跟当前格有关,局部检查足够。
5.3 重绘范围的艺术:shouldRepaint的判断
CustomPainter有一个shouldRepaint方法,决定Widget重建时是否需要重绘。合理的实现是:对比新旧painter持有的数据是否发生变化,只有变化时才返回true。
@override bool shouldRepaint(SudokuBoardPainter oldDelegate) { return oldDelegate.selectedIndex != selectedIndex || !listEquals(oldDelegate.cellValues, cellValues) || !listEquals(oldDelegate.fixedFlags, fixedFlags) || !setEquals(oldDelegate.conflictIndexes, conflictIndexes); }这个判断的价值在于:当页面发生无关的Widget重建(比如弹窗动画触发父级重建)时,painter可以告诉引擎"画布没变,别重绘",省下整帧的绘制开销。在OpenHarmony设备上,这个优化对滑动流畅度的影响非常明显。
6. Flutter for OpenHarmony的适配细节与真机坑点
前面讲的是通用Flutter能力,这节专门讲Flutter for OpenHarmony环境的特殊问题。如果你只是在标准Flutter上写数独棋盘,直接跳过这节;但如果你要在鸿蒙设备上跑起来,下面这些坑可能都会遇到。
6.1 环境配置与版本选择的实际经验
Flutter for OpenHarmony的开发环境我试下来是这样:装好DevEco Studio和OpenHarmony SDK后,拉取社区维护的flutter_flutter分支,替换掉标准Flutter SDK路径。注意系统Flutter和OpenHarmony Flutter不能混用,IDE里要单独指定SDK路径。
版本选择上不要太激进。我当时用的是3.x系列的某个稳定分支,因为社区分支的更新节奏比标准Flutter慢,你追新版本会遇到一些上游代码还没合并完的问题。选一个经过验证的稳定版本,比追求新功能重要得多。
6.2 真机调试时的绘制表现
数独棋盘绘制在真机上有一个比较隐蔽的问题:OpenHarmony设备的屏幕密度差异很大,低端设备上strokeWidth设置不当会导致细线直接消失,因为1物理像素都不到。解决办法是拿MediaQuery.devicePixelRatio做基准,保证细线至少2个物理像素:
final double logicalThin = 0.8; final double minPhysicalLine = 2 * devicePixelRatio; // 物理像素不少于2px final double actualThin = max(logicalThin, minPhysicalLine);实测下来这个处理很有必要。有个设备dpr是3.5,0.8逻辑像素只相当于2.8物理像素,勉强可看;另一个设备dpr低一些,0.8逻辑像素对应的物理像素不足2,细线就开始发虚。在鸿蒙设备生态里,设备型号杂、密度差异大,这个判断必须做。
6.3 性能优化:canvas重绘和帧率观察
最后聊聊性能观察。数独棋盘静态绘制本身很轻量,但选中格高亮、候选数更新这些操作在低端设备上可能触发整层重绘。我的经验是打开DevEco的HiLog抓帧率,如果发现掉帧,优先检查是不是把setState范围搞大了——把setState从SudokuBoardPainter所在的整个页面缩小到只包裹CustomPaint组件,或者干脆用ValueNotifier驱动painter更新。
另一个调优点是候选数的绘制频率。候选数小字在初始阶段可能要画几百个TextPainter,每个都得layout,这是重绘时的最大开销。我的优化是:如果连续两次重绘之间数字值和候选数都没变,只是选中格变了,那候选数部分可以直接复用上一帧的缓存结果,只重画高亮和线条。实测这个优化能把重绘耗时从25ms压到10ms以内,在60Hz屏幕上基本可以保证不丢帧。
从棋盘到完整游戏:一点扩展建议
到这里,数独棋盘绘制的核心环节已经全部分解完了。从实际项目经验来看,棋盘绘制不是终点,它只是游戏交互的起点。你可以在这个基础上继续加:
- 数字键盘的动画弹出与隐藏,滑动选择多格连续填充
- 橡皮擦模式,点击格子清除数字
- 计时器与错误次数的统计面板,与棋盘状态联动
- 夜间模式切换时,painter里所有颜色换成深色调
我个人在实际操作中最大的体会是:绘制层一定要薄,数据层一定要厚。把游戏逻辑全部放在数据模型里,painter只做"读取状态、渲染画面"这一件事,后续加功能会轻松很多。刚开始写这个数独棋盘的时候,我也犯过把逻辑塞进paint方法的错误,后来重构了一次才理顺。希望你在动手的时候,直接走在正确的路上。