上一篇把产品定位聊清楚了:记录我和城市擦肩而过的故事。这一篇聊怎么在 iOS 上把「与城市擦肩」这件事真正做出来。这是全系列比较硬核的一篇,如果你不写代码,可以直接跳到「一个不是 bug 的 bug」那节看故事。

三堵墙

我的需求听起来很简单:城市里散布着几百个故事点,用户路过(走进七十五米到一百来米的半径圈)就在手机上弹一条本地通知。另外有一条产品红线:全程离线判定,位置数据不许离开手机。

然而事情并没有想的那么简单,iOS 给你三堵墙:

  1. Region monitoring(系统地理围栏)每个 App 上限 20 个。一座城市几百个点,名额只有 20 个。
  2. 想绕过围栏、自己常开后台位置流?电量杀手。用户看到电池排行榜第一名是你,Always Allow权限第二天就被收回了。
  3. GPS 在城市里没那么老实。高楼区误差经常五十到一百米,小半径围栏漏报是家常便饭。

这三堵墙互相矛盾:想触发准就要围栏多、半径小;想省电就不能常开定位;想不漏报就要半径大。整个 0.1.x 的定位架构演进,就是在这三个圈里找活路。

第一版,和它的问题

先坦白,我之前没怎么做过定位相关的功能,所以这个项目对我来说也是搞清楚「一个需要经常定位的 App 到底应该怎么做」的过程。我对 Always location 掉电的全部认知,来自用导航 App 的体感:确实掉得挺快。我不想让听城变成那样的 App。

第一版的方案很简单:监听系统的 significant location change,加上周期性后台位置更新,每次拿到新位置就动态注册离用户最近的十八个 geofence。能用,芝加哥旅行就是这一版撑下来的。但它有一条甩不掉的尾巴:那个周期性后台位置流。它既是电量的漏点,也是「位置数据只在本机」这个承诺里最让我自己不舒服的部分,哪怕数据不上传,App 也在持续拿位置。

7 月 1 日的 commit 记录了转折点:

Sentinel geofence architecture: kill always-on background location stream

把常开位置流杀掉,换成哨兵架构。

19 + 1:哨兵架构

核心思路在 geofence.ts 的注释里就一句话:

iOS 每个 App 最多监测 20 个 region:19 个给故事点,1 个留给哨兵。

19 个名额注册用户附近的故事点,第 20 个名额注册一个以用户当前位置为圆心的「哨兵」围栏。用户在哨兵圈里怎么走,App 都不需要知道;一旦走出哨兵圈,系统会替你唤醒 App,App 以新位置为中心重新挑 19 个点、重建围栏、重设哨兵,然后继续不干了睡大觉。

换句话说,App 从「持续盯着你的位置」变成了「被系统按需叫醒」。定位这件事外包给了操作系统本身,而 region 事件是 iOS 里最省电的位置机制。

听城的哨兵 geofence 架构:19 个故事围栏和 1 个哨兵围栏组成动态监测池

19 个围栏给故事点,第 20 个留给哨兵。走出哨兵圈后,iOS 唤醒 App 获取一次位置、重排附近点,再把 App 放回睡眠。手机上可点图放大。

200 米不是拍脑袋,是算出来的

哨兵圈多大合适?太大,池子刷新不及时,你走出覆盖区了围栏还是旧的;太小,整天唤醒 App,省电就白省了。

步行档的答案是 200 米,geofence.ts 里留着推导过程:

取 200m:经过点中心 75m 内时在圈内的弦长 ≥ 2·√(125²−75²) = 200m

解释一下这行几何:步行档下故事点围栏的半径下限是 125 米。假设你从一个围栏里穿过去,路线离圆心最近距离不超过 75 米(这个量级才算「路过现场」),那么你在这个圆里走过的弦长至少是 2·√(125²−75²),正好 200 米。哨兵半径取 200 米,意味着你不可能在圆里走完这么长一段路而哨兵毫无动静:要么这个围栏本来就注册着(直接触发),要么你早就走出过哨兵圈、池子已经刷新过了。覆盖不留死角,是用初中几何保证的。

这个数也不是一次到位的,同一天的 commit 还留着调整痕迹:“Shrink walking sentinel radius to 200m for guaranteed pass-by scan coverage”。

其它几个关键常量一并交代:

  • 哨兵半径:步行 200m,驾驶 450m;
  • 围栏半径下限:步行 125m,驾驶 250m(注释原文:iOS 区域监测在车速 + 小半径下经常漏报,Apple 建议 ≥200m);
  • GPS 精度容差:100m,定位精度太差的样本不拿来做触发判定。

GPS 不站在你这边

以上都建立在「GPS 报的位置是对的」这个假设上,而这个假设在市中心不成立。

我对这件事印象最深的一幕在芝加哥。芝加哥 downtown 的高楼密度跟西雅图和 Bellevue 完全不是一回事,实测的时候 GPS 漂移严重到什么程度:人在芝加哥河这一边,定位能飘到河对岸去。高楼峡谷里,信号反射来反射去,你的「位置」在两岸之间跳舞。

旅行回来我跟 Codex 认真聊了这个问题,产出是 7 月 1 日的另一个 commit:

Always record pass-by; compensate GPS urban-canyon error

