做跨平台开发这么多年,我一直觉得俄罗斯方块是最容易被低估的练手项目——说它简单,核心逻辑几十行就能跑通;说它不简单,旋转、碰撞、消行、计时、随机出块,每个点都值得单独抠细节。这次我用 Flutter 在鸿蒙上把经典俄罗斯方块完整实现了一遍,核心收益不是“做出了一个游戏”,而是验证了一条技术选型路径:Flutter 代码能否在鸿蒙生态里低成本落地。
项目本身不算复杂,难点在于“一套代码,两头适配”。一边是 Flutter 的常规跨平台玩法,另一边的鸿蒙。这个组合在社区讨论热度很高,真正跑通案例却不多。我写这篇文章,就是想把从环境搭建到游戏逻辑、再到鸿蒙真机调试的完整过程记录下来,给你一份可以照着抄的作业。
1. 整体设计与方案拆解
1.1 为什么选 Flutter 而不是 ArkTS
聊鸿蒙开发,绕不开的一个问题是:到底该用 ArkTS 还是 Flutter?ArkTS 是鸿蒙的一等公民,用 ArkUI 声明式语法写页面,配合方舟编译器打包成 HAP,原生体验自然没得说。但问题是,如果你的应用不是只发鸿蒙,还要同时维护 iOS 和 Android,那 ArkTS 这套就完全没法复用,等于从零再写一遍。
Flutter 的优势是代码只写一份,UI 层和逻辑层在三个端都能跑。俄罗斯方块这种纯二维网格游戏,恰好不依赖平台深度调用的能力,用 Flutter 自绘引擎渲染像素级定位,体验和原生几乎没有差距。有人担心内存和功耗,实测在鸿蒙真机上跑 60 帧毫无压力,一个棋盘撑死只有 200 个渲染格子,Skia 画这种规模的绘制任务非常轻松。
ArkTS 和 Flutter 谁更流行的争论很多,我的看法是看场景。如果你的应用是纯鸿蒙专属工具类,ArkTS 确实更省事;如果目标是三端覆盖,Flutter 是当前成本最低的方案。俄罗斯方块这种轻量游戏,Flutter 完全够用,没有选择 ArkTS 的必要。
1.2 为什么用俄罗斯方块做验证样本
很多人觉得俄罗斯方块“太老”“太简单”,没什么技术含量。我反而觉得,它恰好是验证跨平台能力的最佳样本。原因有几个:
- 游戏逻辑是纯状态驱动的二维矩阵操作,不依赖物理引擎和复杂动画,适合先抽成纯 Dart 纯逻辑层。
- 单帧渲染的 widget 数量极少,性能压力集中在计时器和状态刷新上,方便对比不同平台的表现差异。
- 规则人人皆知,不用介绍背景,代码出了问题一眼就能看出来。
更实际的好处是:俄罗斯方块的逻辑边界非常清晰,方便分层架构。我把游戏核心逻辑写成纯 Dart 类,不依赖任何 Flutter 组件,这意味着在鸿蒙适配出现问题时,可以先在 PC 上单测验证逻辑,再逐层排查渲染和通道问题。鸿蒙的 Flutter 工具链没有 Android 成熟,这个设计决策给我省了大量调试时间。
1.3 项目架构分层
整个工程我分成三层,职责划分很明确:
- GameModel 层:纯 Dart 类,负责棋盘状态、方块移动、旋转、消行、随机出块。没有任何
BuildContext,不依赖 Flutter 组件,方便单元测试。 - GameProvider 层:基于
ChangeNotifier的状态管理,持有 GameModel 实例,对外暴露游戏操作方法。UI 通过这个类读取状态和触发操作。 - GameBoard 层:Flutter 渲染层,用
Stack加Positioned做像素级方块定位,用GestureDetector处理滑动手势,用WidgetsBindingObserver接管生命周期。
这种分层设计最直接的好处是:在鸿蒙适配过程中,如果 UI 层出了问题,我可以直接判断是渲染问题还是状态同步问题,不至于在满屏报错里手忙脚乱。Provider 在这一步也体现出优势,它比 Bloc 轻量太多,操作只是调用方法、监听状态,没有中间件和 Event 的曲折路径。
2. 游戏核心逻辑与关键算法
2.1 方块形状与棋盘数据结构
俄罗斯方块的棋盘是 10 列 20 行的二维数组。我用List<List<int>>表示,值为 0 表示空格,1 到 7 表示七种不同颜色。初始化时直接生成一个固定宽高的二维数组:
class TetrisGame { static const int rows = 20; static const int cols = 10; List<List<int>> board = List.generate(rows, (_) => List<int>.filled(cols, 0)); int currentX = 3; int currentY = 0; List<List<int>> currentPiece = []; }七种方块的形状用矩阵表示。比如 I 型方块是 4x4 矩阵,O 型是 2x2,其他形状统一用 3x3 矩阵。我的做法是预定义一组零一矩阵,放在静态常量里:
static const List<List<List<int>>> tetrominoes = [ // I [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0], ], // O [ [1, 1], [1, 1], ], // T [ [0, 1, 0], [1, 1, 1], [0, 0, 0], ], // S [ [0, 1, 1], [1, 1, 0], [0, 0, 0], ], // Z [ [1, 1, 0], [0, 1, 1], [0, 0, 0], ], ];棋盘的行索引从顶部开始,y 轴正方向朝下。这和屏幕坐标一致,渲染时不需要额外换算。定义好数据结构后,碰撞检测、消行、旋转逻辑全都围绕数组操作展开,不涉及像素坐标。
2.2 碰撞检测:移动前预判,不移动后回滚
新手常犯的错误是:先移动方块位置,再判断是否越界或重叠,如果非法就回滚位置。这种方案在逻辑上能跑通,但边界情况非常多。比如方块快速下落时,可能瞬间越过两层墙;又比如连续两个方向的操作叠加,回滚逻辑会变得非常混乱。
我采用的方案是移动前预判。每次都先把目标位置计算出来,逐格检测目标位置是否合法,合法才更新坐标,非法直接忽略该次操作。检测逻辑就一句话:
bool canMove(List<List<int>> piece, int newX, int newY) { for (var i = 0; i < piece.length; i++) { for (var j = 0; j < piece[i].length; j++) { if (piece[i][j] == 0) continue; final boardX = newX + j; final boardY = newY + i; if (boardX < 0 || boardX >= cols || boardY >= rows) return false; if (boardY >= 0 && board[boardY][boardX] != 0) return false; } } return true; }这个函数的判断顺序值得注意:先判断横向越界,再判断纵向越界,最后判断重叠。横向越界最容易出现,因为在左右快速连按时,方块可能一次性滑出多格;纵向的 boardY >= rows 防止消行后 IndexOutOfRange。顺序摆对了,代码就不会在极端操作下崩掉。
2.3 旋转算法与墙踢处理
旋转是俄罗斯方块最大的坑。矩阵旋转的数学方法不复杂,把矩阵转置再水平翻转即可。我在处理旋转时用了通用的转置加翻转:
List<List<int>> rotateMatrix(List<List<int>> matrix) { final n = matrix.length; final rotated = List.generate(n, (_) => List<int>.filled(n, 0)); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { rotated[j][n - 1 - i] = matrix[i][j]; } } return rotated; }但直接把旋转结果放回原坐标,经常会发现方块顶到墙上或者插入已固定方块里。原因很简单:矩阵旋转是纯数学变换,游戏里方块是在网格坐标系里运动,旋转中心不在网格中心,旋转后位置会偏移。
解决方式是墙踢。我的方案是:尝试旋转后,先检测原位置能否放下,如果不行,依次尝试向左偏移 1 格、向右偏移 1 格、向上偏移 1 格,只要有一组位置合法就采用。I 型方块和 O 型方块我做了特殊处理,O 型旋转后不变直接跳过旋转,I 型因为横竖长度差异大,偏移范围放宽到 2 格。实际游戏里,贴着左墙旋转 I 型的情况非常常见,不加墙踢基本转不动。
2.4 消行判定与计分规则
消行逻辑其实很朴素,从棋盘底部往上扫描每一行,判断一行是否全不为 0。是就移除该行,并在棋盘顶部补一行全 0。最简单的实现:
int clearLines() { int lines = 0; for (int y = rows - 1; y >= 0; y--) { if (board[y].every((cell) => cell != 0)) { board.removeAt(y); board.insert(0, List<int>.filled(cols, 0)); y++; lines++; } } return lines; }注意循环里用了一个小技巧:removeAt之后,原本在y上面的行全都往下挪了一位,如果继续向上扫描会漏掉新的满行,所以我把y++,让当前索引保持不变,重新检查一次。这个细节很多人会漏掉,导致一次消两行时只消一行。
计分规则参考经典规定:消一行得 100,两行得 300,三行得 500,四行得 800。分数只和消行数挂钩,不参与下落速度计算,否则逻辑会被搅浑。
2.5 计时器设计与等级递进
俄罗斯方块的节奏感来自下落速度变化。我用了Timer.periodic,而不是在每次操作后重置Timer。原因在于统一管理一个 Timer 更稳妥,不会出现多个 Timer 叠加导致方块瞬移的问题。等级提升时,我取消旧的 Timer 再按新间隔创建:
void restartTimer() { _timer?.cancel(); final intervalMs = (500 * pow(0.85, level - 1)).toInt(); _timer = Timer.periodic(Duration(milliseconds: intervalMs), (_) => tick()); }等级 1 时 500ms 一次,每升一级下落间隔缩短 15%。这个曲线比较舒服,前几级新手能跟上,后面逐步压榨反应速度。实测等级 10 时大概 97ms 一次,手速极限到了就很容易堆叠失败。
2.6 随机出块的 7-Bag 算法
如果直接用Random.nextInt(7),经常会出现连续出同一个方块的情况,游戏体验很糟。经典街区游戏为了公平性,用所谓的 7-Bag 算法:把七种方块放进一个袋子洗牌,每次从中取一块,取完再洗下一袋。这样可以保证任意 7 块中,每种形状至少出现一次。
List<int> bag = []; int nextPiece() { if (bag.isEmpty) { bag = List.generate(7, (i) => i)..shuffle(); } return bag.removeLast(); }实现就几行,但实际手感差异巨大。我一开始用纯随机,玩几局就会遇到连续三个 T 型,堆叠时感觉很廉价。换成 7-Bag 后,节奏舒服多了。
3. 界面实现与手感调优
3.1 像素级渲染:一个格子就是一个 Container
俄罗斯方块的 UI 不需要用自定义画布,直接用 Flutter 的Stack配合Positioned做装填就行。整个棋盘外层是一个AspectRatio保持 10:20 比例,内部用LayoutBuilder拿到实际宽度,动态计算出每个格子的像素大小:
Widget _renderBoard(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final cellSize = constraints.maxWidth / 10; return Stack( children: [ for (var row = 0; row < 20; row++) for (var col = 0; col < 10; col++) if (board[row][col] != 0) Positioned( left: col * cellSize, top: row * cellSize, child: Container( width: cellSize, height: cellSize, decoration: BoxDecoration( color: colorMap[board[row][col]], border: Border.all(color: Colors.black26, width: 0.5), ), ), ), ], ); }, ); }这种方式的性能不用担心。10x20 的棋盘最多 200 个格子,其中还有大量空格不渲染。每次状态变化调用notifyListeners,Provider 的Consumer只会重建这一层面的 widget,不会触发整个页面重建。实测在鸿蒙真机上滑动、旋转、消行完全流畅,没有掉帧迹象。
3.2 手势系统:滑动方向识别与长按重复
俄罗斯方块的手势交互很简单:左右移动、下拉加速、上滑旋转。用GestureDetector的onPanUpdate就能覆盖全部操作:
onPanUpdate: (details) { final dx = details.delta.dx; final dy = details.delta.dy; if (dx.abs() > dy.abs()) { if (dx > 10) provider.moveRight(); if (dx < -10) provider.moveLeft(); } else { if (dy > 10) provider.softDrop(); if (dy < -10) provider.rotate(); } },这里要处理一个容易出现的手感问题:快速滑动时,details.delta是增量而非累计绝对值。如果滑动距离很大,一次onPanUpdate里 delta 可能超过 100,直接触发一次移动没问题,但用户体验会感觉迟钝。我把阈值设为 10,并且对连续操作做了累计判断,这个在代码里看着小,实际玩起来手感差距很关键。
长按自动连续移动是很多人都遇到的问题。GestureDetector的onPanUpdate只在手指滑动时触发,按住不动不会产生任何回调。俄罗斯方块不能按键重复,体验就差了。我的方案是:在onPanDown时记录时间,启动一个 200ms 的Timer.periodic自动左移或右移;onPanEnd和onPanCancel时取消这个 Timer,然后才归零状态。
3.3 侧边栏、下一块预览与分数面板
主游戏区之外,我做了三个侧边栏区域:下一个方块预览、当前分数、当前等级。“下一个方块”在俄罗斯方块里不仅是装饰,它能帮玩家规划堆叠策略,属于必备功能。
预览的实现我复用了同一个PieceWidget组件,接收一个矩阵数据,渲染成小号格子。这里用了一个技巧:预览的宽高是固定的,格子大小由外部传入,这样同一个组件在棋盘区和预览区都能用。分数和等级用AnimatedSwitcher做数字切换动画,视觉上更精致,而且这个动画组件本身很简单,不会被鸿蒙适配影响。
3.4 音效与震动反馈
如果只是视觉反馈,俄罗斯方块玩起来还是干巴巴的。我加了旋转音效、消行音效、游戏结束音效。这里涉及到插件兼容性:鸿蒙对第三方 Flutter 插件的适配没有 Android 好,audioplayers这个常用库在鸿蒙上盲目使用可能直接构建失败。我的做法是先把音效抽象成接口,先实现一套空壳,等鸿蒙的音频通道验证通过后再接入。不追求一步到位,核心是先把游戏逻辑跑通,再逐步完善体验。
震动反馈在鸿蒙上更麻烦,因为系统和 Android 的 API 完全不一样。俄罗斯方块对震动依赖不重,我最终没做震动,只保留视觉和音效反馈。如果你要加震动,得自己去查鸿蒙 SDK 的能力接口,这一点在 Flutter 上的封装还不太成熟,不建议一开始就在项目里铺开。
4. 鸿蒙适配与工程化改造
4.1 鸿蒙 Flutter 开发环境搭建
首先要明确一个现实:Flutter 官方主分支并没有直接支持鸿蒙,要用的是 OpenHarmony 社区维护的 Flutter 分支。这个分支的版本更新会比官方慢半拍,但核心 API 保持兼容,实际使用体验接近。
环境搭建的关键步骤是:
- 从分布式的代码仓库拉取针对 OpenHarmony 的 Flutter SDK 分支,替代本机默认 Flutter SDK。
- 安装鸿蒙的 ohos-sdk,配置开发工具环境变量。
- 确认 Dart SDK 版本和鸿蒙 Flutter 分支配套,否则跑
flutter doctor时会出现版本不匹配警告。 - 执行
flutter config --enable-openharmony开启对鸿蒙平台的支持。
这一步最大的坑是版本匹配。随便拿一个官方稳定版 Flutter 去跑鸿蒙工程,构建时会报一堆类型不匹配错误,因为 OpenHarmony 分支的 SDK API 有一些自定义扩展。我给的建议是:完全按照分支仓库的 README 指引来,不要用最新版,用和维护方测试过的版本一致的那套。
4.2 工程创建与构建流程
创建鸿蒙工程和 Android 工程类似,flutter create的时候指定平台:
flutter create --org com.example --platforms ohos tetris_ohos生成工程后目录里会多出一个ohos目录,它相当于鸿蒙的壳工程,和android目录、ios目录的地位一样。Flutter 代码不需要改,壳工程里会有一个入口 Activity 负责启动 Flutter Engine。
用命令行连接鸿蒙真机时,工具链不是adb,而是hdc。连接到设备后,执行:
flutter run -d ohos如果开发环境配置正确,这个过程和 Android 构建非常相似,会提示需要签名、确认权限。第一次构建的时间明显比 Android 长,因为 OpenHarmony 的 Flutter Engine 通常需要全量编译,没有增量缓存。
4.3 签名配置与真机调试
鸿蒙真机调试有个痛点:默认的 debug 包安装到非开发者模式的手机时会被拦截。手动签名的步骤比较繁琐,需要在鸿蒙开发工具里创建证书,再把证书文件配置到ohos目录下的build-profile.json5里。配置好后,release 包才具备安装条件。
真机调试过程中我用到了两个核心命令:
hdc list targets:查看已连接的鸿蒙设备。hdc file send:往设备传文件,调试时偶尔需要传签名证书。
日志查看也别用adb logcat,鸿蒙上要用hdc shell hilog。Flutter 的日志输出会被包装成特定格式,我第一次排错时盯着乱码看十分头大。后来习惯先跑flutter run看编译期报错,运行时报错再去拉 hilog,效率高得多。
4.4 ArkTS 页面与 Flutter 页面共存
如果整个游戏都用 Flutter 做,那直接在 Flutter 工程里写就完了,不需要碰 ArkTS。但实际业务场景往往是:应用首页和一些系统设置页面用 ArkUI 实现,游戏副流程用 Flutter 实现。
混合方案的做法是:用 Flutter 构建一个 Flutter Module,嵌入到 ArkTS 的工程里。鸿蒙支持在 ArkUI 页面中嵌入 Flutter 组件作为视图容器。这一步的核心是控制好启动场景,Flutter Engine 第一次启动会比较慢,最好在应用进入后台时预热,或者在启动页加载。
俄罗斯方块这个项目我没做混合,直接单一 Flutter 入口。但如果你有混合需求,建议在正式开发前先验证 ArkUI 和 Flutter 的通信通道,也就是平台通道(MethodChannel),确保数据能双向传递,再投入开发。两个框架之间的生命周期事件和内存占用差异,都需要单独评估。
4.5 ArkTS 和 Flutter 并存时的性能表现
鸿蒙原生 ArkUI 的渲染采用方舟引擎,Flutter 则是自绘引擎。两者在轻量级游戏上的性能差距很小。实测俄罗斯方块在两种方案下都能稳定 60 帧,内存占用差异在几十 MB 量级。
我比较在意的是首帧渲染时长。Flutter Engine 在鸿蒙上首次初始化时,大约需要 1 到 2 秒,比 ArkUI 慢不少。对游戏应用来说,这意味着启动页要兜住,不能让用户盯着白屏。我的做法是在 Flutter 首帧渲染后,通过一个标志位通知壳工程关闭启动页,这样用户感知不到等待。
5. 常见问题与避坑实录
5.1 新手最容易踩的五个坑
我跑通整个项目后,回头整理了一份问题清单,按出现频率从高到低排序。我相信你在做类似项目时大概率也会遇到。
| 问题 | 现象 | 根本原因 | 我的解决方案 |
|---|---|---|---|
| Timer 叠加导致方块瞬移 | 方块下落越来越快,甚至一帧落到底 | 每次等级变化都新建 Timer,旧 Timer 没取消 | 统一只维护一个Timer,重启前先cancel() |
| 旋转后方块穿墙 | 方块旋转后卡进墙里或者越界 | 没做墙踢处理 | 旋转后检测偏移位置,尝试左右上偏移 1~2 格 |
| 长按不能连续移动 | 手指按住屏幕,方块没反应 | GestureDetector不处理按住状态 | 用onPanDown启动 200ms 循环 Timer |
| 消行计数不准 | 一次消四行只算了一行或两行 | 移除行后未回退索引 | removeAt后把索引回退,重新检查 |
| 鸿蒙工程构建失败 | 提示各种 Gradle 插件版本错配 | 误照抄 Android 构建脚本 | 删除无关 Android 配置,完全按 OpenHarmony 壳工程规范走 |
这里想展开说下 Timer 叠加的问题。合理的情况是每个游戏状态切换时候都要重新调整下落频率,很多人会图省事在setLevel里直接Timer.periodic,没有先cancel旧 Timer。结果就是玩到高等级时,屏幕上同时跑着两三个 Timer,方块下落速度变成指数级,直接 Game Over。我自己也踩过一次,排查了半天才发现是新旧 Timer 同时在 tick。
5.2 鸿蒙真机调试的“非典型”问题
鸿蒙的调试和 Android 有细微差别,这种差别不会写在主流文档里,我这里单独列几点。
热重载不稳定。在鸿蒙上flutter run的热重载支持没有 Android 好,改动 UI 后按r可能不生效,甚至会闪退。我的经验是:逻辑改动做单元测试,不依赖热重载;UI 改动用hot restart代替hot reload,仍然能比较快地看到效果。
网络调试要特别注意。如果代码里有访问网络的逻辑,鸿蒙的权限声明和 Android 不完全相同,需要在ohos/module.json5里加上具体权限。俄罗斯方块本身不需要网络,但如果后续加排行榜,这个会第一个暴露出来。
日志区分不明显。用debugPrint打印的内容在 hilog 里会被包裹很长一层前缀,搜索自己的关键词会搜不到。建议打印时统一加前缀,比如[tetris],这样hdc shell hilog | grep tetris能快速筛选。
5.3 生命周期和后台保活
俄罗斯方块是个实时游戏,玩家切后台再切回来时,游戏不能直接 Game Over,也不能在后台继续跑。这里我用了WidgetsBindingObserver监听应用状态:
@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused) { provider.pauseGame(); } else if (state == AppLifecycleState.resumed) { provider.resumeGame(); } }忽略这个细节的话,后果很离谱:游戏切到后台,Timer 依然在跑,方块在后台持续下落;玩家切回来时,游戏已经堆满结束。别问我是怎么知道的,问就是踩过了。上网搜 “Flutter 后台 Timer 不暂停” 能搜出一堆帖子,说明这个问题有普适性。俄罗斯方块这种定时器驱动的游戏,生命周期管理不是可选功能,是必备功能。
5.4 测试与重构顺序建议
在鸿蒙上重构代码的代价比 Android 高,因为全量编译一次要很久。所以我建议的开发顺序是:先把 GameModel 逻辑在桌面端用单元测试跑通,再接入 Provider 和 UI,最后才连鸿蒙真机调试。这样能保证你交给鸿蒙工具链的代码是相对稳定的,减少反复构建的次数。
我实际做的时候,旋转和墙踢算法在桌面端反复调了三四次,全是在 Dart 脚本里调。等逻辑完全稳定后再接 UI,整个鸿蒙适配环节的报错量大幅下降,因为报错来源只可能是编译和配置,不会牵扯到游戏规则。
5.5 插件兼容性:尽量用纯 Dart 方案
俄罗斯方块用到的插件不多,但只要是带原生 C++ 实现的插件,鸿蒙上就存在适配风险。音效库、震动库、本地存储库,这些统统要过一遍“是否支持 ohos”的门槛。
我的建议是:新项目一开始就把插件使用控制在最小集合。能用手写纯 Dart 实现的就别引第三方包。比如本地最高分存储,用shared_preferences在 Windows 上很好用,安卓也正常,但鸿蒙上就要验证分支版本是否支持。如果验证不了,就先自己写文件存储,版本上线后再逐步换。
这套思路的价值在于:你把最不确定的因素推到项目后期,而不是一开始就卡死在编译阶段。俄罗斯方块本身对插件依赖很低,基本能跑起来就能看到完整效果,这对验证 Flutter 鸿蒙链路非常有利。
我在实际开发中的体会是:俄罗斯方块这样的经典游戏,是最适合用来做“新平台试水”的载体。逻辑复杂度适中,状态流清晰,渲染性能要求不高,一切复杂度都在“如何把规则实现得准确、如何把交互调得顺手、如何把平台适配做到位”上,踩坑成本低,收获非常直接。
如果你也在评估 Flutter 在鸿蒙生态里的可行性,我强烈建议先拿俄罗斯方块这类小游戏跑一轮。跑通一次完整链路——建工程、写逻辑、调 UI、接真机、打 release 包——比看再多的技术分析文章都更有说服力。逻辑跑通了,后面再往上叠业务功能,心里就有底了。