☰
Flutter鸿蒙适配实战:用俄罗斯方块验证跨平台开发可行性
2026/10/8 2:13:10 网站建设 项目流程

做跨平台开发这么多年,我一直觉得俄罗斯方块是最容易被低估的练手项目——说它简单,核心逻辑几十行就能跑通;说它不简单,旋转、碰撞、消行、计时、随机出块,每个点都值得单独抠细节。这次我用 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 项目架构分层

整个工程我分成三层,职责划分很明确:

  1. GameModel 层:纯 Dart 类,负责棋盘状态、方块移动、旋转、消行、随机出块。没有任何BuildContext,不依赖 Flutter 组件,方便单元测试。
  2. GameProvider 层:基于ChangeNotifier的状态管理,持有 GameModel 实例,对外暴露游戏操作方法。UI 通过这个类读取状态和触发操作。
  3. 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 保持兼容,实际使用体验接近。

环境搭建的关键步骤是:

  1. 从分布式的代码仓库拉取针对 OpenHarmony 的 Flutter SDK 分支,替代本机默认 Flutter SDK。
  2. 安装鸿蒙的 ohos-sdk,配置开发工具环境变量。
  3. 确认 Dart SDK 版本和鸿蒙 Flutter 分支配套,否则跑flutter doctor时会出现版本不匹配警告。
  4. 执行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 包——比看再多的技术分析文章都更有说服力。逻辑跑通了,后面再往上叠业务功能,心里就有底了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询