一堆 GPS 坐标点,怎么变成一个面积数字?这是我做「雁过留痕」时卡了最久的问题。
这个 App 的核心设定是:不记录你去过哪里,而是量化你真正踩过多少土地。后台 GPS 采集本身不难,CLLocationManager 配好 allowsBackgroundLocationUpdates = true 就跑起来了。但拿到一堆坐标点之后,怎么把它们变成一个有意义的面积数字——这事儿折腾了我整整两周。
三个方案,死因各不同
轨迹缓冲区(Buffer):给每个坐标点画一个半径 r 的圆,取所有圆的并集面积。实现倒是简单,但同一条路走两遍面积就算两遍,完全违背「量化新探索」的初衷。第一天就毙掉了。
Voronoi 分割:把轨迹点作为种子生成 Voronoi 图,用多边形面积加总。思路上挺干净,但计算量在点数稍多时直接爆——一次 2 小时 citywalk 容易超 3000 个 GPS 点,Voronoi 重建在主线程跑了 4 秒以上,扔进后台队列也要 800ms+,手机上完全不可用。
自适应栅格化:动态调整网格精度,密集区域用细格、稀疏区域用粗格,理论上节省存储。实现到一半发现问题:相同路线在不同会话下跑两次,计算出的面积相差能到 8%,完全不稳定。根本原因是不同精度的格子在接缝处对齐策略不一致,导致边界格子被重复计或漏计,而且这个偏差随路线形状变化,没有规律可循,修不动,直接推倒。
最终方案:25m 均匀网格
退回到最「笨」的方案:固定精度网格离散化。把地球表面按 25m × 25m 切成均匀网格,每个 GPS 点命中哪些格子就标记哪些格子,面积 = 已标记格子数 × 单格面积(约 625m²)。同一个地方走 100 遍,面积只计一次。
25m 这个精度是反复权衡的结果。10m 的话一次长距离骑行格子数量会到百万级,存储和查询都吃不消;100m 精度损失太大,在小巷绕一圈和走直线的面积差距几乎体现不出来。25m 实测是比较合理的平衡点:一次 2 小时 citywalk 大概产生 8000-12000 个新格子,SQLite 全表扫 8 万个格子约 15ms,冷查询 p99 控制在 30ms 以内,App 内交互无感知。
省市解锁的坐标系坑
做「中国省份解锁」时踩了一个经典坑:iOS CoreLocation 给的是 WGS-84 坐标,国内行政区边界数据普遍基于 GCJ-02(火星坐标),两者在省界附近偏差能到几百米,直接用原始坐标判断会频繁误判「你在哪个省」。
处理方式是落点判断前先做坐标系转换。但有个关键边界:海外坐标不能套这套转换,否则会反向偏移。isInChina 的判断逻辑是先用一个粗粒度矩形包围盒(大约 72°E–135°E、3°N–53°N)快速过滤,边境线附近留 50km 左右的 buffer 都算「国内」,宁可多转换一次也不能漏转。粗包围盒性能极低,真正的省界多边形 PIP(Point in Polygon)判断只在通过初筛后才触发。
// kA = 6378245.0(WGS-84 椭球体长半轴)
// kEE = 0.00669342162296594(第一偏心率的平方)
func transformToGCJ02(wgs84: CLLocationCoordinate2D) -> CLLocationCoordinate2D {
guard isInChina(coordinate: wgs84) else { return wgs84 }
let dLat = transformLat(x: wgs84.longitude - 105.0, y: wgs84.latitude - 35.0)
let dLon = transformLon(x: wgs84.longitude - 105.0, y: wgs84.latitude - 35.0)
let radLat = wgs84.latitude / 180.0 * .pi
let magic = 1 - kEE * sin(radLat) * sin(radLat)
let sqrtMagic = sqrt(magic)
return CLLocationCoordinate2D(
latitude: wgs84.latitude + (dLat * 180.0) / ((.pi / sqrtMagic) * (kA * (1 - kEE) / (magic * sqrtMagic))),
longitude: wgs84.longitude + (dLon * 180.0) / (kA / sqrtMagic * cos(radLat) * .pi)
)
}
省份之外还加了「大区」概念——华东、西北这种。有些用户短期内跑不完一个省,但「我已经踏足华东 4 个省」这个反馈也够有成就感,当作过渡里程碑用。
成就系统的数据结构设计
成就判断涉及的指标比我最初预想的多。最终 BadgeMetrics 里收了 8 个核心维度:
struct BadgeMetrics {
let totalDistanceKilometers: Double // 累计总里程
let chinaProvinceCount: Int // 解锁省份数
let chinaAreaKm2: Double // 国内探索面积
let currentStreakDays: Int // 当前连续记录天数
let longestStreakDays: Int // 历史最长连续天数
let nightSegmentCount: Int // 夜间出行次数(22 点后开始)
let weekendRecordDays: Int // 周末记录天数
let cityUnlockCount: Int // 解锁城市数
}
设计上刻意把「数据汇总」和「解锁条件」拆开——BadgeMetrics 只负责把数字收进来,每个徽章的解锁逻辑在 BadgeDefinition 里通过 BadgeRequirement 枚举描述。加新徽章不动核心计算,只加定义。成就按赛道(BadgeTrack)分:探索、坚持、中国地图、世界地图、Pro 专属,每条赛道配对应主色调,UI 上一眼就能看出这个徽章属于哪个方向。
夜间出行徽章是个小设计——22 点以后开始的记录单独计数。有用户反馈说专门为了解锁这个在晚上出门遛弯,这个反馈挺让我意外的。
Widget:最低成本的日活触点
桌面 Widget 做的是「今日探索进度」——当天新增面积、路程,以及距离下一等级还差多少。用 WidgetKit + AppIntents,数据通过 App Groups 共享,Widget 读 UserDefaults 里的当日统计缓存,不每次刷新都查数据库。
Widget 的价值说白了不在功能,在于每天解锁屏幕瞄一眼「今天走了多少」,形成打开 App 的肌肉记忆。
现在还没解决的两个技术问题
App 目前 1.15 版,App Store 评分 5 分。核心逻辑跑通了,但还有两块没收干净:
耗电:25m 精度需要高频 GPS 采样,我做了动态降采样——用户静止或低速时降低采样频率,但慢速骑行(时速 8-12km)的边界条件很难调,偶尔漏点,导致面积统计偏低。还没找到一个对所有场景都稳的阈值策略。
大文件备份卡顿:走了一年以上的用户轨迹数据量不小,目前用 UIDocumentPickerViewController 做导入导出,大文件时 UI 会有明显卡顿,流式写入还没做。
如果你也在做类似的地理数据处理,或者对 25m 网格方案有更好的思路,评论区聊聊——特别是耗电和 GPS 采样策略这块,我还在找更优解。