Flutter 组件 clipper2 适配鸿蒙 HarmonyOS 这件事,表面上是把一套 C++ 几何算法库搬到一个新平台,真正做下来你会发现,你面对的不是一个库,而是一条从坐标输入、布尔运算、内存交换到平台构建的完整链路。我在做多端图形工具链的时候就栽过跟头:Flutter 自带的 Path 对象拿来画简单形状绰绰有余,可一旦牵扯到区域合并、道路缓冲、多边形的求交求差,性能和结果稳定性立刻见底。后来接触了 clipper2——一套用 C++ 编写的多边形布尔运算库,支持并集、交集、差集、异或和多边形偏移,而且基于整数坐标实现稳健算法,算得上工业级。于是我在鸿蒙端做了一次完整适配:搭建 Flutter 插件,把 clipper2 编成动态库,再用 dart:ffi 把 C 接口暴露给 Dart。整个过程踩了不少坑,也把路径治理从“能用”做到了“敢用在生产环境”。这篇文章不打算讲虚的概念,直接按我的实现路径把架构、坐标换算、性能治理和坑位拆开。
先纠正一个叫法:clipper2 本身不是一个 Flutter 插件,而是一套 C++ 实现的多边形布尔算法库。我们说的“Flutter 组件 clipper2 适配鸿蒙”,本质是把这套 C++ 库通过 dart:ffi 桥接成 Dart 可调用的组件,再封装出方便的高层 API。
1. 为什么执意把 clipper2 搬到鸿蒙
1.1 原生 Path 在复杂几何面前的无力感
我最早也试过用 Flutter 的Path.combine或canvas.clipPath来扛多边形运算。小场景没问题,画两个圆求个交集,几十个顶点的事,Dart 层随便处理。但真实生产环境根本不是这个量级:道路缓冲要沿着折线生成等距轮廓,地图围栏要按行政区合并若干多边形,标注工具要对用户手绘的几百个形状做批量差集。这时候Path.combine的问题暴露得很彻底。
第一个问题是运算种类有限。Path 合并基本是 OR 和 AND,想表达“A 挖掉 B”这种非对称差集,得反复转换边界,极容易弄混。第二个问题是精度。Flutter 底层走的是 Skia 路径运算,本质上是浮点路径的几何求交,遇到两条边几乎平行、顶点几乎重合、多边形自交这些情况,结果会出现肉眼可见的小缺口或者毛刺。第三个问题最致命——性能。我曾对一个 5 万顶点的多边形做差集,Dart 层直接卡到掉帧,原因是每次 Path 操作都会产生大量临时对象,Shape 计算也在同一线程里串行跑,根本没有批量优化的空间。
所以“够用”和“能扛”是两个世界。如果你是做一个 demo、一个原型,Path 就够了;但如果你要处理 GIS 区域合并、CAD 轮廓偏移、激光切割路径生成这类场景,原生 Path 的天花板非常明显。这也是我把目光转到 clipper2 的直接原因。
1.2 clipper2 的技术底牌:整数坐标与稳健布尔拓扑
clipper2 和早期版本 clipper1 最大的区别,在于它把所有坐标统一到 64 位整数体系下。不要小看这个改动,这是它敢说自己“工业级”的底气。
浮点坐标做几何求交,最大的问题不是慢,而是两个非常接近的交点难以判断到底算相交还是不相交。差一个 ULP 就可能导致整个拓扑关系翻转。整数坐标不一样:交点都在网格上,位置可精确比较,每条边的斜率和交点计算都用分数或整数方式表达,几乎不受浮点误差影响。这意味着同样的布尔操作,在 PC 上什么结果,在鸿蒙手机上跑还是什么结果,不会因为平台不同、CPU 指令集不同而出现几何漂移。
clipper2 对外最核心的两个入口是BooleanOp和InflatePaths。前者处理交、并、差、异或,后者做多边形等距偏移。底层实现可以理解成扫描线算法的现代改良版:先把所有多边形边按照顶点 y 坐标排序,然后从左到右扫描事件点,维护活跃边表,通过交点和进入/退出区间来判断哪些区域属于最终结果。这套机制对自交多边形也非常宽容,因为它本质上处理的是“一组有方向的闭合路径”,而不是简单的顶点数组。
还有个容易被忽略但很重要的概念:填充规则。clipper2 提供了 EvenOdd、NonZero、Positive、Negative 四套规则。同样一组多边形,使用 NonZero 和 EvenOdd 得到的结果可能完全不同。GIS 数据里岛洞关系严重依赖 NonZero,而图形渲染更习惯 EvenOdd。后面我会专门讲为什么必须把填充规则固定成项目级约定,否则测试用例永远处于薛定谔状态。
2. 工程骨架:从 C++ 裁剪核心到 Dart 的完整链路
2.1 鸿蒙 Flutter 插件的项目结构
鸿蒙端的 Flutter 插件和 Android 插件在思路上一脉相承,只是 native 工程和产物格式不同。我用flutter create --template=plugin生成插件骨架,然后手动叠加 Clipper 桥接代码,最终目录结构大致是这样:
my_clipper/ ├── lib/ │ ├── clipper_engine.dart // Dart 侧 API │ ├── clipper_ffi.dart // dart:ffi 绑定 │ └── clipper_data.dart // 数据容器和编码解码 ├── ohos/ │ ├── CMakeLists.txt │ └── src/ │ ├── clipper_bridge.cpp // C ABI 桥接层 │ └── third_party/ │ └── Clipper2/ // clipper2 源码 ├── android/ └── example/很多初写 FFI 的人会把 clipper2 编译成系统库去动态依赖,我劝你不要这么做。跨平台适配最怕的坑之一就是“本机编译好好的,到另一个设备上 so 加载失败”。最稳妥的做法是把 clipper2 源码直接放进third_party目录,随主工程一起编译成一个libclipper_bridge.so。反正 clipper2 的源码文件数量不多,编译时间完全可以接受,换来的是对依赖的绝对控制。
CMake 配置里有一条非常关键。现代 Flutter 插件的 C++ 侧默认可能只开 C99,而 clipper2 需要 C++17,至少也要 C++14。不加这行,编译时你会看到一堆模板语法报错,完全看不出是标准问题。
cmake_minimum_required(VERSION 3.10) project(clipper_bridge LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(clipper_bridge SHARED src/clipper_bridge.cpp src/third_party/Clipper2/Clipper.Core.cpp src/third_party/Clipper2/Clipper.Engine.cpp src/third_party/Clipper2/Clipper.Offset.cpp src/third_party/Clipper2/Clipper.RectClip.cpp ) target_include_directories(clipper_bridge PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/src/third_party ) target_compile_definitions(clipper_bridge PRIVATE USINGZ=0 )USINGZ=0是 clipper2 的一个开关。默认情况下它支持三维坐标,内部每个点要多存一个 z 值。平面几何根本不需要 z,关掉之后内存占用和顶点结构体体积都会缩小,结构也更紧凑。
2.2 C 接口层设计:不要让 Dart 直接看到 C++ 类
桥接层最忌讳的一件事,就是把 C++ 类对象直接reinterpret_cast成指针传给 Dart。Dart 侧无法安全构造 C++ 对象,也无法调用析构函数,一旦对象生命周期管理出问题,崩溃方式千奇百怪。我的做法是写一个极薄的extern "C"层,只暴露纯函数接口,输入输出全部用连续内存缓冲区描述。
核心布尔函数的设计是这样:
extern "C" { int32_t ccl_boolean_op( int32_t op, // 0=AND 1=OR 2=NOT 3=XOR int32_t fill_rule, // 0=EvenOdd 1=NonZero int64_t scale, // 浮点坐标缩放系数 const int64_t* subject_pts, // 主多边形,所有顶点扁平化存储 const int32_t* subject_sizes,// 每个主多边形的顶点数 int32_t subject_count, const int64_t* clip_pts, const int32_t* clip_sizes, int32_t clip_count, int64_t* out_pts, // 输出缓冲区 int32_t* out_sizes, int32_t max_out_count, int32_t* result_count ); }为什么全用扁平数组,不传vector<vector<Point>>?因为 Dart FFI 无法直接感知 C++ 容器,就算你把指针传过去,Dart 侧也读不到 vector 的内部布局。扁平化是最稳妥的 ABI:每个多边形是一个连续的点串,sizes数组记录每个点串长度,解析起来简单,拷贝时也只需要一次memcpy级别的操作。
在 Dart 侧,我保留了 C 的 ABI,然后包了一层类型安全的 API:
typedef BooleanOpNative = ffi.Int32 Function( ffi.Int32 op, ffi.Int32 fillRule, ffi.Int64 scale, ffi.Pointer<ffi.Int64> subjectPts, ffi.Pointer<ffi.Int32> subjectSizes, ffi.Int32 subjectCount, ffi.Pointer<ffi.Int64> clipPts, ffi.Pointer<ffi.Int32> clipSizes, ffi.Int32 clipCount, ffi.Pointer<ffi.Int64> outPts, ffi.Pointer<ffi.Int32> outSizes, ffi.Int32 maxOutCount, ffi.Pointer<ffi.Int32> resultCount, ); late final BooleanOpNative _booleanOp = _lib .lookupFunction<BooleanOpNative, BooleanOpDart>('ccl_boolean_op');这里有个细节值得说明:lookupFunction的泛型参数一个是 Native 函数的 C 类型签名,一个是 Dart 侧的 Function 类型签名。两个签名必须完全对应,少写一个参数或者把Int64写成IntPtr,轻则编译过不了,重则运行时内存错位。
很多人在第一步就倒在 ABI 上,所以我建议所有桥接函数参数只允许这几种形态:int32_t、int64_t、double、指针、以及固定的结构体数组。任何更复杂的类型都先做序列化,别让 Dart 和 C++ 之间产生结构体对齐上的猜疑。
3. 坐标换算与内存交换:最容易出错的两个环节
3.1 double 坐标定标:scale 到底该取多少
鸿蒙端接到的业务坐标,几乎都是浮点数:可能是经纬度,可能是设计稿里的逻辑像素,可能是从 CAD 里导出的毫米坐标。而 clipper2 的核心 API 是基于 64 位整数的,所以入门前必须想清楚“浮点坐标如何在整数域里表示”。
最常用的做法是放大定标。你可以把坐标理解为“物理值 × 缩放系数”,然后在 C 桥接层统一转换:
final int scaledX = (x * scale).round(); final int scaledY = (y * scale).round();scale取多少,我踩过很深的坑。取 1e3 太小,相邻很近的顶点会被压到同一个整数点上,布尔运算之后出现大量星形线和缝隙;取 1e9 又太大,一旦坐标本身是百万级别,乘以 1e9 后很快就逼近int64的上限。实践里我一般取 1e6,这个值在绝大多数场景下精度足够,结果也不会溢出。
有一种例外,就是处理世界级地理坐标。经纬度范围是 [-180, 180],就算乘以 1e6 也只有 1.8 亿量级,离 int64 上限远得很。但如果坐标是投影坐标,比如某些国家的平面坐标系,数值可能达到百万甚至千万,再乘 1e6 就会接近 1e13,还没问题;真正危险的是后续对结果做 offset 偏移,偏移量也会进入同一个整数系统。如果 scale 太大,偏移半径 0.5 米在 scale=1e9 下就是 5 亿,几十个偏移嵌套下来,溢出风险成倍增加。
所以我的建议是:scale 不是越高越好,而是够用就好。做像素级设计稿,scale=1e3 到 1e4 足够;做精细 CAD 或 GIS,1e6 是安全起点。定完之后写死成一个常量,让 C 层和 Dart 层全都引用同一个值,永远不要允许调用方临时传一个随机倍数。不同 scale 下跑出的几何结果可能有细微差异,一旦业务方发现坐标对不上,排查成本极高。
3.2 扁平化缓冲与内存生命周期
把业务数据送入 C 层之前,要先把 Dart 的 List 压实成连续内存。我一开始直接逐项调用TypedData写入,跑了 20 万顶点后差点崩溃,因为 Dart 侧对象的访问开销比 C 侧大得多。后来改成一次性分配大块calloc缓冲,批量填充:
final totalPoints = subjectSizes.sum; final Pointer<ffi.Int64> subjectPtr = calloc<ffi.Int64>(totalPoints * 2); try { var idx = 0; for (final polygon in subjectPolygons) { for (final p in polygon) { subjectPtr[idx++] = (p.dx * kScale).round(); subjectPtr[idx++] = (p.dy * kScale).round(); } } // 调用 FFI ... } finally { calloc.free(subjectPtr); }这个finally非常重要。我项目里出现过一次内存泄漏:5000 个多边形反复调用布尔运算,每个多边形都分配几百字节的 native 内存,不释放的话,肉眼可见内存涨到几百兆。后来我给自己定了一条铁律:所有calloc和malloc必须在同一个 Dart 函数里成对出现,如果做不到,就用NativeFinalizer挂到对象的 finalizer 上兜底。
输出缓冲区同样要提前分配。clipper2 的布尔结果可能小于输入,也可能大于输入,比如两个多边形的 OR 合并通常会生成顶点更多的轮廓。最稳定的做法是 C 层先做一次“试算”,返回需要的顶点总数;Dart 侧拿到精确大小后再分配缓冲区,进行第二次正式计算。虽然多一次调用,但避免了缓冲区不足时的重试逻辑和崩溃风险。
输出时要注意读取方式是“块读”。out_pts是一整块连续int64,可以用asTypedList(length)转成 Dart 的Int64List,然后根据out_sizes把每个多边形的顶点切出来。不要一个个pointer.value去读,那样性能又回到底层循环的老路。
4. 性能治理:从“能算出结果”到“能扛住生产流量”
4.1 先看数量级,再谈优化
每次聊性能,我最怕那种上来就谈算法的。优化前必须先知道自己处在什么量级。我用了一个标准测试包:包含一组带洞矩形、一个凹多边形、一条带尖角的多边形带,在真机上跑 clipper2 核心运算,耗时的数量级大致如下表。
| 运算类型 | 顶点规模 | C++ 侧耗时参考 | Dart/Path 粗做 |
|---|---|---|---|
| 交集 AND | 5 万顶点 | 40~80ms | 300ms 以上且帧率崩溃 |
| 并集 OR | 5 万顶点 | 50~100ms | 同左 |
| 差集 NOT | 5 万顶点 | 60~120ms | 无法稳定完成 |
| 异或 XOR | 5 万顶点 | 60~130ms | 无法稳定完成 |
| 等距偏移 Offset | 2 万顶点 | 30~70ms | API 缺失,难以实现 |
具体数字和轮廓复杂程度强相关,但量级是一致的:C++ 整数布尔运算比 Dart 层用 Path 硬拼至少快一个数量级。这还只是单次调用。如果一次要处理几百个多边形,Dart 层的临时对象开销会被指数放大,而 clipper2 侧只要控制好批量入口,增加的时间基本接近线性。
所以我在架构上直接做了一个决定:所有坐标数据先拼成一个大缓冲区,一次性调用 C 层,而不是每个多边形调用一次。这点很像数据库的批量提交,减少跨语言边界次数往往是最大的优化。跨 FFI 的调用开销虽然单次不高,但每多一次调用,Dart 侧就要多一次安全检查和数据拷贝。
4.2 后台 isolate:让 FFI 调用不碰 UI 线程
glib clipper2 的 C++ 运算再快,5 万顶点跑到几十毫秒,如果直接在 UI isolate 里同步调用,照样会掉帧。正确做法是放到后台 isolate 执行。Dart 3 的Isolate.run是干这个事最顺手的方式:
Future<ClipperResult> booleanOp(...) async { return Isolate.run(() { return _runNativeBooleanOp(subjects, clips, op, fillRule); }); }但是这里有个坑:不能在 UI isolate 里分配 nativePointer,然后传给后台 isolate 使用。Dart 的 isolate 之间共享 Dart 堆对象,但 Native 指针在另一个 isolate 里能否安全访问,取决于该 isolate 是否加载了同一个动态库以及内存是否还有效。跨 isolate 传指针极易出现 use-after-free,而且崩溃现场很难排查。
我的做法是把整个“分配内存→填充数据→调用 FFI→读取结果→释放内存”全部放在后台 isolate 内部完成。每个 isolate 打开自己的动态库句柄,数据进来后完全在 native 世界解决,最后只把Uint8List或纯 Dart 对象传回 UI isolate。这样内存周期清晰,GC 和 native 释放也互不干扰。
如果调用频率极高,建议开一个常驻的Isolate.run+ReceivePort工作模式,避免反复创建 isolate 的开销。不过大部分业务场景,Isolate.run的创建成本完全可以接受,我直接把批量入口设计成一次调用处理几百个多边形,把创建开销摊薄。
4.3 路径治理流水线:从输入到输出的完整处理框架
这里的“路径治理”不是某个具体算法,而是一条流水线。我最终落地成四个阶段:归一化 → 预筛 → 核心运算 → 后清理。
第一阶段是归一化。把所有坐标统一缩放、统一 FillRule,坐标系方向也统一成数学坐标系。Flutter Canvas 的坐标系 y 轴向下而 clipper2 是 y 轴向上,在归一化阶段直接把方向翻转过来,避免每个接口传参都要自证。第二阶段是预筛。对参与运算的每个多边形计算 AABB 包围盒,两个包围盒不相交的直接剔除出待求交集合。这个阶段可以用 Spatial Index 做网格划分,把 O(n²) 的碰撞检测降下来。对于几千个多边形参与的大批量 AND,单靠这一步就能省掉大部分算力。
第三阶段就是核心布尔运算。这里我保留了两个入口:普通BooleanOp和快速矩形裁剪RectClip。如果其中一个多边形是矩形,直接走专门优化过的矩形裁剪路径,性能比通用布尔快好几倍。第四阶段是后清理。运算结果经常会产生共线点、重复点、零面积碎片,用SimplifyPaths或CleanUp统一清理,之后再输出给渲染层,避免 Flutter 画布上出现不可见的退化顶点导致渲染毛刺。
这条流水线的好处是:业务方几乎不感知具体算法细节,只要把多边形发进来,拿到干净结果即可。对上层的人来说,路径治理就是一个稳定接口;对内层的人来说,每个阶段都可单独加缓存、加监控。
5. 适配鸿蒙时踩过的坑与定位链路
5.1 符号消失:Release 包里 FFI 函数找不到
在开发阶段跑 Debug 包,布尔运算一切正常。换成 Release 包一运行,Dart 侧直接抛 “Failed to lookup symbol” 异常。我当时的第一反应是 .so 没打进去,但解包发现libclipper_bridge.so躺在 libs 目录里。真正的问题出在符号被链接器丢弃或隐藏了。
C++ 编译器默认对extern "C"函数是导出可见性的,但 Release 构建经常配合-fvisibility=hidden和 strip 选项,把没有被外部引用的符号全部裁掉。鸿蒙侧对动态库符号的处理在不同 SDK 版本下不完全一致,最保险的做法是在桥接函数上显式声明默认可见性:
#if defined(__GNUC__) #if defined(__clang__) #define CLIPPER_EXPORT __attribute__((visibility("default"))) #else #define CLIPPER_EXPORT __attribute__((visibility("default"))) #endif #endif extern "C" { CLIPPER_EXPORT int32_t ccl_boolean_op(...) { ... } }同时 CMake 里不要乱开C_VISIBILITY_PRESET hidden。如果你确实需要隐藏内部符号,也要确保用-Wl,--export-dynamic或者维护一个导出清单,把桥接函数显式白名单。
这个坑最恶心的地方在于 Debug 完全复现不了,只有 Release 或者特定签名包才会触发。我建议把 FFI 符号检查测试直接写进集成测试里,每个版本构建完先跑一遍lookupFunction,符号找不到立即报错,别等业务跑到一半再爆。
5.2 跨 isolate 的 Native 内存:生与死的问题
我在 4.2 已经提过不建议跨 isolate 传 Pointer。具体踩坑时更麻烦:Dart 有一个NativeFinalizer机制,可以把 native 内存释放动作挂到对象 GC 上,提高释放的确定性。但如果你在 UI isolate 创建了对象并注册 finalizer,随后把对象传给了后台 isolate,这个对象的垃圾回收时机就变得非常微妙。后台 isolate 还在使用它,UI isolate 的 GC 觉得没人引用,直接触发 finalizer 把 C 侧内存释放了,后台 isolate 再访问就是野指针,崩溃代码极其难定位。
我的经验是三条硬规则:一是 Dart 对象和 Native 内存同生共死,谁分配谁释放,绝不跨 isolate 共享 native 资源;二是一个 isolate 内如果反复大量分配,使用完成后立刻free,不要依赖 GC;三是所有对 native 指针的访问尽量收敛到同一个文件里,其他地方只拿到封装后的 Dart 数据对象,从源头上避免指针泄露到业务层。
5.3 坐标缩放:精度不够和溢出是一枚硬币的两面
我遇到过一种很诡异的表现:PC 上布尔结果光滑漂亮,鸿蒙真机上同一个多边形却出现锯齿状边界。排查半天,发现真机上跑的数据是从经纬度直接乘scale=1e3进来的,弧线附近的点距小于定标精度,失真被放大到可肉眼可见。反之,相同代码用scale=1e9跑大的坐标,PC 上没事,真机上某几个极端顶点直接变成负数,轮廓整个飞掉。
这说明坐标定标必须在进入桥接层之前完成统一校验。我后来加了一个校验函数:任何坐标绝对值乘以 scale 后如果超过int64范围的 1/8,就拒绝进入布尔运算,并返回明确的错误码;同时检查同一个多边形内相邻点之间的距离,如果已经小于 scale 精度,提示业务方做抽稀而不是盲目塞点。这个校验让线上问题少了一大半。
5.4 踩坑之后我的推荐做法
回到最开始的问题:到底怎么把一个 C++ 几何库稳妥地放进鸿蒙 Flutter 组件里?我的最终答案是:不要想着造一个全功能 C++ 导出层,只暴露最少的“操作原语”——布尔、偏移、简化、矩形裁剪。所有复杂策略都放在 Dart 侧做,这样 C++ 侧的调试压力小,Dart 侧的测试又容易写。
另外,一定要建立回归用例。clipper2 升级过几个小版本,填充规则和部分内部算法的行为有微妙差异。我维护了一套固定用例:一组矩形嵌套、一个带洞的复杂多边形、一条自交折线、一组窄缝多边形,每次升级库或者改坐标定标策略,先跑一遍对比,几何结果有差异立刻亮红灯。这套回归机制帮我挡掉过三次足以让上线事故的隐性变动。
最后一点小技巧:把“定标 + FFI + 后台 isolate”整体封装成一个黑盒之后,不要再对外暴露任何Pointer或 C 类型。业务方只需要传List<List<Offset>>,拿到的也是List<List<Offset>>。跨语言边界越窄,后续维护越舒心。这也是我这次鸿蒙适配折腾下来,觉得最值得分享的经验。