凸包算法第一天就翻车了
把 GPS 轨迹转成面积,听起来就是个几何题。我最早的想法是用凸包——把所有轨迹点用最小凸多边形包起来,算面积。代码写了半小时,跑出来的结果让我直接放弃:从家走到公司,凸包把沿途两侧几百米的空地全算进去了,一趟通勤「探索」了 2km²,荒谬。
这是我做一个 iOS 足迹 App 时遇到的第一个问题。App 的核心想法是把你走过的路转化成一个数字——探索面积(km²)。不是打卡,不是轨迹线,是你用脚实实在在覆盖的土地。
下面按踩坑顺序聊三块:网格计算、成就系统、后台定位。
25 米网格:试出来的粒度
凸包不行,我换成了网格方案。把地图按固定尺寸切格子,GPS 点落在哪个格子,那个格子就算被「点亮」。最终面积 = 亮格子数 × 单格面积。
粒度选 25m 是试出来的。10m 太细,城市里 GPS 漂移一条路能飘出三排格子;50m 太粗,绕小区一圈和穿过去没区别。25m 刚好:一条马路亮一排,拐弯多亮几个,体感合理。
投影用的等距圆柱投影,经度方向乘上 cos(latitude) 做纬度修正,把经纬度换算成近似米。没用 UTM,因为 UTM 跨带处理麻烦,而这个场景对投影精度要求不高——格子本身就是 25m 粒度,投影误差在中低纬度地区远小于一个格子。
struct GridCell: Hashable {
let x: Int
let y: Int
}
func exploredCells(from points: [CLLocationCoordinate2D], gridSize: Double = 25.0) -> Set<GridCell> {
var cells = Set<GridCell>()
let metersPerDegLat = 111_320.0
for point in points {
let metersPerDegLon = 111_320.0 * cos(point.latitude * .pi / 180)
let mx = point.longitude * metersPerDegLon
let my = point.latitude * metersPerDegLat
cells.insert(GridCell(x: Int(floor(mx / gridSize)), y: Int(floor(my / gridSize))))
}
return cells
}
// 面积 = cells.count * 625 m²
成就系统:Game Center 对接踩了两周坑
做到加徽章系统时,我原以为「写几个 if 就完了」。
实际上我搞了个 BadgeMetrics 把所有触发指标聚合到一起:
struct BadgeMetrics {
let totalDistanceKilometers: Double
let recordedDays: Int
let currentStreakDays: Int
let nightSegmentCount: Int
let chinaProvinceCount: Int
let cityUnlockCount: Int
let chinaAreaKm2: Double
let globalAreaKm2: Double
// ...十几个字段
}
为什么要这么做?因为徽章判定条件五花八门——有的看连续打卡天数,有的看省份解锁数,有的看夜间出行次数。如果每个徽章各自查数据库,性能和维护都是灾难。统一算一次 metrics,所有徽章基于同一份快照判定。
徽章分了五个 Track(exploration、consistency、china、world、pro),每个 Track 下多个系列,每个系列分多级。结构定义不难,真正耗时的是 Game Center 对接。
最恶心的一个坑:Game Center 的成就百分比必须单调递增,你上报一个比之前小的值,它不报错、不回调失败,直接静默丢弃。我的代码逻辑在某些边界情况下会重算 metrics(比如删除了一条无效轨迹),导致百分比回退,然后上报就像石沉大海。排查这个花了三天,因为沙盒环境下成就可以手动重置,表现和生产完全不一样——沙盒里重置后再上报小值是可以的,上线后就不行了。最后的方案是本地维护一个已上报最大值的缓存,上报前做 max(local, new) 比较。
另一个坑是沙盒环境的成就列表加载偶尔返回空数组,不是网络问题,就是苹果的沙盒服务不稳定。加了重试队列和指数退避才算稳定。
后台 GPS:从 30% 到 18%
后台持续 GPS 记录是个经典难题。我用 CLLocationManager 的 allowsBackgroundLocationUpdates,activityType 设成 .fitness,系统会根据运动状态调整采样频率。
但依然费电。我在自己的 iPhone 14 上测过,用 Instruments 的 Energy Log 跑了一整天(早通勤步行 40 分钟 + 白天办公室静止 + 晚通勤步行 40 分钟 + 周末步行约 4 小时,累计约 6 小时移动),GPS 模块单项能耗占比约 30%。后来加了一个逻辑:如果连续 N 个点位移小于 25m,就主动把 desiredAccuracy 降到 kCLLocationAccuracyHundredMeters,让系统有机会关 GPS 芯片。一旦检测到位移恢复就切回高精度。同样条件再测,降到了 18% 左右。
有点烦的是,iOS 每次大版本更新后台定位策略都会微调,每年秋天得重新测一轮。
中国行政区的坑
省份解锁功能内置了中国行政区边界数据,用射线法判定点在哪个省市。
坑在坐标系:CoreLocation 返回 WGS-84,国内地图用 GCJ-02,偏移在某些区域能到好几百米。不转换的话,站在北京能被判到河北。市级边界精度也有问题,有些地方锯齿严重,GPS 一飘就落到边界外。我的处理方式比较粗暴——边界简化一次,判定加小缓冲区。不够精确,但对「你去没去过这个市」这个粒度够用。
一个还没想明白的问题
我一直在纠结网格粒度要不要做成动态的——城区 25m、郊区 50m、野外 100m。好处是郊区徒步时不会因为 GPS 精度差而虚高面积。
我试过按 CLLocation.horizontalAccuracy 动态调格子大小:精度优于 20m 用 25m 格子,20-50m 用 50m 格子,50m 以上用 100m。但边界处会出现面积跳变——你从城区走到郊区,同一段路前半段是 25m 格子后半段变成 50m 格子,用户看到面积增速突然变了,体感很奇怪。而且跨粒度的面积没法直接加,得做归一化,复杂度一下上去了。
目前我倾向于固定 25m 粒度 + 后处理过滤(丢弃 horizontalAccuracy 大于 50m 的点),简单粗暴但结果一致。有做过类似自适应网格的同行吗?想听听你们的方案。