1. 从"标签飞出多边形"说起:为什么需要 polylabel
做地图开发的朋友,大概率都遇到过这个尴尬场景:一个形状不规则的小区地块、一片湖面、或者一个行政区边界,程序根据顶点坐标算了个中心点,把小区名称、湖泊标注挂上去——结果标签却跑到了多边形外面的马路上,甚至飘进了隔壁地块。坐标上明明"居中"了,视觉上却完全不是那么回事。
这个问题的根源在于:几何学上的中心,不等于人眼感知的中心。凸多边形还好,一旦碰上 L 形、U 形、月牙形、带内环空洞的多边形,普通的质心(centroid)和包围盒中心都会失灵。质心是面积加权平均点,但凹多边形和带洞多边形的质心完全可能落在图形外面;包围盒中心就更不用说了,一个西北-东南走向的细长地块,包围盒中心可能落在离地块老远的地方。
这时候就需要 polylabel 这类"最大内切圆圆心"算法出场。Mapbox 最早实现了 polylabel,用来解决地图矢量瓦片里的多边形标签定位问题。它的核心目标不是找几何中心,而是找多边形内部一个尽量"居中"、离所有边界都尽可能远的点——这个点在视觉上几乎总是一个多边形最适合放文字标签的位置。名字里的 "poly" 是多边形,"label" 是标签,直接点明了它的用途。
我在 Flutter 项目里遇到这个需求,是因为做的是一个跨平台地理围栏可视化应用:服务端下发复杂区域边界,客户端要在地图上把区域高亮,同时把区域名称、围栏编号精准地放在区域内部,不能压边界,更不能漂到外面去。iOS 和 Android 端 Flutter 的表现都正常,但一到鸿蒙 HarmonyOS 端适配就暴露了问题——原有的地图标注逻辑依赖的地图 SDK 在鸿蒙上的行为不一致,且 Flutter 插件包对鸿蒙平台没有原生实现,跑起来要么崩溃,要么标签位置全乱。于是就有了这次适配实战。
如果你也是做 Flutter 地图应用、或者需要在鸿蒙端绘制复杂多边形并做标注对齐,这篇文章值得看完。我会先讲清楚 polylabel 的计算逻辑,再给出一套可落地的鸿蒙适配方案和性能优化思路,最后复盘几个很容易踩的坑。
2. polylabel 核心算法拆解:网格细分逼近视觉中心
2.1 质心、内切圆与 polylabel 的差别
先说个容易混淆的点:polylabel 并不是"算质心",它是用网格搜索逼近多边形内部的最大内切圆圆心。
人眼判断一个多边形"哪里最居中",其实是在直觉上找一个圆:这个圆放大到不能再放大之前,必须完全落在多边形内部。圆的圆心就是一个很好的标签锚点,圆的半径决定了标签缩放的安全空间。这个圆叫最大内切圆(Maximum Inscribed Circle),它的圆心就叫极点(pole of inaccessibility,直译是"最不可达点",学术上叫内接圆圆心)。"pole of inaccessibility"这个英文术语比较拗口,国内通常翻译成"视觉中心"或"极点"。
质心(centroid)是另一个概念:三角形质心是三边中线的交点,对任意多边形则通过顶点坐标或面积加权计算。质心计算快,但没有任何约束保证它落在多边形内部。考虑一个简单的 L 形多边形——两个长方形拼在一起,它的质心可能落在缺口处,也就是图形外。而 polylabel 的结果天然带一个约束:必然在多边形内部,且离边界尽可能远。
我用一个对比表格来说明三者的差异,这也是我在项目里对团队科普用的版本:
| 方案 | 计算开销 | 是否保证在多边形内部 | 视觉中心效果 | 适用场景 |
|---|---|---|---|---|
| 包围盒中心 | 极低 | 不保证 | 最差,细长/凹多边形完全不可用 | 粗精度的大致定位 |
| 质心(加权) | 低 | 不保证 | 一般,凹多边形会偏移甚至出界 | 简单凸多边形 |
| polylabel | 中高 | 保证(带洞也能保证不落入洞内) | 好,近似最大内切圆圆心 | 地图标注、复杂形状 |
2.2 从包围盒开始的网格贪心搜索
polylabel 的基本思路并不玄乎,理解它有助于你在项目里调整精度参数。整个过程可以拆成四步:
第一步,求包围盒。先找出多边形的 minX / minY / maxX / maxY,把整个多边形放进一个矩形里。
第二步,网格化拆分成候选单元 cell。以包围盒边长的一半为初始边长,把包围盒划分为若干正方形网格。每个 cell 的中心都是一个候选的标签点。这是一个"可能包含最优解"的空间。
第三步,计算每个候选 cell 的得分。对每个 cell,计算它的中心到多边形所有边的最近距离。如果中心落在多边形内部,距离取正值;如果在外部或落在洞里,距离取负值。这个距离本质上就是"以该候选点为圆心,能画出的最大内切圆半径"。
第四步,优先队列 + 二分细分逼近最优。把所有 cell 按中心距离从大到小放进优先队列,每次取出得分最高的 cell。如果它的"理论上限"(中心距离 + cell 对角线长度的一半)仍然大于当前全局最优解,就把这个 cell 一分为四,生成 4 个子 cell 重新计算得分,再压回队列。这个过程反复执行,直到队列耗尽,或者所有 cell 得分接近全局最优解(精度阈值控制),最后返回得分最高的 cell 中心作为标签锚点。
这里的关键不是暴力穷举,而是用分支定界思路:一个 cell 内任意一点都不可能比"中心距离+半对角线"更远,如果这个上限已经不如当前最优解,那这个 cell 整体都可以剪枝,不用再细分。这就是 polylabel 能在大多边形上快速收敛的原因。
实际使用中,polylabel 的函数签名通常是:polylabel(polygonCoords, precision),传入多边形外环和洞的坐标数组,以及一个精度参数(默认 1.0,单位与输入坐标一致)。返回[x, y, distance],其中 distance 就是最大内切圆的近似半径。
在 Flutter / Dart 生态里,已经有纯 Dart 的 polylabel 实现(pub.dev 上搜 polylabel_dart 或直接 import 源码),算法本身是纯计算逻辑,不依赖任何平台 API。这意味着算法本身放到鸿蒙端完全不需要改动——真正要适配的,是把坐标数据安全高效地送进算法、再把算出的标注点同步到鸿蒙地图原生层渲染这一整条链路。
3. 鸿蒙端适配难点:不是说好的纯 Dart 就够了?
3.1 Flutter 在鸿蒙上的现状
鸿蒙适配的第一个认知要点是:Flutter 在 HarmonyOS / OpenHarmony 上的运行方式,和 iOS / Android 不完全一样。当前主流做法是使用 OpenHarmony SIG 维护的 Flutter 兼容版本(flutter_flutter 的 ohos 分支),Dart 层的框架代码可以复用,但原生插件(plugin)不存在自动兼容。
大多数 Flutter 地图相关插件(比如基于高德、百度、Mapbox 的插件)都是通过 MethodChannel 调用 Android 的 Java/Kotlin 和 iOS 的 Object-C/Swift 代码。鸿蒙端既没有 .android 也没有 .ios 的注册入口,如果你用的插件没有主动提供 ohos 目录,那在鸿蒙真机上调用插件方法会直接抛 MissingPluginException。
这正是我遇到的坑:原有方案里,地图渲染依赖一个社区地图插件,这个插件在 Android 上通过原生 SDK 画面叠加和标注。到了鸿蒙端,我其实有两条路可以走:
- 方案 A:换用鸿蒙原生地图 SDK 重写整个地图组件,再以 PlatformView 方式接入 Flutter。
- 方案 B:保留 Flutter 侧的 polylabel 计算逻辑,地图底图仍用 Flutter 层自绘,只通过 MethodChannel 把标注点位和文字传给鸿蒙原生层渲染最上层的文字图层。
我最后选了方案 B 为主、方案 A 为辅。原因很实际:polylabel 算法是纯 Dart,坐标转换和缓存也可以全放在 Dart 侧,真正需要鸿蒙原生介入的只有"把标注点覆盖在地图纹理之上"这一步。这样改动范围最小,也最容易控制风险——核心计算不依赖平台,平台差异被压缩到一个薄薄的图层通道里。
3.2 三端结构:pubspec、MethodChannel 与原生实现
我最终搭建的鸿蒙端适配结构是这样的:
flutter_polylabel_ohos/ ├── lib/ │ ├── polylabel_engine.dart # 纯 Dart 算法封装与 isolate 调度 │ ├── label_placement_manager.dart # 标注对齐、避让与缓存 │ └── ohos_bridge.dart # MethodChannel 封装 ├── ohos/ │ └── main/ │ ├── ets/ │ │ ├── entryability/ │ │ └── pages/ │ │ └── Index.ets # 注册 FlutterPlugin 与渲染 Overlay │ └── module.json5pubspec.yaml 里需要注意:鸿蒙插件的注册方式和 Android 类似,需要在flutter_plugin_ohos相关的配置中声明插件入口。如果你是自己写鸿蒙侧的 FlutterPlugin,需要在 ohos 工程里实现一个继承自FlutterPlugin的类,并在 Module 的配置里注册。
原生侧的核心代码用 ArkTS 写。比如我定义了一个方法通道处理器,接收 Dart 侧传过来的标注点、文字内容和样式参数,然后创建一个 RenderNode 挂到 Flutter 的 Overlay 上作为顶层文本层。核心代码大致是这个思路:
// LabelOverlayPlugin.ets export class LabelOverlayPlugin implements FlutterPlugin { private channel: MethodChannel | null = null; onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel = new MethodChannel(binding.getBinaryMessenger(), 'polylabel_ohos/overlay'); this.channel.setMethodCallHandler((call, result) => { if (call.method === 'drawLabel') { const args = call.arguments as DrawLabelArgs; // 在地图图层之上创建文本 RenderNode // 设置坐标、内容、字号、颜色等 result.success(true); } else if (call.method === 'clearLabels') { // 清理当前所有标注 result.success(true); } }); } onDetachedFromEngine(binding: FlutterPluginBinding): void { this.channel?.setMethodCallHandler(null); this.channel = null; } }Dart 侧的调用封装也很薄:
class OhosLabelBridge { static const _channel = MethodChannel('polylabel_ohos/overlay'); static Future<void> drawLabel({ required List<double> position, required String text, Map<String, dynamic> style = const {}, }) async { await _channel.invokeMethod('drawLabel', { 'x': position[0], 'y': position[1], 'text': text, 'style': style, }); } }重要的是,这个通道只负责"画",所有复杂的地理计算仍然留在 Dart 层。这样即使原生渲染层出了 bug,也不会影响标注点的正确性;反过来,如果我在 Dart 层把 polylabel 参数调坏了,也不会把原生层拖崩溃。适配的核心不是把 polylabel 算法迁移到 ArkTS,而是建立一个清晰的责任边界。
4. 核心适配实现:坐标投影、Isolate 调度与标注对齐
4.1 先投影成平面坐标再算内切圆,否则就是个隐形的 bug
polylabel 算法内部要算点到线段的最短距离,如果直接把经纬度坐标喂进去,在低纬度地区误差还不明显,但到了北纬 40 度以上,经度 1 度对应的实际距离只有纬度 1 度的 70% 左右,算法算出来的"最大内切圆"是按等距空间来的,结果会明显偏斜。所以我在 Dart 侧做了一步WGS84 经纬度 -> Web Mercator 平面坐标的转换,等 polylabel 算完,再把结果坐标反投影回经纬度,回传给鸿蒙原生层。
这里贴一段我在实际项目里的封装代码:
import 'dart:math' as math; class MercatorProjection { static const double earthRadius = 6378137.0; static List<double> project(double lng, double lat) { final x = lng * math.pi / 180.0 * earthRadius; final y = math.log(math.tan(math.pi / 4.0 + (lat * math.pi / 180.0) / 2.0)) * earthRadius; return [x, y]; } static List<double> unproject(double x, double y) { final lng = x / earthRadius * 180.0 / math.pi; final lat = (math.atan(math.exp(y / earthRadius)) - math.pi / 4.0) * 2.0 * 180.0 / math.pi; return [lng, lat]; } } List<double> computePolylabelForLngLatPolygon(List<List<double>> polygon) { final projected = polygon.map((p) => MercatorProjection.project(p[0], p[1])).toList(); // 注意:polylabel 的输入格式可能是 [x,y] 列表,也可能是 Point 列表,需要按所用包来适配 final result = polylabel([projected], precision: 1.0); return MercatorProjection.unproject(result.x, result.y); }第一次适配时我图省事,直接把经纬度丢给 polylabel 跑,结果在北方的围栏标注全往南偏了一大截。排查了半天,最后发现不是算法有问题,而是坐标系不对。这个坑印象太深了,强烈建议所有做地图计算的 Flutter 开发者形成肌肉记忆:在地图空间里做几何计算,先统一投影坐标系。
4.2 用 Isolate 分批计算,避免 UI 卡成幻灯片
polylabel 虽然比暴力穷举快得多,但它是逐次细分逼近的迭代过程。一个 200 个顶点的复杂多边形,精度设到 1 米,平均要迭代几百次 cell,虽然单次计算只要几毫秒,但如果一次性来几千个多边形,在 UI isolate 上直接跑,照样会让地图交互卡顿掉帧。
我在项目里用 Dart 的Isolate.run做批处理计算。做法是维护一个待计算队列,每次取最多 200 个多边形坐标,序列化后丢给一个后台 isolate 统一计算,计算完成后把结果以 Map 形式传回主 isolate:
Future<List<LabelResult>> computeLabelPointsInBatch( List<GeoPolygon> polygons, ) async { final rawData = polygons .map((p) => { 'id': p.id, 'coords': p.coordinates .map((latLng) => [ latLng.lng.toDouble(), latLng.lat.toDouble(), ]) .toList(), }) .toList(); return await Isolate.run(() { final results = <LabelResult>[]; for (final item in rawData) { final projected = _toMercator(item['coords'] as List<List<double>>); final point = polylabel([projected], precision: 1.0); final lngLat = _unproject(point.x, point.y); results.add(LabelResult(id: item['id'], lng: lngLat[0], lat: lngLat[1])); } return results; }); }Isolate.run是 Dart 2.19 以后引入的 API,比手动Isolate.spawn加ReceivePort简洁得多。数据量大时可以再用一个固定大小的 isolate 池来并发处理,但对于大多数围栏数量在 1 万以内的场景,单个后台 isolate 分批已经足够了。
这里要特别提醒一个细节:isolate 之间传递的数据是深拷贝的,传入传出的是纯数据而不是对象引用。所以传参时尽量用扁平结构(比如List<List<double>>而不是自定义类实例),一方面减少序列化开销,另一方面避免因为对象不可共享而导致的编译或运行问题。我最初把GeoPolygon对象直接丢进Isolate.run,编译没问题,但每次传输都有隐性的序列化损耗,换成纯坐标数组后耗时又降了一截。
4.3 标注对齐:不止算出点,还要放对位置
拿到 polylabel 的锚点后,还远没到结束。实际地图上有大量标注同时存在,光靠每个多边形独立算内切圆,标注之间可能互相覆盖。一个多边形内切圆圆心没问题,但两个相邻多边形的标注可能正好挤在一起。
我的对齐策略分三层:
第一层,点位微调。在 polylabel 结果的基础上,尝试上、下、左、右四个方向做小幅度平移(通常每次移动 5~10 像素对应的地理距离),每移动一次检查是否与已放置标注的包围盒相交。如果找到一个方向上不再碰撞,就采用偏移后的点。
第二层,候选池。如果四个方向都碰撞,不急着放弃,而是保留 polylabel 点作为基准,结合多边形内切圆半径换几个候选缩放比例(比如文字更大或更小),重新做第一层的碰撞检测。文字对地图阅读很重要,宁可缩小一点,也要放在多边形内部。
第三层,栅格索引。碰撞检测如果每加一个标注就遍历所有已放置标注,时间复杂度是 O(n²),几千个多边形就非常慢。我用一个简单的二维栅格索引:把平面投影坐标系分成固定大小的格子(比如 200 米一格),每个标注只登记到它所在的格子,碰撞检测时只需检查邻近 9 个格子里的标注包围盒。这样复杂度降到近线性。
class GridIndex<T> { final double cellSize; final Map<int, List<T>> _cells = {}; GridIndex(this.cellSize); int _key(double x, double y) { final gx = (x / cellSize).floor(); final gy = (y / cellSize).floor(); return gx * 100000 + gy; // 简单 hash,注意负数坐标需偏移处理 } void insert(double x, double y, T value) { final k = _key(x, y); _cells.putIfAbsent(k, () => []).add(value); } List<T> queryNearby(double x, double y) { final gx = (x / cellSize).floor(); final gy = (y / cellSize).floor(); final result = <T>[]; for (var dx = -1; dx <= 1; dx++) { for (var dy = -1; dy <= 1; dy++) { final items = _cells[gx * 100000 + gy + dx * 100000 + dy]; if (items != null) result.addAll(items); } } return result; } }注意上面的 hash 只适合坐标在 [0, 99999] 范围内的场景,如果你要处理大范围跨带数据,建议用字符串 key 或者真正的空间索引库。这个栅格足够应对地图围栏场景,重点是把碰撞查询从"扫全表"变成"查邻近格子"。
4.4 缓存与局部更新:地图缩放时不要重算一切
地图上最典型的交互是缩放和平移。拖动还好,坐标不变;一旦缩放级别变化,多边形在屏幕上的大小和像素位置全部改变,标注间距也需要重新适配。
如果每次缩放都重新跑一遍所有多边形的 polylabel,性能再优化也扛不住高频手势。我的方案是分两段缓存:
- 几何缓存:一个多边形的 polylabel 结果只取决于多边形坐标本身,与缩放级别无关。所以我用一个
Map<String, List<double>>缓存 polygonId -> 标注经纬度,只有围栏数据从服务端刷新时才失效。 - 布局缓存:每个缩放级别下的最终屏幕坐标、是否碰撞、是否隐藏,则按 zoom level 分段缓存。只有进入一个新的缩放级别,且该级别的缓存为空时,才从几何缓存里读出锚点,再跑一遍避让计算。
实测下来,几何缓存往往能挡住 90% 以上的重复计算。围栏数据不变时,用户缩放地图只是做 O(n) 的屏幕坐标映射和 O(n) 的避让检查,而不是 O(n log n) 的算法迭代。
5. 复杂多边形标注对齐的实战细节补充
实际操作中,"对齐"两个字里面的门道比想象中多。做地图标注久了你会知道,人眼对"悬挂在地图上的文字"的位置非常敏感。有几个细节如果不注意,标注位置正确了,观感还是不对。
多边形的方向(顺时针/逆时针)会影响部分实现。polylabel 的某些移植版本在计算包含关系时会依赖环的方向判断内外。我在适配时发现,Dart 版实现如果输入是逆时针外环,结果偶尔会偏到边缘。保险做法是所有多边形坐标统一转成顺时针外环 + 逆时针内洞,再送入算法。这就像格式化代码一样,先统一输入规范,能避免大量玄学问题。
带洞多边形要额外处理。一个区域内部有个湖、有个绿化带、有个不能标注的禁飞区,polygon 会包含一个或多个内环。polylabel 支持带洞输入,但必须把洞的坐标作为"负区域"参与距离计算,否则算出的内切圆可能落在洞里。鸿蒙适配时,很多同事第一次接触这个算法会忽略这个问题——服务端下发的数据里 comments 字段写着有个 hole,代码里却没把洞传进去。结果标签正好飘在湖中央,测试一看就崩了。检查方式很朴素:debug 时把 polylabel 返回点的坐标在鸿蒙原生层画一个标记圆,半径等于返回的 distance,肉眼检查是否和边界和洞都相切。
长文本标签的锚点偏移。一个区域名如果是"XX市XX区XX街道XX产业园区"这种长文本,锚点按下对齐的策略和短文本不一样。长文本应该让锚点略微偏向左下方,把文本主体留在多边形视觉中心内。这一条没法靠算法自动解决,我的做法是在对齐层给长文本加一个anchorOffset参数,假设文字宽度大于一定像素,就把锚点向右上角平移文字宽度的一半,同时做碰撞检测保证偏移后不越界。这个规则是我在真机上一遍遍看渲染结果调出来的,比较偏门,但对那些"区域名必长"的政企类项目非常关键。
鸿蒙原生侧文字图层的叠加层级。如果地图底图是 Flutter 自绘的,顶层文字用鸿蒙 Overlay,那要注意 z-order:Flutter 的 PlatformView 和原生 Overlay 之间在鸿蒙上的层级规则与 Android 有些差异,尤其是涉及多窗口(比如卡片分屏)时,原生 Overlay 可能被 Flutter 侧重新绘制覆盖。我在 Index.ets 里把标注 Overlay 挂到了最顶层的窗口栈上,才避免了切后台再回前台时标注消失的问题。
6. 地理围栏大数据量下的性能优化实测
6.1 性能瓶颈定位
适配完成后,我第一时间做了性能摸底。测试机是鸿蒙开发机(麒麟芯片,8G 内存),数据集是某城市 2300 个围栏多边形,顶点数从 3 到 800 不等,总顶点量约 23 万。
优化前直接在 UI isolate 里串行计算,结果非常惨烈:
| 阶段 | 耗时 |
|---|---|
| 数据拉取与解析 | 180ms |
| 2300 个多边形 polylabel 计算 | 3900ms |
| 避让碰撞检测 | 520ms |
| 鸿蒙层绘制 2300 个标注 | 240ms |
总耗时接近 4.8 秒,加载围栏列表时地图明显卡死,转圈圈转了 4 秒多。用户显然不可能接受这个体验。
6.2 三步优化:Isolate、顶点抽稀、缓存
第一步做的,就是把 polylabel 计算从 UI isolate 挪出去,按每批 200 个多边形提交到后台 isolate,一批算完再送下一批。结果计算阶段耗时从 3900ms 降到 1100ms 左右。UI 线程几乎不再卡顿,但总等待时间还是很长。
第二步是顶点抽稀。围栏数据里大量顶点是沿道路边界采样的,一条直路上可能有几十个间隔 5 米的重复采样点,这些点对最大内切圆的计算几乎没贡献,却让每次点到线段的距离计算膨胀了十几倍。我用 Douglas-Peucker 抽稀,epsilon 设为 0.5 米(Web Mercator 平面坐标下),把 23 万顶点抽到 6 万左右。这一步对结果质量影响极小,但计算时间直接降到了 320ms。
第三步是几何缓存。因为围栏数据是启动时一次性拉取的,用户在会话期间不会频繁刷新,所以我只对首次进入某围栏列表时全量计算一次,之后所有缩放平移都走 4.4 节的布局缓存。效果叠加后,首次加载总耗时约 1.4 秒,后续进入列表页几乎无感。
| 优化阶段 | polylabel 计算耗时 | 整链路耗时 | UI 卡顿 |
|---|---|---|---|
| 优化前(UI isolate 串行) | 3900ms | 4800ms+ | 严重卡死 |
| 优化1:Isolate 分批 | 1100ms | 1900ms | 基本无感 |
| 优化2:+顶点抽稀 | 320ms | 1050ms | 无感 |
| 优化3:+几何缓存 | 320ms(仅首算) | 320ms(后续跳变<50ms) | 无感 |
6.3 精度参数怎么调
polylabel 最后一个参数是精度 precision,单位是输入坐标的单位。在 Web Mercator 平面坐标系下,1 个单位约等于 1 米。我默认设 1.0,但这并不意味着所有场景都合适:
- 如果你的标注文字很大(比如 18 号字以上),precision 设到 5.0 也够用,计算速度会快非常多。
- 如果多边形很小(面积不到几百平方米),precision 设 0.1 会特别慢,这时不如直接取第一个顶点或质心,polylabel 对微型多边形没意义。
- 如果你会在 3D 地图上倾斜视角下看到标注,最好额外判断内切圆半径换算成屏幕像素后是否大于文字高度的 1.2 倍,否则标注会显得贴近边缘,观感不佳。
一个我常用的经验公式:precision = max(1.0, 包围盒短边长度 / 200)。这样既保证大区域内的计算精度,又不会在小多边形上磨洋工。这是我跑了十几组数据后总结出来的经验值,不一定普适,但作为初始值很靠谱。
7. 踩坑记录与经验总结
7.1 坑一:经纬度当平面坐标算,全程偏航
前面提过投影问题,但这里值得再啰嗦一遍。鸿蒙端的地图 SDK 大多是 Web Mercator 或 GCJ-02 坐标系(国内常见),如果你的围栏数据来自 WGS84 的服务端,不统一坐标系就开算,结果可能偏出几百米。这个坑不是 polylabel 带来的,是所有地理空间计算共享的坑。
我的规范做法是:在数据进入 Flutter 分析管线时,先统一转成 WGS84 经纬度,然后在代码里显式声明"这里做几何计算前必须转 Web Mercator 平面坐标",同时在工具类里加断言:如果多边形顶点纬度绝对值小于某个阈值(比如 0.0001),就扔异常。这样至少能在早期发现坐标漂移。
7.2 坑二:退化多边形与空区域
polylabel 的假定是输入一个合法的非退化多边形。但真实数据里什么都有:三点共线成一条线段的"面积为零的多边形"、只有 2 个顶点、或者四边形但四个点都在一条直线上。这些数据丢给 polylabel,有的实现会返回一个 NaN 或者直接抛异常。
我的 fallback 策略是:计算前先检查多边形面积,如果面积小于 1 平方米,就直接取所有顶点的平均坐标作为标注点,跳过 polylabel。另外在 catch 到任何异常时,统一回退到"多边形第一个顶点 + 偏移"的兜底方案,保证标注一定出来,只是位置不完美。地图标注偶尔偏一点可以接受,崩溃和空白绝对不能接受。
7.3 坑三:鸿蒙原生层叠加文字被地图图层遮盖
鸿蒙端如果用的是 Flutter 自绘地图,顶层文字 Overlay 和地图 Canvas 的绘制顺序不总是和 Android 一致。我的标注 Overlay 一开始用的是普通 Page 里的高优先级 Overlay Entry,结果在全屏地图上被地图 Canvas 的 SurfaceView 盖住了一部分区域。
排查后发现,鸿蒙对平台 SurfaceView 和普通 RenderNode 的混排有自己的规则。解决方式是给标注 Overlay 设置独立的WindowStage或UIExtensionComponent层级,确保它挂在整个地图 Surface 之上。这个方案在平板分屏、折叠屏展开等多窗口场景下也能稳定工作。
7.4 一个额外的建议:把算法版本固化
最后说个工程上的经验。polylabel 的 Dart 实现存在多个版本,有的带 precision 参数,有的内部硬编码精度;有的返回Point对象,有的返回List<double>;有些版本对自相交多边形的处理还有差异。适配鸿蒙后,我直接把这个 Dart 包 vendor 到项目内部,锁定版本,不再跟随 pub.dev 上游更新。原因很简单:地理计算属于边界情况爆炸的领域,上游一个小改动可能让已缓存的结果全部失效,而自己维护的拷贝虽然要手动跟进修复,但至少可预测、可回滚、可单测。
这次适配完成的最终结果是:Flutter 侧通过 polylabel 计算出的标注锚点,配合后端透传的围栏数据,在鸿蒙端地图上实现了对齐、避让、缓存三件套;2300 个复杂多边形围栏从数据下来到屏幕上稳定标注,整个链路 1 秒上下完成,交互不再卡顿。
如果你也在做 Flutter 地图到鸿蒙的适配,或者正被"多边形标注往外跑"折磨,我建议先别急着换地图库,花两天时间把 polylabel 接入、投影对齐、缓存做好,大概率能解决 80% 的痛点。剩下的 20% 是原生层渲染细节,那就要结合你用到的具体鸿蒙地图引擎去逐层排查了。