如果你正在关注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的设计其中上面两行作为“天空区”方块从这里出生。最简单的数据结构就是二维数组ListListint0表示空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 ListListint 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; } }以后做性能优化时可以把棋盘降成一维数组Listint再用位运算判断整行是否满、是否空比如把一行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底层是链表结构你可以在两端进行操作这其实就是双端队列的概念模型。首期代码里我只用单端进、单端出但如果你想做“下一、下二、下三”预览或者允许玩家和一个“暂存方块”交换就会发现双端操作的需求随时会出现。我在第一版就把预览列表设计成QueueTetrominoType后面扩展时完全不用改数据结构。final QueueTetrominoType 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; }实际移动时我会先把dRow1传给这个函数判断能不能下落能下就更新锚点不能下就进入“落定”流程。这里有个性能小技巧一轮游戏大约会调用几百次碰撞检测每次只遍历最多4个格子所以完全不必担心性能真正的瓶颈一般都在UI重建上。3.2 消行从下往上扫先把满行记下来再统一清理消行逻辑看起来简单哪行满了就去掉哪行。但如果直接一行行删、删完就更新数组很容易写出隐藏bug。比如你从上往下扫描删掉一行后后续所有行的索引都变了下一轮循环就容易漏掉或重复处理。正确做法是从最后一行开始往上扫遇到满行先记录行号但暂不删除。扫描结束后一次性把所有满行移除然后在棋盘顶部补相应数量的空行。这样索引问题就消失了代码逻辑也更清晰。Listint 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行得分100300500800这里有一个容易忽略的细节每次落定可能会同时消多行但一次落定最多只涉及当前方块的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实现很短ListTetrominoType _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而不是按消行数量阶梯计分。计分逻辑我建议用一个纯函数处理输入Setint fullRows输出分数这样单测覆盖很容易。5.3 卡顿、掉帧、时间推进不稳定的排查思路俄罗斯方块如果出现卡顿先别急着怀疑算法慢。我实际排查时发现大部分问题出在UI层每次tick都触发setState整个Widget树全量重建而且高频的动画粒子和方块没有做局部刷新。优化方向是把棋盘控件拆成独立组件只在棋盘内容变化时重绘用RepaintBoundary隔离绘制层落定动画和逻辑刷新分开。另一个隐蔽问题是Timer.periodic在App进入后台后的行为和你预期不一致恢复前台时会出现“瞬间掉好几行”的现象。解决方法是记录目标tick时间恢复时重新计算剩余时间而不是简单重启Timer。这个细节做到位游戏体验会非常顺滑。5.4 一份可以每天用的调试清单我把自己调试这个项目时最常用的工具整理成了一张清单按频率排序工具用途注意点debugPrint棋盘打印验证碰撞和消行每帧都打印会刷屏配合条件开关单测dart test验证算法正确性覆盖旋转、碰撞、消行、7-bagFlutter DevTools检查性能和Widget重建看Timeline里的帧耗时时间盒记录分析单次tick耗时用Stopwatch包一下核心算法平时我在模拟器上调试OpenHarmony版本时会额外注意设备上渲染引擎与模拟器的差异模拟器能跑流畅的动画在真机上可能掉帧。所以性能验证一定要放到目标设备上做不要在模拟器上做最终判断。我个人在实际操作中的体会是这一个星期把数据结构与核心算法夯实之后后面写UI和接入平台通道都变得特别轻松。象棋棋盘上每一个数据结构的选型都会在后续某个功能点上以你意想不到的方式回报你因为用了Queue7-bag预览就像呼吸一样自然因为用了坐标集合表示方块旋转和墙踢几乎是一行数学公式的事因为用了状态机暂停、结束、重新开始这些流程全部变得有迹可循。接下来这个系列的下一篇我会把这份核心逻辑接到Flutter UI上真正在OpenHarmony里跑出一个能玩的俄罗斯方块同时处理按键、手势和画面刷新。希望你把基础代码先写好遇到问题多打印棋盘、多跑单测磨刀不误砍柴工。