以前聊 Flutter 跨端,默认聊的是 Android、iOS,这半年 Flutter for OpenHarmony 的话题明显多了起来。前阵子为了验证 Flutter 到底能不能在 OpenHarmony 设备上接住“自绘 UI + 实时交互”这种硬需求,我特意做了一个七巧板游戏。七巧板只有七块几何图形,但要把拼图、旋转、拖动、碰撞全部跑通,需要的可不是写个列表页那么简单。CustomPainter 的自绘能力、多边形的几何建模、旋转后的碰撞检测、手势事件的稳定分发,全都会被牵扯出来,而这恰好是 Flutter + OpenHarmony 适配最需要验证的部分。这篇文章就把我从环境搭建到多边形碰撞算法落地的完整过程记录下来,顺带聊几个我在 OpenHarmony 真机上踩过的坑,给准备在 OpenHarmony 上跑 Flutter 项目的同学做个参照。
1. 为什么拿七巧板来验证 Flutter 在 OpenHarmony 上的“真能用”
1.1 一个把绘制、手势、几何计算全部拉满的最小项目
很多团队在 OpenHarmony 上验证 Flutter 的时候,首选是写一个业务页:列表、图片、按钮。确实能验证 widget 渲染和基本交互,但验证不了自绘。七巧板不一样,它需要你把每块拼图的轮廓用一个多边形表达出来,然后用 Canvas 画到屏幕;后面用户拖拽、旋转时,又需要持续的几何计算——顶点变换、碰撞检测、吸附判断。
这些能力不是七巧板专属,绘图工具、益智类游戏、白板课件、设计类 App 的底层几乎都是同一套东西。七巧板的优势是规模足够小:一共就 7 块图,每块最多 4 个顶点。就算碰撞检测写错了,你也能一眼看到是哪里算错,不会像大型游戏项目那样在几千行代码里大海捞针。说白了,这就是一个“麻雀虽小、五脏俱全”的验证载具。
1.2 Flutter 在 OpenHarmony 上的真实处境
先说结论:Flutter 官方主线并没有把 OpenHarmony 列为正式支持平台,目前在 OpenHarmony 上跑 Flutter,走的是 OpenHarmony SIG 社区维护的 flutter_flutter 分支。这个分支继续沿用 Dart 层和框架层代码,但把 Flutter Engine 的 shell、光栅化、平台通道对接到了 OpenHarmony 的 Native API 和 XComponent 能力上。
也就是说,你熟悉的 Flutter 写法基本不变,widget、RenderObject、Canvas API 都能用,但平台侧不能随意引一堆只支持 Android/iOS 的插件,很多插件在 ohos 平台上还没有对应实现。因此,选型之前就要有心理预期:把项目范围控制在“自绘 UI + 轻量交互 + 少量平台能力”之内,七巧板就是正好落在这个范围里的项目。
1.3 面试常问的适配点,其实就这三个
Flutter 鸿蒙相关的面试题这两年越来越多,问来问去绕不开三块:第一,Flutter 在 OpenHarmony 上的渲染链路是什么;第二,工程怎么创建,SDK 怎么匹配;第三,遇到画面渲染异常或者事件分发异常怎么排查。这些问题的答案不会写在官方 Flutter 文档里,只能靠实际项目积累。下面几节会按照这三个方向展开:环境搭建讲 SDK 匹配,几何与碰撞讲渲染和计算,最后的实战记录专门讲渲染异常、Isolate 和手势事件。把这个项目完整跑完,这些面试点基本也都能答上来了。
2. 环境搭建:Flutter SDK、OpenHarmony SDK 与三个容易踩的坑
2.1 工具链组成:别用官方 Flutter,用社区分支
OpenHarmony 上的 Flutter 工具链不是“官方 Flutter + 一个插件”就能搞定,而是需要整套 SDK 替换。操作上大致是这样:
git clone https://gitee.com/openharmony-sig/flutter_flutter.git cd flutter_flutter git checkout openharmony export PATH="$PWD/bin:$PATH" flutter doctor这里要特别注意,clone 下来的不是普通 Flutter 仓库,而是带 openharmony 支持的分支。如果你本地已经装了官方 Flutter SDK,建议把环境变量隔离好,或者直接用这个目录下的 flutter 命令,避免两个 SDK 混用。
OpenHarmony SDK 这边,用 DevEco Studio 安装即可,安装后需要确认 native 和 toolchains 组件都在。这是一个非常关键的基础环境,因为 Flutter 的 ohos 平台工程会直接调用 OpenHarmony 的 native 编译链。IDE 的选择上,我日常用 VSCode 写 Dart 代码,装好 Flutter 和 Dart 插件后,在 VSCode 里就能执行 flutter run、热重载。只有需要调整 ohos 工程本身配置的时候,才会切到 DevEco Studio 打开工程目录。
2.2 从创建工程到真机跑起来
环境就绪后创建工程也有讲究。直接用命令生成带 ohos 平台的工程:
flutter create --platforms ohos tangram cd tangram flutter devices flutter run -d <device-id>如果flutter devices能看到 ohos 设备,说明工具链基本打通。首次构建时间会比较长,因为 Flutter Engine 在 ohos 平台的产物需要本地编译或者下载预编译包,几分钟不等。建议第一次先用仓库自带的示例工程跑一遍,确认设备、签名、构建链路都通,再切到自己的项目。直接在项目里排错会比较痛苦,因为你没法确定问题出在业务代码还是工具链。
签名问题也是一个容易被忽略的环节:OpenHarmony 设备不是开箱就能安装 hap,需要在 DevEco Studio 里配置好自动签名,或者用你设备所属组织的签名文件。这一步没配好,构建成功也会在安装阶段失败。
2.3 三个让我浪费时间的坑
我把实际遇到过的问题整理成了表格,方便对照排查:
| 坑 | 现象 | 处理方式 |
|---|---|---|
| ohos-sdk 版本与 Flutter 分支不匹配 | native 链接报错,符号找不到 | 回退或升级 ohos-sdk 到仓库 README 指定版本 |
| NDK 版本错误 | 编译能过但 so 加载失败,运行闪退 | 在 ohos 工程的 build-profile 里指定与分支一致的 NDK |
| 分支切换后依赖缓存混乱 | pub get 卡住、依赖版本异常 | 清理 pub 缓存后重新安装依赖 |
除了这几个,还有一个启动图问题。生成的 ohos 工程默认启动图是 Flutter 的默认白屏,冷启动时会有那么一瞬间的“空白感”。要处理的话,去 ohos/app 的模块配置里替换 launch 背景图,相当于 Android 的 Launch Theme。这个不起眼,但很影响第一印象,尤其是做演示的时候。
3. 七巧板的数据模型:先算尺寸,再画图形
3.1 从正方形切割推导各板块比例
七巧板在数学上就是把一个正方形按特定分割线切成七块。我以整体正方形边长 4 作为基础单位来推导。两个大三角形都是直角等腰三角形,斜边等于正方形的边,直角边等于 2√2,面积各为 4;中三角形直角边为 2,面积 2;两个小三角形直角边为 √2,面积各为 1;正方形边长为 √2,面积 2;平行四边形两邻边分别为 2 和 √2,面积也是 2。合计 4+4+2+1+1+2+2=16,刚好等于大正方形面积。
这里最容易被带偏的是:正方形面积是 2,不是 1。很多人下意识把正方形当成最小块面积 1,结果整个棋盘比例失衡。我自己第一次建模时就栽在这里,后来把全部面积列出来核对才意识到问题。做几何类应用前,先把每块形状的面积加总校验一遍,能省下很多排错时间。
3.2 板块基元:局部坐标加全局变换
在代码里,我让每个板块保存自己的类型、位置、旋转角、缩放比例,顶点坐标在需要时通过本地坐标变换得到。这样可以避免把每个板块的坐标硬编码在全局坐标系里,拖拽、旋转、缩放都好做。
我把七块拼图的局部坐标先定义好,再通过统一的变换函数得到世界坐标。基础顶点定义如下:
import 'dart:math'; class Vec2 { const Vec2(this.x, this.y); final double x; final double y; Vec2 operator +(Vec2 other) => Vec2(x + other.x, y + other.y); Vec2 operator -(Vec2 other) => Vec2(x - other.x, y - other.y); Vec2 operator *(double scalar) => Vec2(x * scalar, y * scalar); double dot(Vec2 other) => x * other.x + y * other.y; double get length => sqrt(x * x + y * y); Vec2 get normalized { final len = length; if (len == 0) return const Vec2(0, 0); return Vec2(x / len, y / len); } @override String toString() => '($x, $y)'; } enum TangramKind { largeTriangle, middleTriangle, smallTriangle, square, parallelogram } List<Vec2> baseVertices(TangramKind kind) { final r = sqrt(2.0); switch (kind) { case TangramKind.largeTriangle: return [const Vec2(0, 0), Vec2(2 * r, 0), Vec2(0, 2 * r)]; case TangramKind.middleTriangle: return [const Vec2(0, 0), const Vec2(2, 0), const Vec2(0, 2)]; case TangramKind.smallTriangle: return [const Vec2(0, 0), Vec2(r, 0), Vec2(0, r)]; case TangramKind.square: return [const Vec2(0, 0), Vec2(r, 0), Vec2(r, r), Vec2(0, r)]; case TangramKind.parallelogram: // 两邻边分别为 2 和 sqrt(2),面积 = 2 return [ const Vec2(0, 0), const Vec2(2, 0), const Vec2(3, 1), const Vec2(1, 1), ]; } } List<Vec2> transformVertices( List<Vec2> base, Vec2 offset, double angleDeg, double scale, ) { final rad = angleDeg * pi / 180; final cosA = cos(rad); final sinA = sin(rad); return base.map((v) { final rotatedX = (v.x * cosA - v.y * sinA) * scale; final rotatedY = (v.x * sinA + v.y * cosA) * scale; return Vec2(rotatedX + offset.x, rotatedY + offset.y); }).toList(); }有一个关键架构点:绘制和碰撞检测必须共用同一套顶点数据。很多初学者喜欢在绘制时用 Path 画完就不管了,碰撞检测另算一套坐标,结果两边对不上。我这里直接用变换函数得出每个板块的全局顶点数组,绘制时用顶点生成 Path,碰撞检测也直接用这套顶点,彻底避免数据不一致。
3.3 用 CustomPainter 把七个板块画出来
绘制部分就是标准的 CustomPainter。每个板块用一个 Path,把顶点依次 moveTo、lineTo,最后 close。三角形、四边形都适用。我给每个板块加了一层描边,既有装饰作用,也可以在调试时看清板块边界:
class TangramBoardPainter extends CustomPainter { TangramBoardPainter({required this.pieces}); final List<TangramPiece> pieces; @override void paint(Canvas canvas, Size size) { final fillPaint = Paint() ..style = PaintingStyle.fill ..isAntiAlias = true; final strokePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2 ..color = const Color(0xFF888888) ..isAntiAlias = true; for (final piece in pieces) { final vertices = piece.vertices; final path = Path(); for (int i = 0; i < vertices.length; i++) { if (i == 0) { path.moveTo(vertices[i].x, vertices[i].y); } else { path.lineTo(vertices[i].x, vertices[i].y); } } path.close(); fillPaint.color = piece.color; canvas.drawPath(path, fillPaint); canvas.drawPath(path, strokePaint); } } @override bool shouldRepaint(TangramBoardPainter oldDelegate) { return oldDelegate.pieces != pieces; } }绘制代码里有两处值得注意:一是 Canvas 坐标方向是 y 轴向下,角度正方向是顺时针,这和数学课本里的坐标系习惯相反;二是 Path.transform 可以直接变换路径,但这里我选择先算顶点再生成 Path,是因为碰撞检测需要顶点坐标。如果只管绘制,用 Path.transform 会省事不少,可一旦后面要接碰撞逻辑,最终还是得回到顶点层面。
4. 多边形碰撞检测:从 AABB 到分离轴定理(SAT)的完整落地
4.1 旋转之后的 AABB 为什么失效
如果给每个板块包一个轴对齐包围盒(AABB),AABB 的优点是计算极快,一两次比较就能排除不可能碰撞的对象。但在七巧板里,板块会旋转,旋转后的 AABB 不等于原始 AABB 等比缩放。举个例子,正方形旋转 45 度之后,它的 AABB 会变成一个明显更大的“外接方形”,区域比实际图形大了一圈。
这会导致一个很常见的表现:玩家明明还没碰到另一块板,系统就认为碰撞了,也就是“假碰撞”。所以 AABB 只能做粗筛,不能在旋转场景中直接作为精确碰撞结果。真正的精确判定,要回到多边形顶点本身来做。
4.2 SAT 的核心:分离轴、投影、重叠判断
分离轴定理(SAT,Separating Axis Theorem)是解决凸多边形碰撞的标准方案。它的原理可以这样理解:两个凸多边形只要存在一条直线(分离轴),使得它们在垂直于这条直线的投影区间互不重叠,那这两个多边形就一定没有接触。反过来,如果所有可能的轴上都出现投影重叠,那它们必定碰撞。
对于两个凸多边形,只需要检查每条边的法线即可,不需要穷举空间中的无数方向。每检测一条轴,就把两个多边形的全部顶点投影到轴上,得到一个最小值和最大值形成的区间,然后比较两个区间是否重叠。只要有一条轴区间分离,立即判定不相交。
我习惯用的类比是“手电筒从侧面照”:从某个角度打光,两个物体的影子中间有缝隙,那它们肯定没碰到;如果每个角度影子都叠在一起,才说明碰到了。
4.3 Dart 实现:Vec2、Polygon 与 SAT 主逻辑
为了代码清晰,我定义了 Polygon 类,负责提供边法线(也就是候选轴)和某个轴上的投影区间。完整实现如下:
class Projection { const Projection(this.min, this.max); final double min; final double max; } class Polygon { Polygon(this.vertices); final List<Vec2> vertices; Vec2 get center { var x = 0.0, y = 0.0; for (final v in vertices) { x += v.x; y += v.y; } return Vec2(x / vertices.length, y / vertices.length); } // 每条边的法线,作为候选分离轴,记得归一化 List<Vec2> get axes { final result = <Vec2>[]; for (int i = 0; i < vertices.length; i++) { final p1 = vertices[i]; final p2 = vertices[(i + 1) % vertices.length]; final edge = p2 - p1; result.add(Vec2(-edge.y, edge.x).normalized); } return result; } // 在指定轴上的投影区间 Projection project(Vec2 axis) { var min = double.infinity; var max = double.negativeInfinity; for (final v in vertices) { final d = v.dot(axis); if (d < min) min = d; if (d > max) max = d; } return Projection(min, max); } } class CollisionInfo { const CollisionInfo(this.hit, this.mtv); final bool hit; final Vec2? mtv; } CollisionInfo satCollision(Polygon a, Polygon b) { final axes = [...a.axes, ...b.axes]; Vec2? minAxis; var minOverlap = double.infinity; for (final axis in axes) { final pa = a.project(axis); final pb = b.project(axis); final overlap = min(pa.max, pb.max) - max(pa.min, pb.min); if (overlap <= 0) { return const CollisionInfo(false, null); } if (overlap < minOverlap) { minOverlap = overlap; minAxis = axis; } } // 用中心连线方向校准 MTV 方向,避免把被拖拽板块推向更深处 final mtv = minAxis! * minOverlap; final centerDelta = b.center - a.center; if (mtv.dot(centerDelta) < 0) { return CollisionInfo(true, mtv * -1); } return CollisionInfo(true, mtv); }需要注意的地方:
- 法线必须归一化,否则后面 MTV 的长度和方向都不准确。
- 顶点按顺时针或逆时针连续排列都行,但必须保证是环状闭合,最后一条边的法线要用最后一个顶点连回第一个顶点。
- 返回的 MTV 表示把 b 推离 a 的最小方向。UI 层到底用加法还是减法,取决于你的板块 position 是中心点还是基准点,保持一致即可。
用这样一套实现跑七巧板非常快。七块板块两两组合最多 21 对,三角形 3 条边、四边形 4 条边,一对最多 7 条轴,每条轴最多投影 8 个顶点,一帧最多也就一千多次点积运算。对 CPU 来说完全是无压力级别,这也是为什么七巧板这种小场景完全没必要把碰撞检测抛到 Isolate 里去。
4.4 碰撞之后怎么办:用 MTV 做复位和吸附
SAT 不仅能判断是否碰撞,还能算出最小平移向量(MTV,Minimum Translation Vector)。在所有轴的投影重叠里,重叠量最小的那条轴,其方向就是推开两个多边形所需的最短方向,重叠量大小就是最小位移长度。这个 MTV 在游戏中很有用,比如拖拽板块时一旦检测到碰撞,可以沿 MTV 方向把被拖拽板块推回一点,避免穿模。
七巧板里还有一个更贴近“拼图手感”的用法:当检测到碰撞,且 MTV 长度小于某个很小的阈值(比如 1 像素),说明板块已经几乎贴合在一起了,这时候直接把被拖拽板块沿 MTV 方向精确平移到贴合位置,再把它标记为“已吸附”。这样玩家拼图时会有明显的卡点感,体验比完全靠手微调要好很多。
4.5 浮点误差:贴合处的头发丝缝隙
几何计算做多了一定会遇到浮点精度问题。两个板块明明在视觉上已经贴紧了,但计算出来它们之间还有 0.001 的间隙,或者反过来有 0.001 的重叠。这个误差放在屏幕上通常看不出,但会让吸附判断变得不稳定,表现为“有时候能吸住,有时候吸不住”。
解决思路是统一做量化:吸附阈值不要设成绝对的 0,而是 0.1 到 0.5 像素;吸附完成后,把板块坐标和角度同时量化到 0.01 和 0.1 度。这样做后面拼图判胜也更稳定。还有一个调 UI 层面的小技巧:给画出来的多边形加一圈描边,即使没有完全贴紧,视觉上也会被描边遮住一部分缝隙,观感会好很多。
5. OpenHarmony 实战记录:渲染、Isolate 与手势事件
5.1 Impeller 不是捷径:当前渲染路径仍是 Skia
如果你关注 Flutter 近两年的动态,应该知道 Impeller 是 Flutter 新一代渲染引擎,用来解决 Skia 在后端编译 shader 时可能产生的掉帧问题。很多人在 Android 上习惯了“渲染异常就开 Impeller”的排错思路。但在 OpenHarmony 上,这个思路暂时行不通:社区适配分支的渲染路径还是以 Skia 为主,Impeller 并没有在 ohos 后端完整落地。
这就意味着,在 OpenHarmony 设备上遇到画面渲染异常时,排查重点要放在 Skia 相关的资源兼容性上,而不是去开一个并不存在的 Impeller 开关。比如某些设备的 GPU 驱动对特定 shader 支持不完整,或者绘制时用了 OpenHarmony 上尚未对齐的混合模式,这些才更可能是真正原因。我的建议是:先跑一个简单的自绘 Demo,确认设备的基础渲染链路没问题,再往上叠加业务绘制。
5.2 七巧板这种小场景,别急着上 Isolate
Flutter 多线程是个经常被过度使用的点。有些朋友一听到“游戏里的碰撞检测”,下意识就要把检测逻辑丢到 compute 或者 Isolate 里去,免得卡 UI 线程。但实际上开一个 Isolate 去处理七巧板这种几十个顶点的计算,开销远大于收益。
多线程真正的用武之地是重型计算:几百上千个多边形、连续多帧的物理模拟、大纹理处理等。判断该不该用 Isolate,我建议你先在 UI 线程跑一遍,如果一帧的耗时超过 6ms 到 8ms,再考虑拆分;如果只有零点几毫秒,那问题根本不在计算,而在别处。七巧板这个项目我实测下来,碰撞检测在一帧里的耗时几乎可以忽略,反而每帧新建 Path、Paint 对象带来的 GC 更值得关注。优化手段也是现成的:能缓存的 Path 就缓存,只在位置或角度变化时重建。
5.3 快速拖动时 onPanUpdate 丢事件的处理
这是我在 OpenHarmony 真机上调试时遇到的一个比较“玄学”的问题:手指快速拖动板块时,板块的运动会出现一帧跳变,像中间被吃掉了一些事件。排查发现,适配层的触摸事件在极快速滑动下,事件时间戳和坐标会有抖动,导致 onPanUpdate 拿到的 delta 不太稳定。
我没有去改框架层的适配,而是在应用层做了兜底。每次 onPanUpdate 只更新“目标位置”,渲染帧里再把板块移向目标位置;同时用 Listener 的 onPointerDown 记录初始坐标,从这个坐标开始拖动,避免第一帧坐标错位。这样处理后,快速拖动的表现平滑了不少。这个问题不同设备和系统版本表现不一样,如果遇到类似现象,可以优先怀疑事件坐标抖动,而不是你的手势逻辑写错了。
6. 把碰撞检测变成玩法:吸附、判胜与后续扩展
6.1 七块拼图是否完成:位姿阈值优先于多边形并集
很多拼图游戏的“判胜”看似要计算多边形并集,实际上对于七巧板这种固定目标图案的场景,更简单也更稳的方案是位姿阈值判断。预先记录每一块板块在目标图案里的位置、角度、缩放,玩家松手时计算板块当前位姿与目标位姿的差异。位置误差小于 3 像素、角度误差小于 1 度,就判定这一块归位;所有板块归位,则刷新关卡。
多边形并集算法(比如 Weiler-Atherton)在概念上更通用,但实现复杂度高,而且坑很多。比如自交、顶点重合、浮点误差都会导致计算结果异常。如果你的玩法不要求“任意自由拼合判定”,完全没必要一开始就上这种重型方案。先用手感友好的位姿阈值,等确实需要“判断两块拼图能不能拼成一个形状”这类玩法时,再考虑并集方案。
6.2 体验优化:接近目标角度时自动旋转补正
吸附除了位置上的对齐,还有角度上的补正。玩家拼图时,往往板块角度已经接近目标角度,就差那么几度,但手动拧准很费劲。我实现了一个很简单的逻辑:拖拽结束那一刻,如果板块当前角度和目标角度之差小于 30 度,就自动把它旋转到目标角度方向,再结合碰撞检测的贴合逻辑完成吸附。这样做门槛低、代码少,但体验提升非常明显。
这个逻辑本质上也是碰撞检测结果的一种复用:你已经算出了板块之间的贴合关系,就不会出现“位置已吸附但角度差十万八千里”的尴尬场景。
6.3 扩展思路:凹多边形、GJK 与空间网格
七巧板本身全部是凸多边形,SAT 用得很舒服。如果以后把玩法扩展到动物拼图、叶片形状这类带凹边的板块,SAT 就不能直接用了,得换思路。一种是把凹多边形拆成若干个凸多边形,对每对凸片分别跑 SAT,这也是工程里最常见的做法;另一种是上 GJK 算法做碰撞检测,它可以处理凸多边形和凸多边形的任意场景,但要想拿回碰撞深度和法线,还要搭配 EPA,实现复杂度明显高一个台阶。
板块数量如果多起来,还可以引入空间网格(spatial hash)做粗筛,只对相邻网格里的板块跑精确碰撞,避免 20 个板块每帧做 190 次两两检测。这些小技巧可以在七巧板项目里先留好接口,后续扩展就不会推倒重来。
调试碰撞检测时,我强烈建议把分离轴临时画到屏幕上:每次 SAT 检测到碰撞,就把对应的 MTV 方向和长度画成一条线。这样能直观看到是哪个方向的穿透,定位“为什么这一侧吸不住、那一侧吸过头”特别快。我之前排查吸附抖动问题,最后发现是我在计算边的法线时忘掉归一化,导致 MTV 方向正确但长度失真,把分离轴可视化之后一眼就暴露了。这个调试思路,算是我这次项目里最值回票价的一个经验。