ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter for OpenHarmony数独棋盘:CustomPaint绘制与数据模型实战

Flutter for OpenHarmony数独棋盘:CustomPaint绘制与数据模型实战 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 ~/ 9col 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; }棋盘类持有一个ListSudokuCell长度固定为81。为什么用一维列表而不是二维数组因为遍历的时候一维列表最方便for (final cell in cells)直接扫完。需要按行列访问时用索引换算成本极低。class SudokuBoard { static const int size 9; final ListSudokuCell 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中的某些数字把那些数字列出来就是候选数。实现候选数有两种常见方案Listint或位掩码int。Listint直观但每次都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 Listint cellValues; // 长度810表示空 final Listbool fixedFlags; // 是否预置数字 final Listint candidateMasks; // 候选数掩码 final int selectedIndex; // 当前选中格-1表示未选中 final Setint 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外包一层GestureDetectoronTapDown回调里拿到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, (i1) * cellSize)这种左闭右开区间。用户点到格子的左边线时dx/cellSize正好等于ifloor之后落在第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.50.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方法的错误后来重构了一次才理顺。希望你在动手的时候直接走在正确的路上。
返回列表