iOS 定位的矛与盾
我在做一个足迹 App:打开记录以后,它全天在后台记下你走过的路,在地图上一点点揭开。对记录的要求就四条:
不漏记:开车、散步、坐地铁,走过的路都要在。
不乱记:手机躺在桌上,地图上不能多出一团乱线。
不费电:全天开着,不能让人想关掉。
不挂提示:状态栏、灵动岛上不能一直挂着“有 App 在使用你的位置”。
这四条互相拆台。要不漏记,就得一直开着定位;一直开着,就费电,系统也会挂提示;为了省电降精度、少记点,又会漏记和乱记。
做下来,我发现这是两场攻守相反的仗:
前半场,App 是矛,iOS 是盾。 我想一天 24 小时拿到每一步的位置,系统要替用户守住电量和隐私:挂起进程,自动暂停定位,在灵动岛上提示。这一场要学会在它的规则里待住。
后半场反过来,App 举盾。 待住以后,系统每秒送来一个点,里面不少是假的:室内 Wi‑Fi 定位乱跳,隧道里手机自己推算位置,半夜手机躺着不动,却报出每秒 25 米的速度。这一场要把假点挡在地图外面。
打完两场,还有一个更根本的问题:盾该放在哪儿。 这一点放到后面讲。
文中的数字都出自我自己手机上的日志。有几处苹果文档没写,或者写了但和实测对不上,会单独说明。
一、在后台待住
最后留下来的配置就这么几行,每一行都是换过别的写法、吃了亏才定下来的:
manager.pausesLocationUpdatesAutomatically = false // 不让系统自动暂停manager.showsBackgroundLocationIndicator = false // “始终允许”下不显示定位提示
manager.allowsBackgroundLocationUpdates = true
manager.distanceFilter = kCLDistanceFilterNone // 不设系统距离过滤
manager.startUpdatingLocation()
// 另一个 manager,只负责被杀掉后把 App 拉起来
wakeManager.startMonitoringSignificantLocationChanges()
liveUpdates 用不了
CLLocationUpdate.liveUpdates 写起来干净,但它在后台一定会显示定位提示。一个全天开着的足迹 App,状态栏上常年挂着提示,用户迟早会去把权限关掉。
老的 CLLocationManager 在用户给了“始终允许”时,可以用 showsBackgroundLocationIndicator = false 关掉提示。只给“使用 App 期间”的话,系统一定会显示,这一点没有商量余地。
distanceFilter 不能设
这一条最反直觉。给 distanceFilter 设 10 米,看上去是在帮系统省电,实际效果是 App 被挂起。
日志里看得很清楚:设了 5~50 米的过滤,坐车途中 App 每次被显著位置变化唤醒,只记十来秒就又被挂起,下一次唤醒在大约 5 分钟后。一段车程在地图上只剩几截断开的短线。
苹果工程师在开发者论坛上说明过:同时开着显著位置变化时,要“不设距离过滤,加百米或更好的精度”,App 才不会被挂起。所以我让系统每秒回调一次,隔多远记一个点完全由 App 自己筛。
自动暂停也得关
pausesLocationUpdatesAutomatically 默认是开的:系统觉得你不动了就停掉定位,要走出一段距离才恢复。我试过配一个 150 米的围栏来恢复,结果等红灯会停,坐久了也会停,开车起步和楼下散步的头一段都丢了。
有的足迹 App 走的就是这条路,省电档在静止时直接停止后台运行,它们的说明里也承认会漏记。我选了另一头:静止时只降精度,定位不停。
显著位置变化只当兜底
显著位置变化要移动 500 米以上才触发,最多约 5 分钟一次,位置误差几百米,拿来画线是不行的。留着它只为一件事:App 被系统杀掉以后,它能把进程重新拉起来。
所以它单独用一个 CLLocationManager,回调里只在日志里记一笔“系统唤醒过”,粗略的位置不进轨迹。还有一条配套的限制:连续定位要在前台开始,后台只有在被显著位置变化唤醒的那一刻开始才有效(苹果的说明)。
用户主动划掉 App 时,我把所有定位都停掉,并推一条“足迹可能丢失”的通知。
重启以后还没解锁
这是 iOS 特有的坑。手机重启以后、用户第一次解锁之前,系统可能会因为显著位置变化在后台把 App 拉起来。这时文件保护还没解开,SQLite 打不开。
我的做法是:新记的点先追加写进一个不加文件保护的小文件,数据库能打开以后再并进去。解锁时、每次回到前台都会重试。手机已经解锁了还打不开(比如文件坏了),就把原因弹给用户。
二、省电,但定位不停
不设距离过滤、不让系统暂停,等于把系统给的两个省电开关都关了。剩下能调的只有两样:要多高的精度,以及拿到点以后做多少事。
让运动传感器决定精度
CMMotionActivityManager 会告诉你手机现在是静止、步行还是在车上,我按它来切换定位方式:
坐车这一行有两个取舍。kCLLocationAccuracyBestForNavigation 是系统最费电的一档,苹果建议插着电用;.automotiveNavigation 会把位置吸附到公路上,坐火车、地铁时反而会偏。
降一档有没有代价,我用同一条通勤路线比过。用导航精度那天,13.1 公里收了 264 个点;用最高精度那天,12.9 公里收了 311 个点。两天的线横向只差 2 米(中位数),误差的中位数都是 8 米,放大到街道级也看不出差别。
传感器说“不知道”时怎么办
这一节踩的坑最多,全都和传感器报的 unknown 有关。
静止要等两分钟才降精度。 等红灯、地铁停站都会让传感器报静止,马上降精度的话,起步那一段就糊了。
“静止够久”要在定位回调里检查。 最初的写法只在传感器换状态时检查:第一次报静止时还不到两分钟,之后状态一直不变,就再也没检查过,结果一直没切到省电。现在每个定位回调都检查一遍。
静止时报的 unknown 别当真。 有一天,传感器从静止跳到 unknown 一共 475 次,中位持续 16 秒,九成又回到静止,基本就是手机被拿起来看了一眼。如果每次都升回最高精度,两分钟的计时就永远数不完。
移动中报的 unknown 也别马上当真。 骑车时传感器大半时间报 unknown,隔一阵才报几秒 cycling。我一开始按状态一变就切换定位方式,结果骑两小时切了 89 次。翻了一周的日志,看前后是同一种移动、中间夹着的 unknown 有多长:步行九成在 2 分钟内,坐车全在 1 分钟内,骑车中位数半分钟、九成在 4 分钟内。所以移动中报 unknown,先按原来的状态算,连续 3 分钟都是 unknown 才认。
后两条合起来是一句话:传感器报出明确的状态,立刻信;报 unknown,就信它上一次明确说的。 这条规则没有副作用:真换了交通方式,比如下车走路,传感器会报出新的明确状态,那时立刻换。
mutating func sensor(_ next: MotionState, at time: Date) { guard next == .unknown else { unknownSince = nil; state = next; return }
guard state != .stationary, state != .unknown else { return } // 静止时的“未知”不理
unknownSince = unknownSince ?? time
if time.timeIntervalSince(unknownSince!) >= 180 { state = .unknown } // 移动中的“未知”要连着 3 分钟
}
回调里少干活
每秒一次的回调本身不贵,贵的是回调里做的事。App 不在前台时,我攒够 100 个点才重算当天的折线。退到后台时把地图上的蓝点也关掉,免得 MapKit 自己再开一路定位。
现在后台耗电和同类 App 在同一水平。下一步要省的是停留期间:按日志,一天里停留期间开着最高精度的时间有 149 分钟,来源是在屋里走动时传感器报了步行,精度就升上去了。改成停留期间只做短查,能压到 20 分钟左右,代价是出门时判定离开最多晚 30 秒。
三、系统给的点不能全信
待住以后,CLLocation 每秒来一个。收不收,我的标准是宁可少记,也不要怪线:少一个点没人看得出来,地图上多一条斜穿小区的直线,一眼就看见了。
室内的误差靠不住
我在办公楼里坐了一天,App 收了 24 个点。这些点全都没有卫星速度(speed < 0),自称误差 15~30 米,实际在 20~60 米的范围里乱跳。
最离谱的一次,位置跳出去 158 米,自称误差 22 米,在那边停了 10 分钟才跳回来,地图上多了一条斜线。室内靠 Wi‑Fi 和基站定位时,系统报的误差明显偏乐观。
每个点怎么筛
每个样本和上一个收下的点比,按顺序过这几关:
误差门槛:误差 100 米以内照常处理;100 米到 2 公里的,只在坐车时收(隧道里只有基站定位),记成“粗略点”:存着,但不揭开、不画线。
间隔随速度变:
max(10, min(speed × 3, 200))米。步行约 10 米一个点,开车最多 200 米一个点。移动距离要超过自己的误差:否则可能只是漂移。室内那一团乱线就是这么来的。
没有卫星速度的点要求更严:和上一个点的误差范围完全不重叠(距离大于两点误差之和)才收。
最管用的是第 4 条。speed 是负数,说明这个点不是卫星算出来的,它自称的误差就要打折扣。
跳出去的点先当候选
Wi‑Fi 定位有个习性:跳到一两百米外,停住不动,过一阵再跳回来。真的走开了的人不是这样,他会越走越远。
所以传感器报静止时,挪出 100 米的点不直接收,先记作候选。卫星测出在动(比如坐在很稳的车上)就直接收;否则要等 2 分钟内又走远 50 米才算数。
半夜每秒 25 米朝正北
这是我见过最怪的一种。好几次凌晨,手机躺着没动,系统却报出每秒 25 米、朝正北的速度。
麻烦的是运动传感器也被带偏了,跟着改口报“坐车”,位置也被照着这个速度直线推出去。有一次是 36 秒推出 624 米,然后弹回原地。苹果为什么会这样,我没查到任何说明。
我在原始样本里多记了几列以后,才看出这种点不对:22 个带速度的样本里,20 个缺速度误差或方向;而正常开车、步行的 3219 个带速度的样本,这几项全都有。
/// 报了 1 米/秒以上的速度,却没同时给出速度误差、方向、方向误差:这个速度是假的static func speedTrusted(speed: Double, speedAccuracy: Double,
course: Double, courseAccuracy: Double) -> Bool {
speed < 1 || (speedAccuracy >= 0 && course >= 0 && courseAccuracy >= 0)
}
不可信的点直接不收。拿那天凌晨的样本重放,原来收进来的 6 个假点一个不剩;拿正常一整天的样本重放,路上的点一个没少。
停下来以后
一个点一个点地筛,对付不了“在家待一天”。有一天我在家,App 收了 39 个点,散在直径约 100 米的范围里,连成一团乱线。
原因是保护松得太容易。在家起身走十几秒,传感器报了“步行”,静止的保护一松,室内 Wi‑Fi 定位乱跳的点就全放进来了。
所以在逐点过滤上面又加了一层“停留”。进入停留的条件放得很宽,离开则必须有位置上的证据:
stateDiagram-v2 在路上 --> 停留: 传感器报静止满 2 分钟<br/>或 3 分钟内没离开 50 米
停留 --> 候选: 卫星测出在动,离开 30 米<br/>或没有卫星速度,跳出 100 米
候选 --> 在路上: 2 分钟内继续走远
候选 --> 停留: 没有继续走远
停留 --> 在路上: 下一个点已经在 1 公里外
停留期间一个点都不记。要离开,先当候选,后面的点继续走远了才算。
传感器只在说“停了”时可信。 传感器说“停了”,可以信,信错了的代价只是少记几个点。传感器说“走了”,不能信:
报步行不解除停留。在家走动和真的出门,传感器分不出来。
报坐车也不解除。前面那个假速度会带着传感器一起改口;真坐车离开时,位置自然会越走越远。
确认需要时间,所以要补记。 “走远 30 米、再走远 10 米”才算离开,意味着确认的时候人已经走出几十米了。如果这一段直接丢掉,每次在路口、景点停一下再走,线都会缺一截开头。所以停留期间虽然不记点,但会留着最近 2 分钟里卫星测出在动的样本。一旦确认离开,就把这一小段按正常步行的间隔补记回去。
两种特殊情况。 App 重新启动时,以前第一个点无条件收下,正好把一个室内漂移点钉在地图上;现在接着数据库里最后一个点判断,还在它 100 米以内,就直接算停留。另一种是 App 被挂起了一路,醒来时下一个样本已经在 1 公里外,这时不等确认,直接算离开。
效果:用四天的真机数据重放,停留处没有卫星速度的点从 82 个降到 11 个,路上的点一个不少。有一天在公司,10:38 到 18:26 收了 0 个点;18:26 步行离开,补记了 5 个点,线的开头是完整的。
隧道:三种坏点
隧道里会收到三种东西,处理办法各不相同。
什么都没有。 有一次断了 103 秒,出洞后的第一个点和进洞前的最后一个点直接连起来,是一条 2.3 公里的直线,比实际的隧道偏出约 150 米。规则:两个准点之间隔了 20 秒以上、又相距 500 米以上,就不连实线。
基站定位。 误差 700 到 1200 米,按前面说的粗略点处理:存着,不揭开、不画,前后的实线在这里断开。
手机自己推算的位置。 这一种最隐蔽。没有卫星时,iOS 会按进洞时的方向和车速自己往前推位置,误差大约一秒涨一米。有一次 65 秒里误差从 14 米涨到 94 米,始终在 100 米的门槛以内,全被当成准点收下。后半段偏到了路的南边,出洞时 Wi‑Fi 定位又把位置拉回来,连出一个钩。
推算点的特征藏在误差曲线里。正常定位的误差有涨有落,推算的误差只涨不降:
// 车在动、误差超过 30 米,前面至少 10 秒误差只涨不降、平均每秒涨 0.5 米以上:手机在推算guard s.speed >= movingSpeed, s.accuracy > 30, let start = risingSince else { return false }
let dt = s.timestamp.timeIntervalSince(start.timestamp)
return dt >= 10 && (s.accuracy - start.accuracy) / dt >= 0.5
高速上信号慢慢变差时,误差一秒只涨 0.05 米,和推算差了一个数量级,不会误伤。拿一周的原始样本重放,只少收 12 个点,全在隧道口。
地铁:干脆不揭开
在地铁车厢里,运动传感器不报“坐车”,只报 unknown 和静止;地下拿到的点误差 400 到 3000 米。粗略点的前提是“人在车上”,这里判断不出来,所以这些点不收。
结果是地铁段在地图上一片空白,这是有意的:坐地铁看不到地面,本来就不该揭开。路线上用一条同色虚线把前后两段接起来,表示“人从这里到了那里”。虚线不揭开,也不算里程。隧道、App 被挂起、坐飞机留下的空档都这样接。
另一个坑:加偏坐标
手机在中国大陆时,苹果地图的底图、搜索、反向编码用的都是加偏坐标(GCJ‑02,偏 250~620 米),而 CLLocationManager 给的是真实坐标。苹果不会告诉 App 当前是否在加偏。
我的做法是比较地图上的蓝点和原始定位:差出几百米,就判定在加偏,之后渲染和进出苹果服务的坐标都做换算。存下来的轨迹始终是真实坐标。
四、盾该放在哪儿:门口少拦,事后重判
上面这些规则,最早全都放在门口:点进来,过筛子,收下的入库,其余的扔掉。
问题在于,筛子里的大部分规则都是猜。猜就会错,规则就一定会改。可门口扔掉的点,规则改了也捡不回来。门口的过滤是一扇单向门。
所以现在拆成三层:
原则就一句:门口只扔确定没用的;凡是靠猜的,都放到能重来的地方。
一根刺的两次修法
开车回来停车进楼,卫星信号没了,Wi‑Fi 定位跳出去五六十到一百米,过一会儿又跳回来,轨迹上多出一根刺。十天里,我的轨迹上有 16 根。
第一版修在门口:没有可靠速度的点跳出 30 米时先不收,等下一个点;下一个点跳回来了,就把它丢掉。用十天的原始样本重放,16 根刺去掉了 14 根,带卫星速度的点一个没少。
数据很漂亮,第二天我还是把它撤了。这条规则判错的时候,比如真的下楼走出去 30 米又走回来,那个点就永远没了。而它要解决的问题,入库以后看着前后两个点一样能判,还判得更准,因为那时已经知道“下一个点”是什么。
于是改成入库后判跳点:三个相邻的点 a、b、c,b 离 a、c 都远,a 和 c 却挨得很近,时间又短,b 就是跳点。
func isSpike(_ a: TrackPoint, _ b: TrackPoint, _ c: TrackPoint) -> Bool { let dt = c.timestamp.timeIntervalSince(a.timestamp)
let ab = a.distance(to: b), bc = b.distance(to: c), ac = a.distance(to: c)
// 有可靠卫星速度:偏出 200 米、5 分钟内回到原处
if dt <= 300, ab > 200, bc > 200, ac < 0.25 * min(ab, bc) { return true }
// 没有可靠速度(室内 Wi‑Fi 定位):门槛降到 40 米、10 分钟
return !b.hasReliableSpeed && dt <= 600 && ab > 40 && bc > 40 && ac < 0.5 * min(ab, bc)
}
跳点不删,只打一个标记。它带来的揭开、到访、里程和成就,都按受影响的范围局部重算、收回。拿测试机两周的数据整份重算:多清掉 13 根刺,里程少了 1.7 公里,区县、成就一个没变。以后要是觉得 40 米太狠,改个数字、发一版,所有旧轨迹都会按新规则再判一遍。
“可靠速度”也是在这时重新定义的。有一个点报速度每秒 1 米,速度误差 1.8,方向误差 153 度,位置往东跳出 98 米又跳回来。速度误差比速度本身还大、方向误差超过 90 度,这种速度就是噪声。可十天里收下的点中有 784 个是这种“不可靠速度”,大多是慢走、堵车时挪十几米的真点。所以这个判断只用在判跳点上,不放到门口。同一个信号,放在门口是一把刀,放在事后只是一条线索。
手机给的,全都存下来
假速度那条规则能定下来,靠的是原始样本里多记的那几列。所以现在每个轨迹点都存下系统给的全部字段:坐标、误差、速度、速度误差、方向、方向误差、当时的运动状态、当时的时区偏移。现在的规则有几项根本用不上,也照存,因为你不知道下一条规则要用哪个字段。
存全了不等于存得胖。读数一律存整数:坐标存 1e‑7 度(约 1 厘米),误差和速度存厘米,方向存 0.1 度,运动状态存编号。每个点从 157 字节降到 61 字节,按两年、63 万个点估算,是 34 MB。
门口猜掉的点单独存一张表:漂移嫌疑、手机推算、地铁里的粗略点、假速度、停留时跳出去的候选,每天两三百个,带着没收的原因。停留期间原地的点、间距太近的点,每天约 8000 个,量大又没有新信息,不存。
重算要快到可以随时做
整套做法的前提是重算便宜。数据库拆成两个 SQLite 文件:事实那份进 iCloud 整机备份;算出来的那份所在的目录标成不备份,丢了就重算。App 版本号一变,就在后台整份重算,算完和旧结果比一遍,告诉用户哪些地方、成就变了。
两年的模拟数据(63 万个点),在 Mac 上用 Release 跑:
关键是线段不存。每个 64×64 的块只记“哪几段时间有线经过”,要画的时候,把那几段时间的点读出来,按当前的规则现连。连线规则改了,也不用迁移任何数据。
五、规则是怎么试出来的
上面每一条规则,都是盯着真机日志一条条看出来的。后台定位没法用模拟器调:挂起、漂移、假速度、隧道推算,模拟器里一样都不会发生。所以我在 App 里埋了两份日志。
记录日志:用心跳发现挂起。 每个收下的点、每次状态切换、停留开始和结束都记一行。丢掉的点每秒都有,不逐条写,汇总进每分钟一行的心跳:
hb bg car cb=58 a=12 r=46 age=1意思是:后台,坐车,这一分钟回调 58 次,收 12 个、丢 46 个,最新的样本是 1 秒前的。心跳用的是普通的 Timer,只在进程活着时触发,所以心跳断了,就是 App 被挂起了,前面 distanceFilter 那个问题就是这么发现的。
原始样本:让规则可以重放。 只看收下的点,调不了过滤规则,因为你不知道改完会误伤什么。所以每次定位回调都往 CSV 里写一行,被丢掉的也写,连同丢掉的原因,一天 3~4 MB,保留 7 天。过滤和停留的逻辑全在一个与平台无关的 Swift 包里,电脑上的重放工具用的就是 App 里那套代码:拿一天的原始样本过一遍,报出收了几个点、和手机上当时差在哪。前面每一个“重放以后少收几个点”的数字,都是这么来的。
日志本身也要可靠:
攒着写,崩溃时补写。 满 60 秒或 64 KB 才写一次盘,进后台、划掉 App 时也写。崩溃时在信号处理函数里把缓冲区补写进去,末尾加一行
crash sig=N。分清是被杀还是崩溃。 每次启动记一行
prev-exit last=bg,标明上次最后是在前台还是后台。末尾有crash行的是崩溃,没有的是被系统杀掉。一键导出诊断包。 包里有数据库快照(用
VACUUM INTO,正在记录时导出也是一致的)、还没并进库的点、日志、原始样本、权限和设置。每次导出生成一个临时密钥,和开发者公钥协商出对称密钥,压缩后用 ChaChaPoly 加密,App 里只有公钥。在电脑上解开后,目录结构和用线拉下来的一样,重放工具直接能跑。
几条经验
distanceFilter不设,自动暂停关掉,显著位置变化只当兜底。 想全天后台不被挂起、不挂提示,只有这一种组合。运动传感器报出明确状态就信,报
unknown就信它上一次明确说的。 “停了”可以信,“走了”要拿位置来证明。没有卫星速度的点,自称的误差要打折扣。 速度、速度误差、方向误差三项凑不齐,这个速度就是假的。
误差本身也是信号。 只涨不降的误差,说明手机在推算位置。
门口只扔确定没用的。 凡是靠猜的规则,都放到入库以后,规则改了可以拿旧数据重判。
事实存全,算出来的随时可以扔。 读数存整数,存全并不贵;重算快到 5 秒,才敢随时改规则。
第一天就把原始样本全记下来。 规则写错了可以改,可那天走过的路过去就过去了,没存下来的数据补不回来。