最近一直在折腾 Flutter for OpenHarmony,目标很直接:在一台 RK3568 开发板上跑通一套游戏集合 App,把五子棋、贪吃蛇、2048 这些小游戏全部塞进去。这个系列的第一步,我选了五子棋。原因很朴素:它是典型的"看着简单、做起来全是细节"的项目,一块 15x15 的棋盘,能把 Canvas 绘制、坐标换算、状态管理、胜负判定这些游戏开发的基础环节全部串起来。这篇文章就把五子棋棋盘绘制的完整实现过程、踩过的坑、以及从"能画"到"能玩"的扩展思路写清楚,给正在用 Flutter 开发 OpenHarmony 应用的开发者一个可直接参考的路径。
阅读这篇文章,你需要对 Flutter 的 Widget 和 State 有基本认知,但不用多深——我会把每一步背后的原理和选择逻辑都讲明白。不管你是刚开始接触 Flutter for OpenHarmony,还是已经在做游戏类应用,这篇内容应该都能帮你省掉不少自己填坑的时间。
1. 为什么选 Flutter 来啃 OpenHarmony 这块硬骨头
1.1 Flutter for OpenHarmony 到底解决了什么问题
OpenHarmony 的应用开发有自己的 UI 框架 ArkUI,写简单应用完全没问题。但如果你和我一样,有大量跨端开发需求,或者团队里已有的技术栈就是 Flutter/Dart,那 Flutter for OpenHarmony 的价值就非常明显。它保留了 Flutter 的渲染引擎、组件模型和开发体验,把底层能力映射到 OpenHarmony 的系统服务上,实现"一套代码,多端运行"。
这背后的核心是 Flutter 的自绘机制。Flutter 不依赖系统原生控件去渲染 UI,而是通过 Dart 层发出的绘制指令,由底层的渲染引擎生成最终画面。在 OpenHarmony 平台上,这套绘制指令会被对接到底层图形栈,所以理论上 Flutter 应用的绘制性能和交互流畅度是可控的,不会因为系统组件风格不同而产生割裂感。
我选 Flutter 还有一个很现实的原因:游戏集合 App 这类应用,界面中的棋盘、方块、地图几乎全是自绘需求。如果每一块都用系统原生控件去拼,复杂度和维护成本都会很高。Flutter 的 CustomPainter 直接面对 Canvas 编程,恰好是做游戏 UI 的最顺手工具——它不关心控件层级,只关心"往哪画、画什么"。
1.2 游戏集合 App 的第一块拼图,为什么是五子棋
很多人可能会觉得,五子棋棋盘不就是画几条线吗,有什么好讲的?真正动手做之后才发现,它把游戏开发里最关键的几个环节全占齐了:
- 图形渲染:15x15 的网格线、星位、棋子的立体感表现;
- 交互反馈:手指点击位置的精确换算、落子命中判断;
- 状态管理:棋局数据模型、回合切换、悔棋重置;
- 算法逻辑:五连棋形的胜负判定,后续还能扩展 AI 对战。
这四块内容如果能在 OpenHarmony 设备上全部跑通,那整个游戏集合 App 的地基就算打好了。后续往里面加贪吃蛇、俄罗斯方块,本质上都是在复用这套已经验证过的架构——数据模型与 UI 解耦、自绘组件独立、交互逻辑可测试。
而且五子棋是典型的"学习成本曲线平缓"的项目。它不像贪吃蛇那样强依赖定时器和移动方向随时变化,也不像俄罗斯方块那样需要处理复杂的旋转矩阵和消行判定。五子棋的核心难点集中在 Canvas 绘制和坐标换算上,对 Flutter 开发者来说,这两个点恰恰是进入游戏开发最需要掌握的基本功。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 十五路棋盘绘制:数据模型和 Canvas 实现
2.1 先建模再画图:棋盘状态用二维数组还是别的结构
这里想先说一个容易踩的误区。挺多新手拿到"画棋盘"这个需求,第一反应是打开代码直接画线,画完网格觉得万事大吉。但真到了要落子、判胜负、悔棋的时候,发现数据没地方存,每画一笔都要临时从 UI 状态里抠数据,代码乱成一团。
我强烈建议:写绘制代码之前,先把棋盘数据模型定义好。五子棋的棋盘是固定 15x15 的矩形空间,最直接的数据结构就是一个二维数组,每个位置存放 0、1、2 三个值,分别代表空、黑子、白子。
dart复制class GobangBoard {
static const int size = 15;
final List<List<int>> _cells;
GobangBoard()
: _cells = List.generate(
size,
(_) => List<int>.filled(size, 0),
);
int getCell(int row, int col) => _cells[row][col];
bool place(int row, int col, int player) {
if (row < 0 || row >= size || col < 0 || col >= size) return false;
if (_cells[row][col] != 0) return false;
_cells[row][col] = player;
return true;
}
void reset() {
for (var row = 0; row < size; row++) {
for (var col = 0; col < size; col++) {
_cells[row][col] = 0;
}
}
}
}
为什么用二维数组,不用 Map 之类的结构?两个原因。第一,二维数组的随机访问复杂度是 O(1),getCell(row, col) 直接按下标取值,这对后续频繁的胜负判定扫描非常友好——四方向连续扫描棋子时,数组下标遍历是最自然的方式。第二,棋盘的行列语义天然和数组的二维下标对应,代码可读性高,不容易出错。
用 Map 去存储键值对也不是不行,但五子棋棋盘是固定大小的矩形空间,Map 的哈希开销在密集访问场景下完全没有必要。等到后面要做 19 路围棋棋盘时,这套二维数组模型只需要改一个 size 常量就能复用。
2.2 CustomPainter 绘制 15 路棋盘:网格、星位、棋子
数据模型定好之后,绘制逻辑就清晰了。Flutter 里所有自绘内容都通过 CustomPaint + CustomPainter 完成。CustomPainter 的 paint 方法会给你一个 Canvas 对象,你可以像在纸上画画一样,画线、画圆、画矩形。
棋盘绘制第一步,计算单元格尺寸。假设棋盘宽度等于 CustomPaint 的宽度,左右各留一个间距 margin,那么相邻两条网格线之间的距离是:
dart复制final cellSize = (size.width - margin * 2) / (GobangBoard.size - 1);
这里有 15 条竖线、15 条横线,线间距是 14 段,所以分母是 size - 1。这个细节特别容易算错:有的人把 15 条线当成 15 个格子,结果棋盘画出来左边多一块、右边少一块。
接着是绘制代码:
dart复制class BoardPainter extends CustomPainter {
final GobangBoard board;
BoardPainter(this.board);
static const double margin = 16;
@override
void paint(Canvas canvas, Size size) {
final cellSize = (size.width - margin * 2) / (GobangBoard.size - 1);
// 1. 棋盘底色
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
const Radius.circular(8),
),
Paint()..color = const Color(0xFFE8C97A),
);
// 2. 网格线
final linePaint = Paint()
..color = const Color(0xFF5A4A3A)
..strokeWidth = 1.2;
for (var i = 0; i < GobangBoard.size; i++) {
final offset = margin + i * cellSize;
canvas.drawLine(
Offset(margin, offset),
Offset(size.width - margin, offset),
linePaint,
);
canvas.drawLine(
Offset(offset, margin),
Offset(offset, size.height - margin),
linePaint,
);
}
// 3. 星位
final starPoints = [
Offset(margin + 3 * cellSize, margin + 3 * cellSize),
Offset(margin + 11 * cellSize, margin + 3 * cellSize),
Offset(margin + 7 * cellSize, margin + 7 * cellSize),
Offset(margin + 3 * cellSize, margin + 11 * cellSize),
Offset(margin + 11 * cellSize, margin + 11 * cellSize),
];
for (final point in starPoints) {
canvas.drawCircle(point, 4, Paint()..color = const Color(0xFF4A3A28));
}
// 4. 棋子
for (var row = 0; row < GobangBoard.size; row++) {
for (var col = 0; col < GobangBoard.size; col++) {
final value = board.getCell(row, col);
if (value == 0) continue;
final center = Offset(
margin + col * cellSize,
margin + row * cellSize,
);
_drawPiece(canvas, center, cellSize * 0.43, value);
}
}
}
@override
bool shouldRepaint(covariant BoardPainter oldDelegate) {
return oldDelegate.board != board;
}
}
这段代码里最值得说的是棋子的圆心定位。五子棋的落子位置在网格线的交叉点上,不在格子中心。很多人一开始想当然地把棋子画到格子正中间,结果整个棋盘的视觉完全不对。正确的公式是:
dart复制final center = Offset(
margin + col * cellSize,
margin + row * cellSize,
);
col 是列索引,从 0 到 14;cellSize 是相邻交叉点之间的距离。列索引乘以间距再加上左边距,就是该列第一个交叉点的位置,后面每一列依次加一个 cellSize。
2.3 棋子的立体感:用渐变画出视觉舒适的棋子
黑白棋如果只用 canvas.drawCircle 填充一个纯色圆,画出来会显得非常"平",像贴纸一样。要让棋子看起来有立体的质感,最简单有效的方法是加放射渐变——模拟棋子顶部受光、边缘逐渐变暗的效果。
dart复制void _drawPiece(Canvas canvas, Offset center, double radius, int player) {
final paint = Paint();
if (player == 1) {
paint.shader = RadialGradient(
colors: const [
Color(0xFF6A6A6A),
Color(0xFF222222),
],
radius: 0.9,
).createShader(
Rect.fromCircle(center: center, radius: radius),
);
} else {
paint.shader = RadialGradient(
colors: const [
Color(0xFFFAFAFA),
Color(0xFFCCCCCC),
],
radius: 0.9,
).createShader(
Rect.fromCircle(center: center, radius: radius),
);
}
canvas.drawCircle(center, radius, paint);
}
很多初学者在这里会花很多时间去研究径向渐变参数,其实不用太纠结。RadialGradient 的 colors 是从圆心到边缘的颜色序列,radius 控制渐变的覆盖范围,0.9 的时候边缘还能保留一点底色,视觉上最自然。实测在 RK3568 开发板上,15x15 棋盘全量重绘,没有任何顿挫感。这说明 CustomPainter 的自绘性能在 OpenHarmony 平台上是完全够用的。
另一个细节:棋子半径我取了 cellSize * 0.43,而不是 cellSize / 2。留出一点间距,是为了避免相邻棋子视觉上"粘"在一起。标准围棋棋盘上棋子之间是有呼吸感的,这个比例是反复调出来的结果。
2.4 shouldRepaint 和 RepaintBoundary:让重绘按需发生
CustomPainter 有一个配套方法 shouldRepaint,它决定当 Widget 重建时,底层绘制是否需要重新执行。如果不重写这个方法,每次父 Widget setState 都会触发整个棋盘重新绘制;如果重写了但返回 false,则棋盘画面不会刷新。
我的做法是让它对比棋盘数据模型:
dart复制@override
bool shouldRepaint(covariant BoardPainter oldDelegate) {
return oldDelegate.board != board;
}
这样只有当棋盘里的棋子数据发生变化时,才会真正执行 paint 方法重新绘制。为什么要在意这一点?因为 Flutter 页面里除了棋盘,通常还有计分板、按钮、提示文字。如果不做任何控制,棋盘区域会因为其他状态的更新而被反复重绘,浪费不必要的性能开销。
配合 RepaintBoundary 把棋盘单独包起来,效果更好:
dart复制RepaintBoundary(
child: CustomPaint(
painter: BoardPainter(_board),
size: Size.square(360),
),
)
RepaintBoundary 会把棋盘绘制隔离到独立的图层里。当页面其他部分发生重绘时,棋盘图层不会跟着一起重绘,这在后面加游戏菜单、弹窗的时候特别有用。
3. 落子交互与坐标换算:手指到棋盘的精确映射
3.1 GestureDetector 拿到一次干净准确的点击
棋盘画好之后,下一步是让用户能点击落子。Flutter 里处理点击事件最直接的方式是用 GestureDetector 包住 CustomPaint,监听对应的回调。
这里有一点容易忽略:选 onTapUp 还是 onTapDown。我的经验是优先用 onTapUp。原因很直觉——onTapDown 在手指按下的那一瞬间就触发了,用户这时候手指可能还在移动、调整落点;onTapUp 在手指抬起时才触发,这时候用户已经确认了要落子的位置,体验上更符合"确定下棋"的意图。
dart复制GestureDetector(
onTapUp: _handleTap,
child: CustomPaint(
painter: BoardPainter(_board),
size: const Size.square(360),
),
)
3.2 坐标换算:从像素坐标到行列索引
点击事件回调里会给一个 TapUpDetails 对象,通过 details.localPosition 能拿到触摸点在棋盘上的局部坐标。我们需要把这个像素坐标映射成棋盘的行列索引。
这个换算过程本质上是绘制过程的逆运算。绘制时我们是这么定位棋子的:
dart复制x = margin + col * cellSize
y = margin + row * cellSize
反过来,已知 x 和 y,求 row 和 col:
dart复制row = ((local.dy - margin) / cellSize).round()
col = ((local.dx - margin) / cellSize).round()
用 round() 做四舍五入,是因为用户点击的位置不一定是精确的交叉点,我们要把它吸附到最近的交叉点上。视线上的原理:如果在第一行附近点击,local.dy - margin 的值接近 0,除以 cellSize 后接近 0,round 之后就是 0,对应第 0 行;如果点击位置在第一条线和第二条线的正中间,这个值接近 0.5,round 之后是 1,对应第 1 行。
这段代码看起来简单,但边界判断一定不能省:
dart复制void _handleTap(TapUpDetails details) {
final local = details.localPosition;
// 先检查是否在棋盘范围内
if (local.dx < BoardPainter.margin || local.dy < BoardPainter.margin) return;
if (local.dx > size.width - BoardPainter.margin || local.dy > size.height - BoardPainter.margin) return;
final row = ((local.dy - BoardPainter.margin) / _cellSize).round();
final col = ((local.dx - BoardPainter.margin) / _cellSize).round();
if (row < 0 || row >= GobangBoard.size || col < 0 || col >= GobangBoard.size) {
return;
}
if (!_board.place(row, col, _currentPlayer)) return;
setState(() {
_currentPlayer = 3 - _currentPlayer;
});
}
如果把边距区域也当成有效点击区,用户点在棋盘外圈附近时,round() 之后的索引可能偏移成负数或者 15 这种越界值,轻则落子位置不对,重则数组越界直接崩掉。另外,_board.place 里已经有"该位置是否为空"的判断了,这一步不要省,双保险。
3.3 落子后的状态刷新与动画节奏
落子成功后调用 setState,Widget 重建,CustomPaint 的 shouldRepaint 检测到棋盘数据模型变了,触发 paint 重绘。这是整个流程闭环的最后一块。
要不要加落子动画?我的建议是先把逻辑跑对,再考虑动作表现。很多开发者一上来就加各种动画效果,结果在调试的时候很难分清问题是出在逻辑上还是出在动画打断上。等基础流程稳定了,可以用 TweenAnimationBuilder 做棋子的出现缩放,或者用 AnimatedContainer 做透明度的过渡,这些都是锦上添花。
在 OpenHarmony 真机上尤其要注意这一点。开发板性能有限,动画本身可能不明显,但动画导致的额外重绘,在低端设备上是可以被感知到的。扎实的状态流转优先于好看的效果,这是我在实际开发里反复确认过的原则。
4. 在 RK3568 开发板上跑通的踩坑实录
4.1 环境搭建里最耗时的坑:版本匹配
Flutter for OpenHarmony 的开发环境和官方 Flutter 开发有差异,最典型的就是 SDK 版本匹配问题。你在 DevEco Studio 里新建工程,导入 Flutter 的 OpenHarmony 适配分支后,有三个版本必须对齐:OpenHarmony 系统镜像的 API 版本、DevEco Studio 内置 SDK 的编译版本、Flutter/Dart SDK 的适配版本。
我实际遇到的一个报错是构建时提示 dev.flutter.flutter-plugin-loader 插件解析失败,还有一次是 Gradle 同步时出现 applying the Flutter's main Gradle plugin imperatively 的告警。这两个问题本质上都是版本不匹配导致的。解决方案是:
- 先查清楚开发板烧录的系统镜像对应 OpenHarmony API 级别;
- 根据这个 API 级别选择匹配的 Flutter 适配分支;
- 在 DevEco Studio 里同步更新 Gradle 插件版本,不要使用默认最新版本。
一个更稳妥的做法是先跑官方仓库里的示例工程,验证环境能编译运行后,再开始自己的项目。否则代码写了一大堆,最后发现是环境问题,排查起来非常痛苦。
4.2 真机调试:USB 连接识别与日志过滤
把五子棋应用跑上 RK3568 开发板,第一步是让开发工具识别设备。OpenHarmony 设备有些不会像 Android 那样直接通过 adb 暴露出来,需要先用命令行确认 devudid 和 serial 是否被正确识别。
如果设备列表刷不出来,先别怀疑代码,检查三件事:
- USB 线是不是数据线,很多 Type-C 线只能充电不能传数据;
- 开发板的开发者模式是否开启,USB 调试权限是否授予;
- 驱动或命令行工具是否匹配当前开发板的 OpenHarmony 版本。
日志方面,Flutter 的 debugPrint 在开发阶段强烈建议保留,它可以直接输出到 DevEco Studio 的 Log 面板。如果你想用命令行工具抓完整日志,注意过滤 Flutter 相关的 TAG,否则会被系统大量底层日志淹没,很难定位到自己的问题。
4.3 绘制与交互层在 OpenHarmony 上的适配小问题
CustomPainter 的 Canvas API 在 OpenHarmony 上兼容性不错,绘制逻辑基本一通到底。真正的坑在平台能力调用,比如输入法、弹窗、剪贴板这类涉及系统服务的功能。
我在同一个 App 里后续加了排行榜功能,页面里有 TextField 输入昵称,还要弹出一个底部弹窗确认信息,结果发现 OpenHarmony 某些版本对键盘高度变化的通知时机不一样,弹窗内容会被键盘挡住。这个问题的根源不是 Flutter 本身,而是平台层的差异。
解决办法是不要把布局的调整完全托付给默认的 resizeToAvoidBottomInset,而是自己监听键盘的遮挡区域,或者用全屏自定义弹层来规避。五子棋模块本身用不到键盘,但同一个 App 里模块之间是共享页面框架的,这块问题早晚会遇到,提前有认知会让你排查速度更快。
5. 从"能画"到"能玩":五子棋模块的后续扩展
5.1 胜负判定:四方向扫描检测五连棋形
棋盘能画、能落子,这还只是一个"画板"。要让五子棋可玩,第一步是补上胜负判定。最简单的实现思路:每次落子后,以当前落子位置为中心,沿水平、垂直、主对角线、副对角线四个方向分别向两边延伸计数,统计同色棋子的连续个数,达到 5 就判定胜出。
dart复制bool checkWin(int row, int col, int player, GobangBoard board) {
const directions = [
[1, 0], // 水平
[0, 1], // 垂直
[1, 1], // 主对角线
[1, -1], // 副对角线
];
for (final dir in directions) {
var count = 1;
for (var step = 1; step < 5; step++) {
final r = row + dir[0] * step;
final c = col + dir[1] * step;
if (r < 0 || r >= GobangBoard.size || c < 0 || c >= GobangBoard.size) break;
if (board.getCell(r, c) != player) break;
count++;
}
for (var step = 1; step < 5; step++) {
final r = row - dir[0] * step;
final c = col - dir[1] * step;
if (r < 0 || r >= GobangBoard.size || c < 0 || c >= GobangBoard.size) break;
if (board.getCell(r, c) != player) break;
count++;
}
if (count >= 5) return true;
}
return false;
}
这里每次落子只检查当前落子点,并不需要检查整个棋盘,所以效率是很高的——理论上一个棋子最多影响四个方向,每个方向最多向两侧延伸 4 步,总判断次数非常少,不需要任何优化就能跑得飞快。
5.2 一个简单 AI 的思考路径:从随机落子到攻防评分
人机对战模式是五子棋棋盘绘制的自然延伸。最简单的 AI 是随机落子,但体验太差,玩两盘就没意思了。稍微提升一个档次的方案是"攻防双评分":
- 遍历棋盘上所有空位;
- 对每个空位计算"AI 落在这里能形成的攻击价值",比如活三、冲四、连五;
- 再计算"如果对手落在这里能形成的威胁价值";
- 最终得分 = 己方攻击分 * 1.1 + 对手威胁分 * 1.0,取最高分的空位落子。
为什么要给己方攻击分加一个略高的权重?因为五子棋先手优势明显,进攻效率通常比防守更值钱。这个权重比例不是理论推出来的,是实际对弈测试调出来的。再往上就是 minimax 加 alpha-beta 剪枝,配合一个稍好一点的评估函数,在 15x15 棋盘上也能做到 3-4 层的搜索深度。
这套 AI 逻辑和 Flutter UI 是完全解耦的,建议单独抽成一个类,方便用纯 Dart 写单元测试。
5.3 把棋盘模块抽象出来,服务更多格子类游戏
做完五子棋之后,我最大的体会是:不要为单一游戏写死结构性的代码。棋盘数据模型、CustomPainter、坐标换算逻辑,这三块内容其实可以抽象成通用组件,服务于其他格子类游戏。
比如国际跳棋,棋格是格子而不是交叉点,但坐标换算的核心思路完全一样——像素坐标除以单元格大小,取整得到行列索引;扫雷也一样,格子的 hitTest 判断和五子棋的落子命中判断没有本质区别。围棋更不用说,同样的交叉点模型,把 size 从 15 改成 19,再加提子和打劫规则即可。
在我自己的游戏集合 App 里,我把棋盘做成了一个独立 widget,数据模型通过参数注入,绘制逻辑根据传入的配置生成不同的棋盘样式。这样每个游戏一个目录,互不污染,新游戏进来只需要专注自己的规则逻辑,画布部分不用重复写。
最后再分享一个实际操作中的体会:在开发阶段,无论代码逻辑多简单,都建议先在模拟器里把"绘制-落子-胜负判定"这条链路完整跑一遍,再上真机。OpenHarmony 的模拟器和真机在渲染层会有细微差异,但在开发环境和真机调试之间切换调试的成本远高于先确认逻辑正确。五子棋棋盘绘制只是游戏集合 App 的第一步,但这一步走得稳,后面的贪吃蛇、2048、俄罗斯方块都会轻松很多。
