
上一篇我们费了好大劲把 Flutter 引擎跑在了 OpenHarmony 设备上结果打开 App 屏幕上还是空的。群里好几个朋友都在问同一个问题“工程是能跑了游戏画面呢”这很正常空壳工程只能证明环境通了离“能玩的俄罗斯方块”还差十万八千里。这一篇就专门解决画面问题为什么俄罗斯方块的界面必须用 CustomPaint 自绘而不是堆组件、OpenHarmony 环境下项目怎么彻底跑通、以及如何用 Canvas 把棋盘、方块、预览区和得分全部画出来。文章最后我会把自己在真机上踩的几个坑——网格线发虚、画面被系统栏吃掉、低配设备掉帧——全部摊开讲能帮你少走不少弯路。1. 先别急着动笔为什么俄罗斯方块的画面必须自绘而不是堆组件1.1 游戏画面的渲染本质每一帧都是全量像素俄罗斯方块的游戏画面本质上是一块 10 列 × 20 行、共 200 个格子的像素画布。每个格子的颜色随时可能变化方块下落的过程里理论上每一帧60fps 下就是每秒 60 次都有大量格子要更新。如果用 Flutter 的常规控件来表达这种画面你最直接的想法可能是用StackPositioned把 200 个小Container铺上去。我第一次做原型时就这么干当时还觉得自己挺聪明——每个格子对应一个Container改颜色就是改color属性多朴素。结果跑了不到 5 分钟就发现问题了每次状态变化Flutter 都要对这 200 个Element做一次 diff。方块下落、旋转、消行一秒钟内状态要变好几十次Widget 树的 diff 和重建压力直接拉满。我在一台内存只有 4GB 的低配开发板上跑掉帧掉到肉眼可见的卡顿简直没法看。这不是 Flutter 控件体系本身的问题而是“场景错配”。俄罗斯方块这类游戏画面是高度规则的网格它的更新频率高、结构固定、视觉形态简单本质上更适合用 Canvas 直接绘制而不是维护一棵庞大的控件树。1.2 Container 方案的四宗罪我不建议你用Container堆格子的另一个原因是这套方案在处理视觉细节时会非常痛苦。具体来说有四个致命问题Widget 树膨胀200 个格子再加上预览区、得分区一顿操作下来大几百个Element是正常的复杂嵌套让 build 耗时和内存峰值成倍上涨。刷新粒度太粗只要有一个格子变化很难直接把变化隔离到单个Container上最终会退化成“无脑 setState 整个棋盘”全部重建。细节表达困难网格线要通透主界面背景要沉稳幽灵块要半透明当前方块要有层次感。这些用Container的BoxDecoration也能做但组合起来极其繁琐效果还不一定好。行为不符合列表语义游戏棋盘是固定大小、不滚动的用GridView、ListView去做等于白白引入布局系统里用不到的计算。反过来看 CustomPaint一次paint()调用遍历一遍二维数组画 200 个矩形全量更新也就几百行绘制的成本在 Skia 引擎下属于非常轻的负载。这就是我最终选择自绘的根本原因。1.3 自绘不是“所有 UI 全自己画”这里要澄清一个重要原则CustomPaint 只负责“高频变化且结构规则”的部分不是把所有 UI 都塞进 Canvas。我的界面布局拆成了三层主棋盘区一个CustomPaint绘制背景、网格线、已落定方块、活动方块和幽灵块。预览区一个小尺寸CustomPaint只绘制下一个方块的形状。文字 HUD得分、消除行数、游戏状态这些文字直接用Text组件外面包一层ValueListenableBuilder跟棋盘完全解耦。有些教程喜欢把文字也用TextPainter画进同一个 Canvas技术上完全可行但我实际对比之后放弃了。原因有两个第一文字刷新频率远低于棋盘没必要跟着每一帧一起重绘第二OpenHarmony 适配分支的字体渲染链路比标准 Flutter 平台复杂交给系统控件更省心。2. 动手前先确认OpenHarmony 上的 Flutter 工程到底怎么搭2.1 用社区维护的适配分支别用官方主分支Flutter 官方仓库目前并没有直接把 OpenHarmony 作为一等平台发布。要在开源鸿蒙设备上跑 Flutter我们用的是社区维护的适配分支——我这边拉的是flutter_flutter的 OpenHarmony 分支克隆下来之后把bin目录加进PATH即可。这里有个非常容易踩的坑如果你直接用官方stable分支的 SDK 去创建 OpenHarmony 工程执行flutter create的时候会直接报错提示类似ohos is not a supported platform。因为官方 SDK 里根本没有注册这个平台类型。注意第一次使用适配分支时flutter doctor显示的版本号可能是自定义的不要慌这是正常现象。只要它能识别ohos平台就行。2.2 依赖拉不下来先配好镜像再谈别的OpenHarmony 适配分支的依赖拉取和标准 Flutter 不太一样pub 仓库地址如果不配pub get大概率会卡住或者超时。我在实际项目里直接把环境变量指向华为云的 Flutter 镜像问题立刻消失。Linux / macOS 上执行export PUB_HOSTED_URLhttps://pub.flutter-io.cn export FLUTTER_STORAGE_BASE_URLhttps://storage.flutter-io.cnWindows 上就在系统环境变量里分别新增这两个变量值同上。这里多说一句不要嫌配镜像麻烦OpenHarmony 开发链路里涉及的构建产物、引擎二进制都比较大网络波动会让你反复怀疑人生。配好了之后flutter doctor和pub get基本都能顺利通过。2.3 从创建工程到看见第一帧工程创建命令比标准平台多一点参数flutter create --platforms ohos --org com.example tetris_ohos执行完之后项目里会多出一个ohos目录里面是一套标准的 OpenHarmony 工程结构build-profile.json5、entry模块等等。Flutter 引擎以依赖方式嵌进 OpenHarmony 工程运行时通过自定义的FlutterOHOS容器接入设备窗口。设备调试需要用到 OpenHarmony 的 hdc 工具类似 Android 世界的 adb流程是hdc list targets # 确认设备是否在线 flutter devices # Flutter 侧确认设备 ID flutter run -d deviceId # 跑起来实测下来OpenHarmony 分支的热重载r大多数场景是可用的但一旦改了pubspec.yaml或者ohos原生目录里的代码就得重新flutter run。不要对热重载抱有不切实际的期望这一点在适配初期尤其明显。2.4 顺带聊聊ArkTS 和 Flutter 选哪个每次聊到 OpenHarmony 开发总会有人问“为什么不直接用 ArkTS”。我自己的观察是ArkTS 作为 OpenHarmony 的第一方语言系统能力对接最直接如果团队只做开源鸿蒙单端那必须选 ArkTS。但如果你的团队已经有 Flutter 技术栈或者产品需要同时覆盖 Android / iOS / OpenHarmony 多个端Flutter 的价值就很明显了——它通过自绘引擎保证渲染一致性一套界面代码跨端复用。这篇文章聊的 CustomPaint就是那个“一致性”的底层支撑。所以 ArkTS 和 Flutter 不是谁替代谁而是看你的产品形态落在哪儿。3. CustomPaint 绘制实战从空画布到完整游戏画面3.1 先把 CustomPaint 的几个参数盘清楚进入绘制之前有必要花一分钟把CustomPaint的构造参数讲明白。因为它有几个参数用不对会很误导人参数作用我的建议painter实际执行绘制的CustomPainter对象必须传核心逻辑都在这里foregroundPainter在子节点之上绘制的 painter几乎不用容易造成图层混乱size画布的“建议尺寸”不要过度依赖最终以父布局约束为准isComplex提示引擎“这个画布绘制较复杂”谨慎使用需要配合RepaintBoundary才有效isWillChange提示引擎“画布会频繁变化”同上属于优化提示不是开关实际绘制过程中painter.paint()方法收到的Size参数才是真正能用的绘制区域它由父级约束决定。所以我在棋盘CustomPaint外层套了一个AspectRatio(aspectRatio: 0.5)强制画布维持 10:20 的宽高比这样内部就不用再自己算棋盘比例了。3.2 先把棋盘数据模型定下来再谈画法绘制和逻辑解耦是我在整个系列里一直坚持的原则。这一篇不做输入和下落逻辑但数据结构必须先定好不然paint方法里拿不到东西可画。我用一个快照类来描述游戏状态class GameSnapshot { final ListListint? grid; // grid[y][x]null 表示空格子int 是颜色索引 final DraggingBlock? current; // 当前正在下落的方块 final ListListint preview; // 下一个方块的相对坐标 final int score; const GameSnapshot({ required this.grid, required this.current, required this.preview, required this.score, }); } class DraggingBlock { final int type; // 方块类型对应颜色索引 final int x; // 所在列 final int y; // 所在行 final ListListint cells; // 相对坐标比如 [[0, 1], [1, 1]] const DraggingBlock({ required this.type, required this.x, required this.y, required this.cells, }); }这里grid是 10 列 × 20 行的二维数组。int存的是颜色索引而不是Color对象因为棋盘里有 7 种标准方块颜色做一个palette颜色列表索引对上去即可。数据结构和绘制完全解耦之后后续做输入控制、消行计算只需要生成新的GameSnapshot绘制层天然就能响应。3.3 paint 方法的分层绘制顺序决定成败CustomPainter.paint()里的绘制顺序直接决定最终视觉效果。我的绘制顺序是画背景底色画已落定的方块画当前活动方块画幽灵块落点提示最后画网格线这里强调一个经验网格线要放在最上层但颜色必须非常浅透明度要给到 10% 左右。如果画在最底层方块落上去之后网格线就被挡住了棋盘看起来缺少结构感如果颜色太重又会影响方块的颜色辨识度。我自己用的网格线颜色是Color(0x14FFFFFF)也就是白色 8% 透明度既通透又能看清格子边界。核心绘制代码大致如下class BoardPainter extends CustomPainter { BoardPainter({ required this.snapshot, required this.palette, required this.cellSize, required this.dpr, }); final GameSnapshot snapshot; final ListColor palette; final double cellSize; final double dpr; final Paint _bgPaint Paint()..color const Color(0xFF10141C); final Paint _linePaint Paint() ..color const Color(0x14FFFFFF) ..strokeWidth 1.0; late final ListPaint _blockPaints List.generate(palette.length, (i) { return Paint()..color palette[i]; }); override void paint(Canvas canvas, Size size) { final boardWidth cellSize * 10; final offsetX (size.width - boardWidth) / 2; // 棋盘水平居中 // 1. 背景 canvas.drawRect( Rect.fromLTWH(offsetX, 0, boardWidth, cellSize * 20), _bgPaint, ); // 2. 已落定方块 for (var y 0; y 20; y) { for (var x 0; x 10; x) { final idx snapshot.grid[y][x]; if (idx ! null) { canvas.drawRect( Rect.fromLTWH( offsetX x * cellSize, y * cellSize, cellSize, cellSize, ), _blockPaints[idx], ); } } } // 3. 活动方块 final cur snapshot.current; if (cur ! null) { final paint _blockPaints[cur.type]; for (final cell in cur.cells) { canvas.drawRect( Rect.fromLTWH( offsetX (cur.x cell[0]) * cellSize, (cur.y cell[1]) * cellSize, cellSize, cellSize, ), paint, ); } } // 4. 网格线这里只演示纵向横向同理 for (var i 0; i 10; i) { final x _alignLine(offsetX i * cellSize); canvas.drawLine( Offset(x, 0), Offset(x, cellSize * 20), _linePaint, ); } } double _alignLine(double logicalValue) { return (logicalValue * dpr).round() / dpr 0.5 / dpr; } override bool shouldRepaint(covariant BoardPainter oldDelegate) { return oldDelegate.snapshot ! snapshot; } }几个细节我需要展开讲一下_blockPaints是在 painter 对象里预先创建好的Paint列表7 种颜色创建一次绘制时直接复用。千万不要在paint()里每画一个方块就Paint()..color xxx高频调用下这种小对象分配会显著拖慢 GC。offsetX的作用是让 10 列棋盘在画布中水平居中因为棋盘画布宽度 10 * cellSize而父级给的Size.width可能会宽一点居中之后画面不会偏左或偏右。方块颜色直接用Color(0xFF...)硬编码到palette里而不是用Colors.red这种 Material 色因为自绘场景下少一层主题解析颜色也更可控。3.4 网格线发虚问题一个物理像素的执念网格线是俄罗斯方块界面的门面。如果网格线是虚的、糊成一条 2px 宽的灰带子整个画面立刻显得廉价。我第一次在 OpenHarmony 真机上看效果时就撞上了这个问题。原因在于Flutter 里的坐标单位是逻辑像素而屏幕实际渲染是物理像素。当devicePixelRatiodpr不是 1 的时候一条 1 逻辑像素宽的线很可能落在两个物理像素之间的边界上系统只能用抗锯齿把它“糊”成两个半像素宽的线视觉上就是发虚。解决办法是在绘制时把坐标对齐到物理像素网格上。代码里的_alignLine就是干这个的double _alignLine(double logicalValue) { return (logicalValue * dpr).round() / dpr 0.5 / dpr; }逻辑拆解一下logicalValue * dpr把逻辑坐标换算成物理坐标.round()对齐到最近的物理像素/ dpr换回逻辑坐标最后再偏移0.5 / dpr半个物理像素——加上这半个偏移线的中心正好落在物理像素上画出来就是锐利的 1px 实线。OpenHarmony 设备上的 dpr 常见是 2 或 3所以这个坑几乎是必踩的。以后你画任何固定坐标的线条、边框都可以用这个思路。3.5 得分文字到底该不该画进 Canvas关于得分、状态文字的绘制我上面提过我选择了Text组件而不是TextPainter。这里补充一下操作细节。如果你真的想让所有东西都在 Canvas 里TextPainter的基本用法是这样的final tp TextPainter( text: TextSpan(text: SCORE, style: const TextStyle(color: Colors.white70, fontSize: 12)), textDirection: TextDirection.ltr, )..layout(maxWidth: 120); tp.paint(canvas, const Offset(16, 16));如果你只想做一次静态绘制这没问题。但我的实际项目里得分每秒要变多次每次paint()都要重新TextPainter、重新layout()字数一多开销就上去了。而且 OpenHarmony 适配分支的文本布局链路尚未完全优化高频率TextPainter.layout()在低端设备上很容易成为性能瓶颈。所以最终方案的职责划分是棋盘区和预览区CustomPaint 自绘因为它们的高频变化场景是色块Canvas 画矩形便宜。文字区普通Text组件 ValueListenableBuilder刷新粒度细且文本渲染走 Flutter 的标准管线更稳。4. 刷新策略与性能调优让画面稳定跑在 60FPS4.1 最野路的写法每秒无脑 setState 整个页面我见过不少初学者写俄罗斯方块会在State里起一个Timer.periodic(Duration(milliseconds: 500))然后在回调里直接setState(() {})——整个页面全部重建。这种写法在纯Text界面里也许能跑但放到这个游戏里就是灾难。因为setState会触发整个Widget树重新构建棋盘CustomPaint的didUpdateWidget会一路被调用paint()也会跟着重刷。实际上你只是想让活动方块往下走一格结果得分、预览、背景全部跟着陪跑一遍。正确的做法是缩小状态范围。我引入了两个ValueNotifierValueNotifierGameSnapshot boardState只驱动棋盘和预览区重绘。ValueNotifierint scoreState只驱动得分文字更新。然后界面上用ValueListenableBuilder分别监听哪个数据变了就只刷新对应区域。ValueListenableBuilderGameSnapshot( valueListenable: boardState, builder: (context, snapshot, _) { return RepaintBoundary( child: CustomPaint( painter: BoardPainter( snapshot: snapshot, palette: palette, cellSize: cellSize, dpr: dpr, ), size: Size.infinite, ), ); }, )这样做的好处是网格线、背景这些静态内容在 snapshot 不变时不会重绘得分变化也不会带动棋盘刷帧性能和代码可维护性同时提升。4.2 RepaintBoundary把重绘的边界明确圈起来CustomPaint 本身不会自动限制重绘范围。即使你只在棋盘区域 setState只要它的父级层没有隔离渲染引擎依然有可能做整层重绘。这个时候要动用RepaintBoundary。RepaintBoundary的作用是把它的子树提升到一个独立的 Layer 上。Layer 内部发生变化时引擎只需重绘这一层而不是整个页面。俄罗斯方块这种上下分区明显的界面非常适合用它把棋盘区、预览区、HUD 区分别圈起来。有一个参数我要特别提醒CustomPaint的isComplex和isWillChange。如果你已经用RepaintBoundary包住了画布isComplex: true可以提示引擎使用更高质量的光栅化缓存isWillChange: true则告诉引擎这块画布即将频繁变化不要过度缓存。这两个参数要配合实际场景来用——棋盘画布频率高适合isWillChange: true预览区变化频率低适合isComplex: true。4.3 paint() 方法内部的三个“不要”把paint()当成一个“极简绘制函数”来写这是我从这个项目里得到的最重要教训。具体来说有三个“不要”不要在 paint() 里 new 大的对象Paint、TextPainter、Path这些对象尽量成员复用。paint 方法调用频率极高每次构建对象都会产生垃圾GC 一频繁帧率曲线立刻出现锯齿。不要在 paint() 里做异步或 IOCanvas 绘制过程是同步的你在paint()里await任何东西都等于阻塞渲染管线。数据准备、文件读取、图片加载全部放到绘制之前做完。不要在 paint() 里拿布局信息MediaQuery、LayoutBuilder、context.size这些都不要在 paint 里访问。尺寸信息已经通过Size size参数传进来了额外状态提前从构造函数注入即可。这三点每一条我都在真机上验证过。最夸张的一次是浮动方块时在paint()里创建TextPainter画提示文字性能帧耗时直接涨了 3 倍不止抽出来之后立刻恢复。4.4 顺便聊一下渲染引擎为什么 OpenHarmony 上走的是 Skia有朋友问我“Flutter 不是已经有 Impeller 了吗OpenHarmony 上是不是也切过去了”实际情况是Impeller 是 Flutter 新一代渲染引擎目前主要作为 iOS 平台的默认引擎Android 平台还在逐步推广而 OpenHarmony 的 Flutter 适配分支目前仍然基于 Skia 渲染。这意味着你在 OpenHarmony 上通过 CustomPaint 写 Canvas 代码最终都由 Skia 执行。Skia 对 CustomPaint 这种纯色块、矩形绘制的场景是非常擅长的性能完全可以接受。我实测下来满屏 200 个格子全部重绘单帧绘制耗时也就 2~3ms离 16ms 的帧预算还有很大距离。真正拖后腿的往往不是绘制本身而是频繁的 Widget 重建和 layout。所以没必要等 Impeller 铺过来。现阶段做好自绘区域划分、状态隔离Skia 足够把这局游戏稳稳定在 60FPS。5. 真机调试与踩坑记录从白屏到完整画面的修复过程5.1 hdc 连接与第一个白屏的排查思路OpenHarmony 设备调试和 Android 很像但不叫 adb叫 hdc。连接流程hdc list targets # 查看在线设备 hdc shell # 进入设备终端可选 flutter devices # 确认 Flutter 能看到设备 flutter run -d deviceId白屏是新手最常见的故障。我的排查顺序是先看控制台有没有 Flutter 引擎初始化的报错日志再确认ohos目录里的entry模块是否正确引用了 Flutter 容器最后确认 CPU 架构是否匹配OpenHarmony 设备上 arm64 和 x86 模拟器是不同的。如果是引擎初始化成功但界面空白大概率是 Flutter 页面没有挂到原生容器上。检查entry/src/main/ets/下的页面代码确保FlutterContainer或等价组件已经加载了 main.dart 的入口。5.2 画布尺寸被系统栏吃掉棋盘绘制完成后我发现的第一个视觉问题是最底部一行方块被系统导航条挡住了。原因很简单我把CustomPaint直接塞进了body而没考虑底部安全区。解法有两个用Scaffold的body时手动MediaQuery.of(context).padding做偏移。更省心的是把棋盘区域放在SafeArea里面然后用布局约束去限制CustomPaint的尺寸而不是在画布里写死坐标。我采用的是后者。因为CustomPaint的paint()收到的Size参数本来就是 SafeArea 之后的区域只要画布的cellSize是根据实际传入的Size动态计算的就不会被系统栏吃掉。这里引出另一个经验不要在CustomPaint里硬编码任何尺寸。cellSize应当在外部通过LayoutBuilder或者MediaQuery计算好后传入 painter这样在不同分辨率、不同屏幕比例的 OpenHarmony 设备上棋盘都能自适应。5.3 低配设备掉帧优化把静态层和动态层拆开OpenHarmony 的开发板上性能参差不齐。我在一颗相对低端的芯片设备上测试时发现整个画面有明显掉帧火焰图显示重绘集中在整块画布。分析之后定位到问题我把背景和网格线也放进了同一个paint()里每次活动方块移动背景和网格线都要跟着重绘一遍。这部分明明是不变的。优化方案是把静态层和动态层拆成两个CustomPaint叠在一起下层背景 网格线只在棋盘尺寸变化时重绘。上层已落定方块 活动方块 幽灵块只依赖GameSnapshot变化。实测优化效果很明显对比数据大概是这样场景优化前平均帧耗优化后平均帧耗方块下落单帧重绘8.4ms2.1ms消行动画连续 10 帧10.2ms3.6ms静止画面空转6.1ms0.8ms底层静态画布虽然也会被上层触发局部合成但 Skia 对已经缓存好的 Layer 做叠加代价比重新执行全部绘制逻辑低得多。如果你连这层合成开销都想省可以先把静态层绘制到ui.PictureRecorder里缓存再通过canvas.drawPicture直接贴到画布上。我在项目里做到了这一步不过常规设备其实用不到这么极致。5.4 几个值得坚持的绘制习惯绕了这么多坑最后沉淀下来几条习惯写在这里给读者做个总结绘制顺序分层固定背景、已落定方块、活动方块、幽灵块、网格线这个顺序不要乱。一旦乱了方块之间的遮挡关系就会出问题。所有可复用的绘制对象都成员化Paint、TextPainter、Rect缓存不要偷懒在paint()里临时创建。save/restore 成对使用一旦用了canvas.translate、clipPath、transform一定要记得canvas.save()和canvas.restore()配对否则状态残留会污染后续绘制。每个画布只负责一件事棋盘画布不画文字预览画布不画网格。职责单一不仅性能好排查问题也快。棋盘能稳定画出来了接下来的重头戏就是让方块真正动起来——键盘和触摸输入、下落逻辑、旋转碰撞检测、消行判定这些我们下一篇接着聊。到时候你会发现绘制层一旦把数据结构定义清楚后续的逻辑接入会顺畅得多。