除了误差补偿,这个 commit 里还有一个我觉得更重要的决定:把「记录」和「提醒」解耦。提醒可以被频控拦下、被静音时段压掉、被去重规则跳过,但「你确实路过了这里」必须无条件写进本地日记。通知是可以错过的,擦肩不可以。毕竟整个产品的一句话定位是「记录我和城市擦肩而过的故事」,记录才是底线。

驾驶档:换一套参数

开车经过故事点算不算擦肩?算,但总不能在方向盘前给人弹故事。驾驶档的处理是换一套参数继续工作:

  • 速度判定带迟滞:车速约 ≥29 km/h 进入驾驶档,≤9 km/h 才退出,避免在红绿灯前反复横跳;
  • 围栏半径下限从 125m 抬到 250m,对抗高速下的漏报;
  • 注册池中心沿行进方向前移 min(1500m, 车速 × 60),也就是把「未来一分钟会到的地方」的故事点提前注册好,代码里就是一行经纬度投影;
  • 开车经过的点不实时打扰,先写进本地缓冲,等你停车回到步行档,合成一条汇总通知:「刚才有几则城事与你擦肩而过」。点开进日记,不进某个具体故事。

这些参数没有一个是模拟器里调出来的,全是我自己开车一趟趟试出来的:有的点就是不触发,有的点连着跳好几次,还有 GPS 偏移把点触发到隔壁街区的。上一篇提过我老公建议我用 GPS 模拟器测,这里补个后日谈:实测这活儿也只能我干,他连驾照都没有。

给承诺留座位

还有一个细节我也是打磨出来的。产品里有个功能叫「下次路过提醒我」:你在 App 里读到一个感兴趣的故事,标记一下,未来路过现场时会收到提醒。问题来了,围栏只有 19 个名额,都是按「离你最近」动态分配的,万一你标记的那个点一直被更近的点挤掉呢?

解法是给承诺留座位:池子里固定保留 4 个槽位(MAX_MARKED_REGION_SLOTS = 4)给用户手动标记的点,5 公里内优先注册,不参与普通点的距离竞争。算法上这不是最优分配,但「用户显式许过的愿必须兑现」的优先级,高于算法最优。

一个「不是 bug 的 bug」

7 月 3 日,git log 上一天出现了五个 commit,全在打同一场仗:

Harden pass-by state persistence / Preserve pass-by footprints across content reloads / Unify pass-by footprint recovery / Serialize startup state and geofence refresh / Harden state recovery against wiped storage

这场仗的起因,是一个根本不存在的 bug。

有个朋友去纽约玩,顺便试用听城,玩得挺开心。之后她过桥去了新泽西,回头一看,发现自己在纽约的擦肩记录「不见了」,赶紧跟我报 bug。我排查了一圈,发现其实不是 bug:App 里纽约和新泽西是两个城市包,而那个版本刚做了城市范围过滤,定位在哪座城,就只显示那座城的数据。她的记录都在,只是被 scope 藏起来了。

到这里本来是个虚惊一场的小故事。但我没忍住,顺手对这块「优化」了几轮,然后发现越修越过了:不知道哪一步,把她日志里的一部分记录真的弄丢了。为一个不存在的 bug 做修复,修出了一个真的 bug,还是丢用户数据这种最恶性的。我当时非常 depressed。

之后的善后分三步。先是和 Codex 一起研究「能不能从触发日志和其他冗余数据里把丢掉的记录恢复回来」,做了恢复机制;然后针对读写冲突加了一层防护,毕竟 iOS 后台唤醒给的执行窗口极短,geofence 事件完全可能赶在状态加载完成之前到达,内容 OTA 重载、存储被系统清理,每一个都可能吞掉记录;最后把这次事故制度化:写了一个新的 verify-store-resilience 校验脚本,十四个故障场景断言,凡是改到存储层、持久化格式、迁移或恢复逻辑的提交,必须全过才能走。

这件事给我留下三条教训。第一,用户报「数据丢了」,先分清是真丢了还是被藏起来了。第二,修一个不存在的 bug,比不修危险得多。第三,纯本地数据 App 没有服务器兜底,持久化防护就是你的全部容灾。

剩下的都是频控

触发链路的最后一环是「什么时候闭嘴」。所有规则都在本地跑:每天最多 6 条(可调 1 到 20);两条通知之间至少隔 10 分钟;同一个故事点 30 天内不重复提醒;同一天同一个五百米左右的区域最多 4 条;静音时段默认晚上十点到早上八点。权限也是渐进的:不给 Always 也能用,When-in-use 加粗粒度聚类照样能看附近有什么故事,不逼用户一上来就交出全部。

听城的触发日志调试面板,显示一次提醒成功和一次频控拦截

第一篇里那张触发日志也记录了这条链路:通知触发成功,下一条又被最小间隔拦下。

写到这里可以回收第二篇的一句话:「位置永远不离开手机」不是隐私页上的话术,是架构约束。这条触发链路不上传坐标或轨迹,系统围栏事件唤醒、触发判断和擦肩日记都在本机完成。

电量呢?诚实交代:哨兵架构改完之后体感稍微改善了一点,但我没有做量化对比,就是体感。这种事写出来不如一个百分比好看,但比编一个百分比可信。

接下来

geofence.ts 单文件 1677 行,是整个项目密度最高的一份代码,以上所有决定都躺在里面。

下一篇聊「故事从哪儿来」:350 个故事点和几百张插画怎么生产、怎么不发版就推到用户手机上,以及一个坐标标到机场跑道上的乌龙。