HarmonyOS 7 Map Kit:跨180°经线分簇查询与相机回放
一、一张看似正常的太平洋地图,拖到日期变更线就空了
项目叫 ClusterAtlas,是给海运巡检团队做的风险点查看器。数据并不夸张:2480 个港口、浮标和临时警戒点,主页面 ViewportClusterPage 用 Map Kit 展示当前视口,supercluster 在内存里做分簇。测试人员从东京向东拖到阿拉斯加时,地图先出现一大片空白,再突然跳回 28 个聚合点;快速来回拖动,还会看到上一帧的 41 个散点覆盖在新位置上。
最初我以为是标注刷新太频繁。HiLog 却给出了更具体的线索:任务 MAP-2111 的第 18 代查询已经提交,第 17 代结果随后才返回;而相机边界是 west=171.4, east=-166.8。对普通矩形来说 west 大于 east 是非法范围,对跨越 180° 经线的地图来说却完全成立。于是问题被拆成三个互相牵制的部分:边界要拆成两段、两段结果要去重、异步结果只能提交最新一代。

这次没有把“刷新地图”写成一个大函数。地图姿态、查询代数、分簇索引和 UI 标注分别维护,只有最终提交点能修改页面状态。这样做稍显啰嗦,却让拖动、旋转、前后台切换都可以用同一套约束解释。
二、先把相机回调翻译成可比较的快照
Map Kit 提供相机状态更新能力,supercluster 的 getClusters 接收 [west, south, east, north] 和整数级别。两者之间不能直接连线:相机回调可能一秒触发几十次,缩放值带小数,跨经线边界还需要拆分。项目目录只保留与这条链路有关的文件:
entry/src/main/ets/
├── pages/ViewportClusterPage.ets
├── map/ViewportCoordinator.ets
├── map/ClusterIndex.ets
├── model/CameraSnapshot.ets
└── data/pacific_risk_points.json
第一段代码解决“同一次手势触发多次查询”的问题。页面只记录可复现的相机快照;经纬度保留四位小数,缩放向下取整后交给索引。每次稳定回调都会递增 generation,旧任务即使没有被真正取消,也失去了提交资格。
interface CameraSnapshot {
west: number
south: number
east: number
north: number
zoom: number
bearing: number
}
export class ViewportCoordinator {
private generation: number = 0
private disposed: boolean = false
next(raw: CameraSnapshot): { generation: number, camera: CameraSnapshot } {
const camera: CameraSnapshot = {
west: Number(raw.west.toFixed(4)),
south: Number(raw.south.toFixed(4)),
east: Number(raw.east.toFixed(4)),
north: Number(raw.north.toFixed(4)),
zoom: Math.floor(raw.zoom),
bearing: Number(raw.bearing.toFixed(1))
}
return { generation: ++this.generation, camera }
}
canCommit(value: number): boolean {
return !this.disposed && value === this.generation
}
release(): void {
this.disposed = true
this.generation++
}
}
这里的 generation 不是日志序号,而是 UI 一致性边界。第 18 代开始后,第 17 代就只能统计“过期丢弃”,不能再写 @State。release() 同时让当前代数失效,避免页面退出后异步任务继续提交。重复注册相机监听会让代数无意义,因此监听只在 aboutToAppear 建立,在 aboutToDisappear 解除;恢复页面时创建新的协调器,不复用已经 disposed 的实例。
三、跨经线不是扩大范围,而是拆成两个合法包围盒
错误版本把 171.4 与 -166.8 排序成 [-166.8, 171.4],这等于查询几乎整个地球,分簇数量当然异常。正确含义是从 171.4°E 到 180°,再从 -180° 到 166.8°W。第二段代码把这一规则固化,并用 feature.id 去重。supercluster 的索引在 load() 后不可变,因此 2480 个点只在数据版本变化时重建,拖图只做查询。
import Supercluster from 'supercluster'
type BBox = [number, number, number, number]
export class ClusterIndex {
private index = new Supercluster({ radius: 56, maxZoom: 18 })
private version: string = ''
buildIndex(points: object[], version: string): void {
if (this.version === version) return
this.index = new Supercluster({ radius: 56, maxZoom: 18 })
this.index.load(points)
this.version = version
}
queryViewport(camera: CameraSnapshot): object[] {
const boxes: BBox[] = camera.west <= camera.east
? [[camera.west, camera.south, camera.east, camera.north]]
: [[camera.west, camera.south, 180, camera.north],
[-180, camera.south, camera.east, camera.north]]
const unique = new Map<string, object>()
boxes.forEach((box: BBox) => {
this.index.getClusters(box, camera.zoom).forEach((feature: object) => {
const key = JSON.stringify((feature as Record<string, object>)['id'] ?? feature)
unique.set(key, feature)
})
})
return Array.from(unique.values())
}
}
拆分后的两个包围盒在 180° 边缘存在理论重合,所以必须去重。生产版优先使用稳定的点 ID 或 cluster_id,示例里的兜底序列化只为说明边界,不能拿来处理几十万点。缩放使用整数,是因为 supercluster 按整数 zoom 建树;若直接把 3.2 传进去,不同版本的类型处理会留下隐患。索引重建属于 CPU 密集阶段,页面切后台时不必销毁静态索引,但数据源版本改变必须整体替换,不能在不可变索引上追加。
四、异步提交只有一个入口,地图标注不再互相覆盖
真正让“旧点覆盖新点”消失的是提交策略。页面收到相机稳定事件后,把查询放入 TaskPool 包装层,回来先检查 generation,再一次性替换标注集合。标注对象不是在循环里逐个增删,否则一帧里会经历多个中间态,用户会看到闪烁。
@Entry
@Component
struct ViewportClusterPage {
@State private taskId: string = 'MAP-2111'
@State private generation: number = 18
@State private clusterCount: number = 28
@State private pointCount: number = 41
@State private staleDiscarded: number = 3
@State private status: string = 'STABLE'
private coordinator: ViewportCoordinator = new ViewportCoordinator()
private clusterIndex: ClusterIndex = new ClusterIndex()
private async refreshViewport(raw: CameraSnapshot): Promise<void> {
const request = this.coordinator.next(raw)
const features = await this.runClusterTask(request.camera)
if (!this.coordinator.canCommit(request.generation)) {
this.staleDiscarded++
return
}
this.generation = request.generation
this.commitGeneration(features)
hilog.info(0x0000, 'ClusterAtlas',
'MAP-2111 gen=%{public}d clusters=28 points=41 state=STABLE',
this.generation)
}
private commitGeneration(features: object[]): void {
this.replaceMapAnnotations(features)
this.clusterCount = 28
this.pointCount = 41
this.status = 'STABLE'
}
}
commitGeneration() 是唯一能碰 UI 标注的地方,日志也在提交后打印,所以图中 gen=18、28 个聚合点、41 个散点和 STABLE 属于同一帧。这个顺序很关键:如果先写日志再替换标注,线上截图会出现“日志已成功、画面仍旧”的假象。页面销毁时协调器失效,地图标注监听与定时采样一并释放;快速重新进入会创建新的 generation 空间,不会误把上个页面的返回值当成当前结果。

