如果你正在关注OpenHarmony生态,同时也想让一套UI代码跑在更多设备上,那Flutter这个名字你大概率早就听过了。真正动手的时候,很多人会卡在同一个问题上:环境配好了、示例工程能跑了,然后呢?我自己也是绕了不少弯路才走出来,直到我决定用俄罗斯方块当一个完整练习项目,才把“Flutter代码在鸿蒙上怎么组织、怎么落地”这件事真正串了起来。
这个系列会从零到一实现一个可以在OpenHarmony设备上运行的俄罗斯方块,第一期先把最不能跳过的部分讲透:数据结构与核心算法。为什么先讲这个?因为俄罗斯方块十几行规则背后,其实是一个典型的状态机加一堆几何运算,逻辑不独立出来,后面每个UI改动都会牵一发动全身。正好借这个题,把队列、二维数组、状态机、碰撞检测这些基础概念落一次地。
这篇内容适合正在学Flutter和OpenHarmony的朋友,也适合想复习数据结构但不想再看课本的人。你可以把它当成一份带完整代码思路的项目笔记,照着敲一遍,比自己刷十篇教程管用。
1. 为什么把俄罗斯方块作为OpenHarmony的Flutter练手项目
1.1 一套代码跨平台才是选Flutter的底气
先说为什么是Flutter,而不是别的方案。很多人纠结“openHarmony上到底能不能跑Flutter”“性能会不会很差”。我的结论是:对于俄罗斯方块这种2D游戏,Flutter的渲染管线完全扛得住,而且OpenHarmony社区目前对Flutter的适配进度比很多人想象中要好,组件渲染、事件注入、平台通道这些基础能力都已经可用。相比其他跨端框架,Flutter的优势是自绘渲染,UI在不同平台上的表现一致性高,不会出现在Android上好好的、到别的平台上组件样式走样的问题。
我也看过别的跨端方案,要么是运行时依赖太重,要么是动态化能力有限。Flutter用Dart语言,AOT编译后性能和原生接近,对游戏这种高频刷帧场景尤其合适。再叠加Impeller渲染引擎的持续改进,动画和复杂布局的绘制开销一直在下降。所以选Flutter不是跟风,是综合对比后最顺手的路。
当然,这不代表没有坑。OpenHarmony上的Flutter还处于快速迭代阶段,部分第三方包没有适配鸿蒙,遇到问题可能要自己看平台源码。但这些坑不影响你把核心逻辑先写扎实,反而是本系列想帮你避开的。
1.2 俄罗斯方块的复杂度恰好压在一个舒服的点上
俄罗斯方块这个题目我特别推荐给想练手的人,因为它的复杂度不高不低,正好能覆盖一整条技术栈:数据结构、核心算法、UI渲染、输入事件、状态管理、持久化存储。规则虽然简单,但实现起来牵扯的细节一点都不少,而且每一步的反馈都非常直观,写坏了立刻能在屏幕上看到。这种“坏得很明显”的特性,对学习调试技术非常有帮助。
如果项目太小,比如写个计数器,学不到几何运算和碰撞检测;如果项目太大,比如做一个完整RPG,大概率会在中途放弃。俄罗斯方块就卡在中间这个甜蜜点:一个周末能写完第一版,但想写得好,又能让你沉浸很久。
从数据结构的角度看,它几乎全能:棋盘是一个二维数组,方块是一组坐标集合,预览队列可以用队列,随机生成要用洗牌算法,得分记录需要持久化。这些数据结构不是书本上的抽象概念,而是你每天都要亲手操作的工具。
1.3 逻辑层先独立,避免后面处处打补丁
很多新手一上来就铺界面,把游戏规则直接写在Widget里,结果后面每加一个功能都要翻一遍UI代码。我写这个项目的第一原则是:核心逻辑是纯Dart代码,不进任何Widget。棋盘、方块、碰撞、消行、随机,这些都不依赖Flutter库,只依赖dart:core和dart:collection。
这样做有三个好处。第一,逻辑可以被单元测试覆盖,我在写UI之前就能验证算法对不对。第二,后面如果要换渲染方案,比如换成Canvas专门绘制,或者接入其他游戏引擎,核心代码不用重写。第三,也是很重要的一点,OpenHarmony和Android/iOS的适配差异被挡在UI层之外,核心逻辑天然跨平台。
这个系列的代码结构我会按这样组织:lib/core放游戏逻辑,lib/ui放界面,lib/platform放平台通道相关代码。第一期只做lib/core,后面的章节再往上堆UI。先打地基,再装修,这是写大一点项目时最稳妥的顺序。
2. 数据结构:棋盘、方块、队列的选型与实现
2.1 棋盘:先用二维数组,再用位运算做优化
俄罗斯方块标准棋盘是10列×20行,很多版本会在顶部额外加两行隐藏区,用来预生成和判断死亡,我用的是10×22的设计,其中上面两行作为“天空区”,方块从这里出生。
最简单的数据结构就是二维数组:List<List<int>>,0表示空,1表示已被方块占据。为什么选这个而不是一个“对象数组”?因为格子的状态就是二维网格上的布尔值,用整数二维数组直观且高效,Dart里List的随机访问本身就是O(1),你给一个坐标就能直接定位,不需要链表那种结构。
这里有一个新手很容易踩的坑:行列顺序。我建议统一约定board[row][col],也就是第一维是行(从顶部到底部),第二维是列(从左到右)。如果一会儿用board[x][y],一会儿用board[y][x],后面旋转和碰撞检测绝对出错。
看一小段初始化代码:
class TetrisBoard { static const int cols = 10; static const int rows = 22; static const int hiddenRows = 2; late List<List<int>> cells; TetrisBoard() { cells = List.generate(rows, (_) => List.filled(cols, 0)); } bool isInside(int row, int col) { return row >= 0 && row < rows && col >= 0 && col < cols; } bool isEmpty(int row, int col) { if (!isInside(row, col)) return false; return cells[row][col] == 0; } }以后做性能优化时,可以把棋盘降成一维数组List<int>,再用位运算判断整行是否满、是否空:比如把一行10格对应到一个int的低10位,满行就是0x3FF。不过第一版不要急着上这种优化,二维数组的可读性和调试便利性远比节省那几KB内存重要。
2.2 方块:相对坐标集合加锚点,不背4x4矩阵
俄罗斯方块七种基本方块:I、O、T、S、Z、J、L。每种形状都有四个旋转状态。很多人第一反应是写一个4×4矩阵来表示每种形状,再存四份旋转结果。这样虽然能用,但冗余量大,而且I方块和O方块的旋转中心跟其他方块不一样,用矩阵容易搞混。
我用的方案是“相对坐标集合+锚点”。每个方块由一个锚点(在当前棋盘中的行列位置)和一组相对于锚点的格子坐标构成。旋转时只需对这组坐标做一个90度旋转变换,然后重新计算绝对坐标。
旋转的数学变换很简洁:顺时针旋转90度,坐标从(x, y)变成(-y, x)。这里坐标系原点在锚点,向右是x正方向,向下是y正方向。
我定义一个枚举加一个形状类:
enum TetrominoType { i, o, t, s, z, j, l } class Shape { final TetrominoType type; final List<(int, int)> offsets; const Shape(this.type, this.offsets); List<(int, int)> rotateRight() { return offsets.map((p) { final (x, y) = p; return (-y, x); }).toList(); } }为什么不用4×4矩阵?因为矩阵里大量格子是空的,旋转时要做整块矩阵变换,计算和存储都是浪费。用坐标集合,一个方块最多4个格子,旋转只要处理4个点。代价是要自己维护锚点偏移,比如I方块和O方块需要特殊处理旋转中心,这一块留到旋转算法小节细说。
2.3 预览队列:双端队列与链表的价值
俄罗斯方块需要一个“下一个方块”的预览,经典实现在后面做7-bag随机时也需要一个队列来缓存方块序列。这里就是数据结构教科书里的“队列”大显身手的地方。
Dart的dart:collection库提供了Queue类,它有addLast和removeFirst两个方法,时间复杂度都是O(1)。如果我用List来模拟,每次从头部移除元素是O(n),因为后面的元素要整体前移。虽然方块序列很短,性能差异不明显,但代码表达上,Queue的语义更贴合场景:一端进,一端出。
而且Queue底层是链表结构,你可以在两端进行操作,这其实就是双端队列的概念模型。首期代码里我只用单端进、单端出,但如果你想做“下一、下二、下三”预览,或者允许玩家和一个“暂存方块”交换,就会发现双端操作的需求随时会出现。我在第一版就把预览列表设计成Queue<TetrominoType>,后面扩展时完全不用改数据结构。
final Queue<TetrominoType> previewQueue = Queue(); void refillQueue() { final bag = _shuffledBag(); previewQueue.addAll(bag); } TetrominoType takeNext() { if (previewQueue.isEmpty) { refillQueue(); } return previewQueue.removeFirst(); }3. 核心算法:从碰撞检测到游戏状态机
3.1 碰撞检测:先走一遍“假动作”,再决定要不要提交
碰撞检测是整个游戏最频繁调用的函数,掉落的每一步、每一次左右移动、旋转、硬降都要先问它一句:这个动作之后的位置合不合法?
实现思路非常朴素:拿到方块当前的所有绝对坐标,尝试应用目标变换,对每个坐标检查两件事。第一,坐标是否在棋盘边界内;第二,坐标对应的格子是否已经被别的方块占着。只有所有格子都通过,这个动作才被允许。
要注意的是,检测过程中绝对不能直接修改棋盘数组。我是这样做的:move函数只返回“是否成功”和“新位置”,真正落到棋盘是在确认动作合法之后。这个原则叫“先试后提交”,相当于你走路之前先拿脚尖探一下地,而不是整个人直接踩过去。
核心函数长这样:
bool canMove(ActivePiece piece, int dRow, int dCol, TetrisBoard board) { for (final (r, c) in piece.cellPositions()) { final nr = r + dRow; final nc = c + dCol; if (!board.isInside(nr, nc)) return false; if (!board.isEmpty(nr, nc)) return false; } return true; }实际移动时,我会先把dRow=1传给这个函数判断能不能下落,能下就更新锚点,不能下就进入“落定”流程。这里有个性能小技巧:一轮游戏大约会调用几百次碰撞检测,每次只遍历最多4个格子,所以完全不必担心性能,真正的瓶颈一般都在UI重建上。
3.2 消行:从下往上扫,先把满行记下来再统一清理
消行逻辑看起来简单:哪行满了就去掉哪行。但如果直接一行行删、删完就更新数组,很容易写出隐藏bug。比如你从上往下扫描,删掉一行后,后续所有行的索引都变了,下一轮循环就容易漏掉或重复处理。
正确做法是:从最后一行开始往上扫,遇到满行先记录行号,但暂不删除。扫描结束后,一次性把所有满行移除,然后在棋盘顶部补相应数量的空行。这样索引问题就消失了,代码逻辑也更清晰。
List<int> clearFullLines(TetrisBoard board) { final fullRows = <int>[]; for (var row = TetrisBoard.rows - 1; row >= 0; row--) { if (board.cells[row].every((cell) => cell == 1)) { fullRows.add(row); } } if (fullRows.isEmpty) return fullRows; for (final row in fullRows) { board.cells.removeAt(row); board.cells.insert(0, List.filled(TetrisBoard.cols, 0)); } return fullRows; }消行数对应分数,我沿用了经典计分:
| 消行数 | 1行 | 2行 | 3行 | 4行 |
|---|---|---|---|---|
| 得分 | 100 | 300 | 500 | 800 |
这里有一个容易忽略的细节:每次落定可能会同时消多行,但一次落定最多只涉及当前方块的4个格子,所以理论上最多消4行。我按完整消行数量处理,一次性结算分数,不用逐行累计。
3.3 旋转与简版墙踢:给方块一个“挣扎空间”
七种方块里,O方块不旋转,其余都旋转。旋转的数学我在前面提过,但实际接入时有一个绕不开的问题:如果一个方块靠墙,旋转后的位置超出了棋盘边界,这时严格禁止旋转会让玩家很难受。经典“墙踢”机制给方块一点挣扎空间:旋转失败时,尝试在水平或垂直方向上偏移1格,看看挪一下能不能转成功。
我第一版用的简版墙踢偏移序列是:(0,0)、(-1,0)、(1,0)、(0,-1)。也就是说,先按原位旋转,不行就左挪一格再转,再不行就右挪一格,再不行就上挪一格。每个尝试都走一次碰撞检测,全部失败才判旋转无效。这个偏移表比标准SRS的墙踢表简化很多,但体验已经足够日常使用。
代码层面是这样的流程:
bool rotatePiece(ActivePiece piece, TetrisBoard board) { final originalOffsets = piece.offsets; final rotatedOffsets = piece.rotatedOffsets(); // 旋转后的相对坐标 for (final (dr, dc) in const [(0, 0), (-1, 0), (1, 0), (0, -1)]) { final temp = piece.copyWith( offsets: rotatedOffsets, row: piece.row + dr, col: piece.col + dc, ); if (_isValidPosition(temp, board)) { piece.apply(temp); return true; } } return false; }实际玩起来你会感受到:靠墙时按旋转,方块会轻轻一弹,而不是直接拒绝。这一点点“宽容度”对游戏手感的提升非常明显。
3.4 用7-bag随机生成器替代裸random
如果每生成一个方块都直接Random.nextInt(7),运气不好会出现连续几个I方块或者连续几个S方块,游戏节奏完全被破坏。主流解决方案是7-bag随机:把七种方块装进一个“袋子”,洗牌后逐个取出,取完再装一副新牌洗。这样保证每7个方块里恰好包含所有类型各一个,公平且节奏稳定。
洗牌用Fisher-Yates算法,Dart实现很短:
List<TetrominoType> _shuffledBag() { var types = TetrominoType.values; for (var i = types.length - 1; i > 0; i--) { final j = _random.nextInt(i + 1); final temp = types[i]; types[i] = types[j]; types[j] = temp; } return types; }我用的previewQueue就是从这个函数拿到新牌:队列空了就洗牌灌入,取方块时从队列头部弹出。这就是“队列”和“洗牌算法”在真实项目里的经典配合,数据结构和算法的关系往往就是这样,彼此之间互相成就。
3.5 游戏状态机:一套规则覆盖所有UI状态
俄罗斯方块看起来一直“在进行”,但其实游戏状态非常有限:初始化、进行中、暂停、结束。不用状态机的话,你会在代码里到处写isPaused、isGameOver这种布尔变量,互相组合起来很容易出乱子。
我定义枚举:
enum GameState { ready, playing, paused, gameOver }核心Game类维护一个state字段,所有输入和tick都先进状态判断:
void onTick() { if (state != GameState.playing) return; // 下落、碰撞、落定 } void togglePause() { if (state == GameState.playing) { state = GameState.paused; } else if (state == GameState.paused) { state = GameState.playing; } }状态迁移规则集中在几个方法里:start()把state从ready改成playing;落定时如果新方块无法放到出生位置,就直接进gameOver。这样做的好处是,UI层只需要监听state变化来切换界面,逻辑层不用到处散布“当前能不能动”的判断。
4. 把这些纯Dart逻辑接到OpenHarmony之前,先留好三个口子
4.1 核心层不要import任何Flutter UI库
我已经强调过一次,但这里还想展开说。第一期代码要保证lib/core目录下的文件里,import语句只出现dart:async、dart:collection这类标准库,不出现package:flutter/material.dart。这不是洁癖,是为了让核心逻辑可以在纯Dart环境下测试和运行,未来无论你把渲染层换到Flutter、Canvas还是其他引擎,业务代码都不受影响。
我在写单元测试时,直接运行dart test,不需要启动模拟器。手感好的体验是:改完碰撞算法,几十条测试跑一遍,全绿了再上UI,问题定位速度比在模拟器里反复点快一个量级。
4.2 游戏循环别用Future来跑
做游戏必须有节奏心跳,每隔一段时间让方块自动下落一格。新手最容易想到的是用Future.delayed递归调用。这在功能上能跑通,但我要提醒你:Dart的Future.then回调是被扔进微任务队列的,微任务队列的优先级高于事件队列,如果在里面做大量计算,会阻塞UI帧渲染,导致掉帧。俄罗斯方块对帧率要求没那么苛刻,但游戏循环的写法决定了代码后续扩展到复杂动画时是否顺畅。
在Flutter里我推荐两个方案:Timer.periodic用于普通间隔逻辑,Ticker从flutter/scheduler引入,适合每帧更新的渲染逻辑。第一版我用Timer.periodic监听游戏tick,用ValueNotifier通知UI更新。到第二个系列章节讲UI时,会发现这种解耦非常省心。
还有一个细节:游戏速度会随等级提升而加快。我做了一个interval计算函数,等级越高,Timer.periodic的间隔越短,但底层数据结构完全不用变,这就是逻辑独立带来的好处。
4.3 EventChannel、PlatformView和一众插件都要往后排
很多人在写OpenHarmony的Flutter应用时,第一个念头是“我要接震动、我要接原生广告、我要接第三方登录”。这些需求最后确实都要做,但请先沉住气,把游戏本身跑通。平台相关的能力依赖事件通道(EventChannel)和平台视图(PlatformView),这些在OpenHarmony上的适配方式和Android/iOS有差异,第三方插件更是要一个个确认兼容性。
我的规划是:先在这期把所有算法逻辑落地,后面专门用一节讲怎么通过MethodChannel调用鸿蒙原生能力,比如设备震动和系统返回键处理;再往后讲PlatformView嵌入原生控件的注意事项。这样当你需要接入第三方平台SDK时,已经清楚哪些工作在框架层、哪些在插件层,排查问题会快很多。
如果你现在一定要提前体验平台通道,建议先把一个最简单的示例跑通,比如发送一个字符串到原生侧再传回来。这个Hello World级别的通道验证,能帮你排除大半环境问题,避免后面写了一大堆再回头查通道注册。
5. 常见问题与调试实录
5.1 方块“穿墙”和“卡墙”问题排查
方块旋转后穿到别的方块里,或者靠墙旋转时直接失败,这是最常遇到的算法bug。我遇到这种情况的第一反应是打印方块旋转后的绝对坐标,逐个格子判断。穿墙多半是锚点偏移量算错,尤其是I方块的旋转中心跟其他方块不同,需要单独处理。卡墙则是没有接入墙踢,按上一节的偏移表处理就好。
排查时用可视化打印,我在棋盘输出时用#表示实心格,.表示空,这样一眼就能看出问题:
void debugPrintBoard(TetrisBoard board) { for (var row = 0; row < board.rows; row++) { final line = board.cells[row].map((e) => e == 1 ? '#' : '.').join(); debugPrint(line); } }5.2 消行后上方方块集体错位
如果消行后上面几行留出了空心,或者方块位置整体乱掉,大概率是“边扫边删”的索引问题。解决办法前面已经写了:先记录所有满行,再统一处理。另外一个关联注意点是新补的空行一定要插到顶部,不是底部,否则整个棋盘就上下颠倒了。
我也遇到过分数不对的情况,原因是把单行消行的分数累加成了400,而不是按消行数量阶梯计分。计分逻辑我建议用一个纯函数处理,输入Set<int> fullRows,输出分数,这样单测覆盖很容易。
5.3 卡顿、掉帧、时间推进不稳定的排查思路
俄罗斯方块如果出现卡顿,先别急着怀疑算法慢。我实际排查时发现,大部分问题出在UI层:每次tick都触发setState,整个Widget树全量重建,而且高频的动画粒子和方块没有做局部刷新。优化方向是:把棋盘控件拆成独立组件,只在棋盘内容变化时重绘;用RepaintBoundary隔离绘制层;落定动画和逻辑刷新分开。
另一个隐蔽问题是Timer.periodic在App进入后台后的行为和你预期不一致,恢复前台时会出现“瞬间掉好几行”的现象。解决方法是记录目标tick时间,恢复时重新计算剩余时间,而不是简单重启Timer。这个细节做到位,游戏体验会非常顺滑。
5.4 一份可以每天用的调试清单
我把自己调试这个项目时最常用的工具整理成了一张清单,按频率排序:
| 工具 | 用途 | 注意点 |
|---|---|---|
| debugPrint棋盘打印 | 验证碰撞和消行 | 每帧都打印会刷屏,配合条件开关 |
| 单测(dart test) | 验证算法正确性 | 覆盖旋转、碰撞、消行、7-bag |
| Flutter DevTools | 检查性能和Widget重建 | 看Timeline里的帧耗时 |
| 时间盒记录 | 分析单次tick耗时 | 用Stopwatch包一下核心算法 |
平时我在模拟器上调试OpenHarmony版本时,会额外注意设备上渲染引擎与模拟器的差异:模拟器能跑流畅的动画,在真机上可能掉帧。所以性能验证一定要放到目标设备上做,不要在模拟器上做最终判断。
我个人在实际操作中的体会是,这一个星期把数据结构与核心算法夯实之后,后面写UI和接入平台通道都变得特别轻松。象棋棋盘上每一个数据结构的选型,都会在后续某个功能点上以你意想不到的方式回报你:因为用了Queue,7-bag预览就像呼吸一样自然;因为用了坐标集合表示方块,旋转和墙踢几乎是一行数学公式的事;因为用了状态机,暂停、结束、重新开始这些流程全部变得有迹可循。
接下来这个系列的下一篇,我会把这份核心逻辑接到Flutter UI上,真正在OpenHarmony里跑出一个能玩的俄罗斯方块,同时处理按键、手势和画面刷新。希望你把基础代码先写好,遇到问题多打印棋盘、多跑单测,磨刀不误砍柴工。