每次GPS回调要判定64个格子的状态,还不能卡
这是我做「像素征途」时最头疼的问题。
玩法很简单:你在现实中走路,GPS轨迹实时映射到地图的像素格上,走过的格子就被点亮,变成你的领地。整张地图被切成 8×8 = 64 格的区域(Zone),每个格子对应现实中大约 8-10 米见方的区域。走满一定数量就算"征服"这个区域。
听起来不复杂,但 CoreLocation 的回调频率在步行场景下大概每秒 1-2 次,每次回调都得算当前坐标落在哪个格子、这个格子所在区域的征服进度有没有变化。如果每次都遍历整个区域的 64 个格子,密集区域直接卡成幻灯片——我第一版就是这么干的,在测试机上肉眼可见地掉帧。
征服判定:阈值简单,增量更新才是关键
征服规则本身很直白:一个 8×8 区域里点亮 58 格算「征服」,64 格全亮算「完美征服」。58 这个数字调了很久,太低没挑战,太高让人绝望——毕竟现实中有些角落真的走不到(围墙、河道、施工区),留 6 格的容错基本刚好。
判定函数就是个阈值比较,没什么花头:
staticfuncevaluate(litTiles:Int,conqueredThreshold:Int,// 58perfectThreshold:Int// 64)->ZoneConquestEvaluation{returnZoneConquestEvaluation(isConquered:litTiles>=conqueredThreshold,isPerfect:litTiles>=perfectThreshold)}``` 真正要解决的是 `litTiles` 这个数怎么高效维护。 我的做法是给每个Zone维护一个 `Set<String>`,存的是已点亮格子的 tileKey。GPS回调进来时,先算坐标对应的 tileKey,查一下这个 key 是不是已经在Set里——如果已经存在,什么都不做,这次回调的成本就是一次哈希查找,O(1)。只有当 tileKey 是新的,才插入Set、更新 `litTiles` 计数、触发征服判定。 说白了就是增量更新:绝大多数GPS回调(你在同一个格子里走动)的计算量几乎为零,只有跨格子的那一瞬间才触发真正的逻辑。改成这个方案之后,密集区域的卡顿彻底消失了。 ## 连击系统的状态设计 光占领不够,还得让人每天都想出门。我加了一套连击倍率:连续走3天奖励 ×1.5,5天以上 ×2.0,中间允许断1天(graceDays=1)不清零连击。 为什么是1天而不是2天?我统计了TestFlight阶段的数据,断了2天以上的用户有大概70%后面就不再打开了,基本可以认为已经流失。给1天容错是照顾"周末宅了一天、周一继续"这种正常节奏,再多就不值得保护了。 核心状态就三个字段:`consecutiveDays`、`lastActiveDate`、`dailyEffectiveContribution`。每天第一次GPS回调时,拿当前日期和 `lastActiveDate` 做差:-差1天:`consecutiveDays+=1`,正常连击--差2天(在 graceDays 范围内):连击不断,但当天不算连击增长--差3天以上:连击归零 倍率计算很简单,连续5天以上给2.0,3-4天给1.5,其余1.0。另外每天有个贡献上限50,防止有人开车刷格子——TestFlight阶段真有人试过,一天刷了300多个格子,数据直接炸了。加了 cap 之后这种情况就不存在了。 ## 热力衰减:渲染时实时计算,不做后台遍历 这是我个人最喜欢的一个设计。走过的路线不是永远高亮,而是随时间衰减。具体的衰减阶梯和对应透明度:|时间范围|视觉表现|透明度||---------|---------|-------||0-4天|路线发光最亮|100%||5-7天|光晕开始消退|~65%||8-14天|区域强调消失|~35%||15-30天|逐步淡出|35%→12%线性过渡||30天以上|微弱残留|12%固定|实现上我纠结过两个方案。一开始想做后台定时任务,每天凌晨批量遍历所有格子、更新透明度值。但算了一下,一个活跃用户可能有几千上万个已点亮格子,每天全量遍历一次写入太重了,而且用户不打开App的话这些计算全浪费。 最后的方案是:每个格子只存 `lastVisitDate`,衰减值在渲染时实时算。地图可见区域内的格子,拿当前时间减去 `lastVisitDate` 得到天数差,按上面的阶梯映射到透明度。可见区域外的格子根本不算。这样计算量完全跟屏幕上可见的格子数挂钩,跟总数据量无关。 灵感来自GitHub的贡献热力图——绿格子越多越有成就感,但一段时间不提交热度就冷下去。放在这个App里,长时间不去的区域会慢慢暗下来,有一种"领地在流失"的紧迫感,会驱动你去"巡视"老地盘。 ## 格子循环系统(TileLoop):做了三版 后来我觉得格子只有"亮/灭"两个状态太单薄,就加了格子循环系统:每个格子有自己的等级(level)、路线阶层(roadTier)、访问次数、冷却时间。反复经过同一个格子会提升等级,高等级格子每天能产出碎片奖励。 这里说一下 roadTier——它表示格子所处的道路类型,主干道、支路、小巷分别对应不同的阶层。主干道上的格子因为通行频率天然更高,升级更快、产出更多;而小巷里的格子虽然升级慢,但有稀有度加成。这个设定是为了鼓励用户既走大路也钻小巷,不然所有人都沿着主干道来回刷就没意思了。 这套东西做了三版,前两版都废了。 第一版太简单:就一个 visitCount,每次经过+1,到了某个数升级。问题是用户感受不到"升级有什么用"。 第二版加了冷却时间(cooldownSeconds)、每日产出上限(todayYieldClaimCap)、还有重生机制(respawn)。规则太多,我让三个朋友试用,没有一个人搞懂"重生收集"是什么意思。 第三版砍掉了大部分用户不需要理解的字段,只在界面上暴露三个信息:当前等级、下次产出还需要几次访问、今天已经领了多少碎片。底层数据结构还是保留了完整的状态,但用户看到的是简化后的提示文本。 经验教训就是:底层可以复杂,但暴露给用户的信息一定要克制。我试过把所有字段都展示在UI上,结果用户觉得"这是什么表格软件"。 ##CoreLocation后台定位的一个坑:精度漂移 做LBSApp绕不开后台定位。这里分享一个折腾了我挺久的问题:用户静止不动时,CoreLocation偶尔会吐出漂移坐标,偏差几十甚至上百米。如果不处理,用户放着手机不动,地图上会莫名其妙地多出几个点亮的格子。 我的处理方式比较粗暴但有效:连续两次回调的坐标距离如果小于8米(前面提到每个格子大约8-10米见方,8米差不多是一个格子的边长),就判定为静止状态,直接丢弃。如果距离大于阈值但速度为0(`location.speed<=0`),也丢弃——这种大概率是漂移。 这个方案不完美,偶尔会漏掉用户很慢速移动的情况。但在"误判静止"和"误判移动"之间,我选择宁可少记几个格子,也不要让用户看到鬼打墙式的假轨迹。 ## 接下来的计划 目前App刚上架不久,有用户希望能导入更早年份的照片位置数据(现在只支持最近3年),这个排在下个版本。探索排行榜也有 bug 在修。 下篇准备写GPS轨迹平滑的三种方案对比(卡尔曼滤波、滑动窗口均值、贝塞尔插值),在像素征途里实测下来效果差异挺大的。你们在做LBS类应用时GPS漂移是怎么处理的?评论区聊聊,特别想知道有没有人试过纯加速度计辅助修正的方案。