Flutter for OpenHarmony游戏集合App的实战,做到俄罗斯方块消行效果这一环时,我一度以为它只是“扫描满行+删掉重画”这么简单。真动手后才发现,越简单的规则,越要在视觉反馈和逻辑更新之间找到平衡点。这篇内容完全来自我手头模拟项目X里一个真实跑通的模块,记录从棋盘建模、满行扫描到消行动画落地的完整过程,同时包含Flutter跑在OpenHarmony上的构建适配和帧率表现,适合正在做小游戏、准备把Flutter游戏模块搬上OpenHarmony的开发者参考。
先说结论:俄罗斯方块消行效果完全可以只用一个AnimationController完成,不需要为此引入重型动画库,也不需要把每个格子做成widget。下面我会把每一步拆开讲清楚,包括为什么这样设计、实际跑起来会遇到什么坑,以及最终在OpenHarmony平板上的表现。
1. 为什么我会用Flutter给OpenHarmony做游戏集合App
1.1 这次的选型背景
我手上有一批老的端侧小游戏逻辑,之前用原生方式编写,每次要加新玩法的时候,维护成本就上来了。前阵子讨论要不要迁移到统一技术栈,最后定下来用Flutter重写这个游戏集合App。原因很直接:小游戏的逻辑基本都是纯算法,不依赖平台私有API,而UI渲染又正好是Flutter的强项,天然适合跨端。开发计划里明确包含一款OpenHarmony平板,于是整个项目就变成了“Flutter for OpenHarmony游戏集合App”。
俄罗斯方块是集合里第一个被完整实现的游戏。选它不是因为上手快,而是它几乎覆盖了小游戏里所有典型技术点:二维数据建模、碰撞检测、事件输入、状态机、动画、计分。把消行效果折腾明白了,其它游戏模块就能复用同一套思路。而且从团队协作角度看,先做规则最清晰的游戏,也方便把项目规范定下来,后续模块照葫芦画瓢就行。
1.2 Flutter在OpenHarmony上的真实体感
Flutter在OpenHarmony的适配虽然不像iOS和Android那样开箱即用,但UI层一旦跑起来,跨端差异基本被引擎接齐了。我在实际开发中不需要关心OpenHarmony的原生UI组件体系,也不需要去理解ArkUI的组织方式,只需要专注游戏画面本身。Flutter的自绘模式在这种场景下优势很明显:消行动画是几十个格子同时移动,如果走常规Web渲染方案,不同设备的合成时机不同,帧率很难保证;而Flutter把每个格子变成canvas上的矩形,绘制成本接近零。
当然,平台通道和插件生态目前还没有完全跟上。所以我在做消行动画时,刻意只用了dart:ui、dart:async、flutter/material这些基础库,避免依赖第三方插件。这样不管是编译到OpenHarmony还是回头编译到移动端,代码路径完全一致,不需要为某个平台写分支。
1.3 模拟项目X的代码结构
我把俄罗斯方块模块独立成一个package,放在game_collection工程里,路径大致是game_collection/lib/games/tetris。目录拆成model、view、controller三层。model层只负责棋盘与方块逻辑,不碰任何Flutter API;view层用CustomPainter画棋盘和消行效果;controller层接手势、定时器和分数变更。分层之后,我甚至能在纯Dart环境里写测试,直接验证满行扫描算法,不需要启动模拟器。
下面进入正题,先讲棋盘模型。这个部分看似基础,但很多消行动画做不好的根子,其实都埋在设计棋盘数据结构的那一瞬间。
2. 俄罗斯方块的棋盘模型:二维数组、活动方块与碰撞检测
2.1 棋盘数据结构的选择
10列20行的棋盘,我用了List<List<int?>>,每个元素存具体的颜色索引,null表示空格。为什么不直接存bool?因为后续要支持不同方块的颜色、消行高亮时的临时着色,bool不够用。int?的成本可以忽略,200个格子的数据量对运行时来说不是瓶颈。
class Board { static const int rows = 20; static const int cols = 10; final List<List<int?>> grid = List.generate( rows, (_) => List<int?>.filled(cols, null), ); bool isInside(int row, int col) => row >= 0 && row < rows && col >= 0 && col < cols; bool isOccupied(int row, int col) => !isInside(row, col) || grid[row][col] != null; }这里有一处要提醒:isOccupied里我把“越界”也当作被占用处理。这样活动方块往左边界移动时,只需要统一调用isOccupied,不需要在外面再写一遍边界判断。俄罗斯方块里,左右边界和底部边界其实访问的是同一套逻辑,统一处理后代码会简洁很多。
2.2 形状库与旋转逻辑
7种标准方块,我用一个常量表表示。每个方块预置了四个旋转状态的坐标列表,而不是实时做矩阵变换。为什么不用矩阵旋转?因为T形、L形、Z形这些方块绕中心旋转时,很容易出现重心漂移,预置旋转表可以彻底绕开这个问题。
typedef Tetromino = List<List<Offset>>; // 以T形方块为例 const List<List<Offset>> tShape = [ [ Offset(0, 0), Offset(0, 1), Offset(0, 2), Offset(1, 1), ], // 旋转90度、180度、270度的坐标列表略 ];每次旋转时直接查表取下一个状态,同时要检查旋转后是否与已固化的格子重叠。如果重叠,就要做wall kick修正:尝试向左挪一点、向右挪一点、向上挪一点,直到找到合法位置。这里看起来和消行无关,实际上非常关键。我见过不少实现,因为旋转越界修正没做好,方块在右边界旋转时被截断,被截断的格子直接落到棋盘外,导致那一行始终缺一格,永远消不掉。所以消行动画写之前,先把旋转越界的修正写完整,后面才不会排查到吐血。
2.3 碰撞检测和落块节奏控制
俄罗斯方块是“周期步进”游戏:每N毫秒尝试下行一格。N随等级递减,从800ms降到100ms。每次尝试都用isOccupied检测下一行是否被占用,检测通过则下落,失败则把当前方块固化到棋盘,进入消行检查。
固化时机是整个游戏循环的转折点。固化后活动方块消失,棋盘进入“等待判定”状态,而不是立刻消行。为什么?为了给动画留出时间窗口。如果固化瞬间就消行,玩家根本看不到哪一行被消除,视觉反馈很弱。就是这“看不见的一瞬”,让我改造了整个状态机:把“游戏进行中”拆分成“下落中”“等待消行预览”“执行消行动画”“生成新方块”四个子状态,让动画有自己独立的表演时间。
3. 满行判定与预览高亮:消行前的状态处理
3.1 从底部向上扫描满行
满行扫描是O(200)的遍历,任何设备都能毫秒级完成。我从底部向上扫,得到所有满行行号。顺序很重要:从底向上收集,后续删除行时才能批量处理;如果从顶向下,删除后行号会漂移,容易漏行。
List<int> collectFullRows() { final fullRows = <int>[]; for (int row = rows - 1; row >= 0; row--) { if (grid[row].every((cell) => cell != null)) { fullRows.add(row); } } return fullRows; }这里要注意一个细节:如果直接在这里做数据删除,那么不会触发任何动画。所以实际操作分成两步:先进入“预览态”,满行格子高亮闪烁,持续约250ms;然后再真正删除。这个延迟极其关键,它给了玩家一个“看清楚了”的信号。没有这个信号,单行消的时候玩家可能根本没发现发生了什么,游戏体验会非常机械。
3.2 消除结果与计分规则
一行消掉算100分,两行300,三行500,四行800。计分和行数的关系不是线性,这是鼓励玩家囤积方块一次消多行。注意:计分应该在预览开始前就结算,否则玩家会看到分数跳变和动画消失不同步,觉得很怪。我最初把计分放在动画结束后,结果每次都是动画播完分数才动,视觉上有一个明显的割裂感。
void settleScore(int lines) { const scores = [0, 100, 300, 500, 800]; final idx = lines.clamp(1, 4); score += scores[idx]; }等级提升也和消行数挂钩。每累计10行等级加1,下落间隔变短。这个机制和消行预览时长直接相关,因为等级越高,下落越快,留给预览的时间窗口就越短。
3.3 高亮预览的时机选择
预览不是越久越好,250ms是我在模拟项目X里反复调出来的平衡点。太短,玩家看不清是哪几行被消,边缘位置的行尤其容易被忽略;太长,会拖慢游戏节奏,尤其在游戏速度很快的时候,玩家会觉得手里的方块迟滞。
这里我踩过一个很深的坑:最初高亮固定500ms,游戏等级一旦高起来,下落间隔只有300ms,导致活动方块还没落到预期位置就被高亮打断,手感直接崩了。所以高亮时长必须随等级动态缩短。我最终实现成“基础250ms,随等级线性降低,上限400ms,下限150ms”。这样不管游戏多快,预览都能让玩家感知到,又不会拖累节奏。
4. 消行动画:单控制器驱动的整体下落方案
4.1 动画目标拆解
消行动画的目标拆开是三个动作的叠加:满行高亮闪烁、满行消失、满行上方所有行整体下落。很多人做这里时,会用多个AnimationController分别驱动每一行下落。行数多了,控制器数量飙升,还容易因为动画时序偏移产生阶梯错位。我的方案是:一个AnimationController,配合Interval切割时间段。0到0.25是高亮闪烁,0.25到1是整体下落。无论同屏消多少行,都是同一个时间轴上的一个动作。
final controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), ); final highlightAnim = CurvedAnimation( parent: controller, curve: const Interval(0.0, 0.25), ); final fallAnim = CurvedAnimation( parent: controller, curve: const Interval(0.25, 1.0, curve: Curves.easeIn), );4.2 偏移量计算与渲染坐标映射
这里是要点。消行动画中,逻辑棋盘在动画开始时保持旧状态不变,绘制时给每个保留的格子算一个“虚拟新坐标”:先找出当前行上方已被消除的行数fallCount,那么这一行的目标行号就是row - fallCount。动画进行时,渲染行号 = row - fallCount * t。
举个例子:10列棋盘,同时消掉第5行和第8行。第4行的格子上方有第5行这一个消除行,所以fallCount=1;第6、7行的格子上方有第5行,fallCount=1;第9行到第19行的格子上方有第5和第8两个消除行,fallCount=2。t从0到1时,第4行和第9行的格子分别整体下落1格和2格,所有格子同步到达新位置,视觉上就像被消除的行“抽走”,上方方块整齐压下来。
这个方案最巧妙的地方在于:它不修改数据,只修改绘制坐标。动画结束后,才执行真正的行删除和数据重排。数据重排与动画展示分离,让逻辑彻底清晰,也避免了动画中途状态不一致的问题。
double renderRowFor(int row, int fallCount, double t) { return row - fallCount * t; }为了拿到每个格子的fallCount,我在固化后、动画开始前预计算一张Map:键是行号,值是该行需要下落的总格数。预计算的原因是绘制回调里不可能再去遍历棋盘做统计,那会每帧重复计算,浪费性能。
4.3 CustomPainter绘制方案
我并没有为每个格子创建widget,而是用CustomPainter把整个棋盘画在一个canvas上。原因很简单:200个格子如果都用Container,每次消行动画setState会导致200个widget同时重建,在OpenHarmony上虽然不至于掉帧,但重建成本没有任何意义。CustomPainter在动画tick时只需要重绘矩形,性能好很多。
绘制时循环所有行和列,计算每个格子的渲染坐标,然后canvas.drawRect。先跳过被消除的行(只在highlight阶段画高亮色),再画其余格子。
class BoardPainter extends CustomPainter { final Board board; final Animation<double> fallAnim; final Animation<double> highlightAnim; final Map<int, int> fallCountMap; @override void paint(Canvas canvas, Size size) { final cellW = size.width / Board.cols; final cellH = size.height / Board.rows; for (int row = 0; row < Board.rows; row++) { final fallCount = fallCountMap[row] ?? 0; for (int col = 0; col < Board.cols; col++) { final color = board.grid[row][col]; if (color == null) continue; final renderRow = row - fallCount * fallAnim.value; final rect = Rect.fromLTWH( col * cellW, renderRow * cellH + 1, cellW - 1, cellH - 1, ); final paint = Paint()..color = blockColors[color]; canvas.drawRect(rect, paint); if (fallCount > 0 && fallAnim.value > 0.999) { board.grid[row - fallCount][col] = color; board.grid[row][col] = null; } } } } }上面这段代码里有一个我实际用到的技巧:动画快结束时,直接在paint回调里把格子搬到新位置。这样省掉了一个额外的数据重排函数。不过要注意,这种写法依赖“动画结束后必然还有一次paint回调”,如果系统在动画结束瞬间省掉了最后一帧,就会留下脏数据。保险起见,我实际代码里是在controller.addStatusListener里做的数据迁移,paint只负责显示。这里贴出的是简化思路,读者自己实现时建议用statusListener方案,更稳。
RepaintBoundary也别忘了。虽然CustomPainter本身是自绘,但游戏页面可能还有别的UI元素,把棋盘这个CustomPainter包进RepaintBoundary,动画期间只有棋盘区域会重绘,不会触发整个页面重建。
4.4 动画过程中输入锁定的必要性
动画进行时,玩家按左右方向键、旋转键,理论上应该被忽略。实现上我加了一个busy标志,在点击预览高亮的瞬间置为true,动画结束再复位。否则玩家在动画中把方块旋转,有可能让活动方块的坐标与渲染坐标错位,产生无法复原的视觉bug。
这个busy标志位是我建议任何俄罗斯方块实现都必须保留的。不少demo效果卡顿,其实不是性能问题,而是动画期间逻辑还在接受输入,状态被搞乱了。比较典型的现象就是消行动画瞬间棋盘闪了一下,然后新方块出现在莫名其妙的位置。加了输入锁之后,整个状态机的稳定性上了好大一截。
5. 从Flutter到OpenHarmony的落地适配
5.1 环境与构建配置要点
Flutter跑在OpenHarmony上,和跑在移动端最大的区别在构建链路。我这里使用的是社区维护的OpenHarmony适配分支,整体流程是:Dart代码照常用Flutter工具链编译,最后打出hap包,再用OpenHarmony的SDK工具安装到设备。
需要注意的点有:
- 确认使用的Flutter SDK版本与OpenHarmony引擎版本匹配,不同适配分支的Channel差异很大,混用会导致运行时符号找不到。
- pubspec.yaml里尽量避免平台插件,如果必须用,要检查是否有对应的OpenHarmony实现版本。
- 开发工具中打开的是Flutter工程里生成的目标平台工程目录,而不是整个Flutter工程。
- 签名配置在打包时必须处理,真机调试需要对应设备的签名文件。
如果你的工程像模拟项目X一样只用了dart:ui、dart:async这类基础库,适配过程其实不复杂。最容易出问题的是那些依赖平台通道的插件,比如文件存储、传感器、系统分享。俄罗斯方块用不到这些,所以全程没有在OpenHarmony上写过一行原生代码。
下表是我在适配中实际验证过的支持情况:
| 功能需求 | Flutter底层方案 | OpenHarmony适配说明 |
|---|---|---|
| 常规绘制 | Canvas自绘 | 引擎层负责接入,业务零适配 |
| 资源读取 | rootBundle | 随hap打包,正常可用 |
| 网络请求 | dart:io | 无需特殊适配 |
| 本地存储 | shared_preferences | 需要替换为支持OpenHarmony的插件 |
| 传感器 | 平台通道 | 需要找对应插件或自行对接 |
| 音频播放 | 平台通道 | 我在模拟项目X中暂未接入 |
5.2 帧率实测与画面表现
我在一款OpenHarmony平板上做了消行动画测试。用Profile模式观察,消行瞬间的绘制耗时大约0.6ms一帧,整个动画期间帧率稳定在接近60fps。对比用200个widget方案跑同样动画,单帧绘制耗时在2.5ms左右,虽然也不卡,但剩余预算变少,如果同一个页面还有别的动画就危险了。
另外有一个有意思的现象:OpenHarmony上Flutter应用的帧调度,和我之前用的设备有一点差异,表现为偶尔在动画启动的第一帧会有约16ms的停顿。后来排查发现是Ticker的注册时机正好赶在系统vsync边界,解决办法是让controller.forward()在下一次帧回调里执行,或者先stop再reset。这个属于适配分支特有的调度行为,写进代码注释里提醒自己别踩第二次。
5.3 几个必须注意的平台差异
OpenHarmony平台的返回手势和横竖屏切换行为和常见移动端很不一样。游戏页面建议强制横屏,并且锁定系统导航,否则玩家在消行动画期间触发返回手势,游戏状态没有保存,进度就丢了。俄罗斯方块不像关卡制游戏可以自动存档,返回后重开一局,玩家的心理预期会被破坏。
还有屏幕dpr:部分OpenHarmony设备的逻辑分辨率报告方式和常规设备不一致。绘制格子尺寸时,直接按拿到的MediaQuery逻辑像素计算,不要手动乘设备的物理像素比例,否则同一款平板会出现格子宽度不一致的问题。我在项目里统一封装了一个getGameArea()方法,所有尺寸都从它取,遇到异常设备时只需改一处。
另外要在入口处捕获平台异常。我用了runZonedGuarded统一兜底,至少在游戏过程里崩溃时能弹回主菜单而不是白屏。俄罗斯方块的棋盘数据在内存里简单,不需要额外做崩溃持久化,能重启一局就已经够用。
6. 实战中踩过的坑和后续可以继续做的事
6.1 印象最深的三个坑
坑一:AnimationController与Timer的时钟竞争
最早的下落节奏用的是Timer.periodic,消行动画用的是AnimationController。两者由不同机制驱动,消行开始瞬间因为Timer回调恰好先执行了一次下落,把新方块压进了还在播放动画的棋盘,导致视觉重叠。解决办法是:把下落驱动也迁移到板级帧回调里,统一节奏。或者更简单,在做消行动画的标志位存在期间,暂停Timer。我最后选了暂停方案,代码改动最小。
坑二:高亮颜色在部分设备上看不清
我最初高亮色用白色,OpenHarmony平板的色彩调教和电脑不同,白色在低亮度下几乎看不见。后来改成“原颜色+白色边框”,也就是高亮时格子内填充原色,边线加粗为白色。这样即使看不清填充,也能通过边框识别哪一行正在被消除。这个细节虽然小,但直接决定了预览阶段的有效性。
坑三:旋转后消行检测漏行
有一版代码旋转后没有做wall kick修正,方块在右边界旋转时一部分被截断,被截断的格子直接落到了棋盘外,导致棋盘里那一行始终缺一格,永远消不掉。这个bug排查了很久,最后发现是旋转落点判定问题。所以我在章节2.2里反复强调wall kick,就是因为它是前置条件,写不好后面全是连锁问题。
6.2 扩充到游戏集合的路线
这次消行动画方案不只能用在俄罗斯方块。我后来把同样的“逻辑坐标-渲染偏移-单控制器”思路套到另一个消除类游戏里,表现同样稳定。如果你也把这个游戏集合项目持续做下去,可以考虑三个方向:
- 把消行动画模块抽成通用组件,喂给它棋盘二维数组和消行行号,它就能自动播放动画,这样新游戏接入成本极低。
- 把Dart层游戏逻辑单独抽成纯逻辑包,方便将来跑单元测试,不依赖Flutter框架。我在模拟项目X里已经把俄罗斯方块核心逻辑抽出来了,测试用例覆盖了满行扫描、wall kick、计分规则。
- 游戏页面统一使用CustomPainter加RepaintBoundary,后续增加粒子特效时,对既有模块影响最小。
至于音效和震动反馈,OpenHarmony生态的插件还在补齐中。我在模拟项目X里暂时用的是本地轻量音频方案,通过平台通道对接,等官方插件成熟后再替换。
6.3 关于消行动画的个人参数建议
如果让我只挑三个参数来影响手感,一个是高亮时长,一个是下落曲线,一个是输入锁的开启时机。高亮时长必须随等级动态缩短,固定值在高速局里会毁掉节奏;下落曲线用easeIn而不是linear,因为easeIn让前段下落稍慢、后段加速,符合“重力感”的直觉;输入锁一定要在预览一开始就挂起,而不是等动画开始才挂,否则高亮期间玩家按一次方向键,刚好卡进动画,状态就全乱了。
这三个参数之间其实有联动:高亮短了,输入锁就要早;落下曲线快了,高亮就要更明显一些。没有绝对正确的组合,只能在目标设备上反复试。好在俄罗斯方块的核心逻辑和动画是分离的,改参数不需要动框架,这也是我在这个项目里最满意的一点。
最后再分享一个实际调试技巧:在OpenHarmony平板上验证消行动画时,不要只看真机效果,最好开一下Flutter的Profile模式,把每一帧的build和paint耗时记录下来。很多卡顿在视觉上只有一瞬间,但Profile曲线会明确告诉你掉帧发生在消行动画启动的那一帧,还是发生在数据重排的那一帧。定位不同,修复方向完全不一样。我在模拟项目X里靠这个方式省下了大量排查时间。