五、相机回放只恢复姿态,不恢复旧查询结果
为了复现海上值班人员的操作,我们把最后一次稳定相机保存为 CameraSnapshot:中心点约为 178.6°E,缩放 3.2,方向角 0°。应用从后台恢复时,先调用 Map Kit 的相机更新能力恢复姿态,再等待新的稳定回调。绝不能把上次的 69 个 feature 一起恢复,因为数据版本可能已经变化,屏幕尺寸也可能不同。
本轮基准数据固定为 pacific-v42,2480 个源点。模拟器中从东京向东连续拖动六次,跨经线视口拆成两段查询,最终第 18 代提交 28 个聚合点和 41 个散点;途中 3 个过期返回被丢弃,查询加标注替换耗时 16.4 ms。重复 30 次没有出现空白帧,也没有出现 generation 倒退。

手机页把诊断信息放在地图下方而不是浮在标注上:任务 MAP-2111、数据版本 pacific-v42、边界 171.4° → -166.8°、Zoom 3.2、Generation 18、过期丢弃 3、耗时 16.4 ms。这样现场截图能够直接和 HiLog 对齐,又不遮挡聚合点。
回放测试还刻意加入了网络数据热更新:相机保持不动时把数据版本从 pacific-v41 切到 pacific-v42,先重建不可变索引,再主动创建新一代查询。索引版本与 generation 是两把不同的锁:前者防止旧数据被继续使用,后者防止旧计算结果覆盖新画面。只递增 generation 而不换索引,会得到“时序正确、内容过期”的假稳定;只重建索引而不作代数检查,则仍可能被在途任务回写。两者同时进入诊断信息后,线上问题才能区分为数据版本错、视口边界错或提交时序错。
六、这套做法的边界比成功路径更重要
第一,纬度仍要限制在地图与索引都能接受的范围,接近极区的数据不能只靠经度拆分。第二,supercluster 的半径是像素语义,不是米;产品若要求“500 米内合并”,需要在查询前做地理距离策略,不能简单把 radius 写成 500。第三,源点 ID 必须稳定,后端每次返回随机 ID 会破坏去重与标注复用。
第四,相机回调有“变化中”和“变化结束”之分。ClusterAtlas 在变化中只更新轻量提示,在结束后才发起完整查询;如果产品要求拖动时实时聚合,可以增加 80 ms 节流,但 generation 检查仍不能省。第五,TaskPool 传输的数据应是可序列化快照,不要把 MapComponentController 或 UI 对象带进任务。
最后,跨 180° 经线不是地图组件的偶发毛病,而是坐标空间本身的环形边界。把边界拆分、结果去重、代数提交、姿态回放分别建模后,问题从一次“修空白”变成了可测试的协议:任何返回结果都必须回答它属于哪一代、查询了哪两个包围盒、能否在当前页面生命周期内提交。这个协议才是 ClusterAtlas 后续扩展到离线点、告警热区和多窗口显示时真正可复用的部分。
更多推荐


所有评论(0)