先说我做了什么
这个 App 做的事情说白了很简单:后台 GPS 静默记录你走过的路,然后把轨迹转成一个数字——你这辈子真正踩过的土地面积,单位 km²。
不是打卡了几个地点,不是走了多少步,是面积。25米精度的网格,一格一格叠起来。
我发现,当你 citywalk 结束后打开 App,看到「今天新开垦了 0.3 km²」,那种感觉和「走了 8000 步」完全不一样。面积有一种领土感,有一种真实的积累感——你这块地方是真的踩过去了,不是飘过去的。
App 叫「雁过留痕」,大雁飞过,在天空留下痕迹。你走过,在地图上留下面积。文末有下载方式。
成就系统是这个 App 的灵魂,但建模挺麻烦的
游戏化是核心玩法之一。用户每次出行都可能解锁新省份、新城市、新徽章、新等级。
成就系统一旦做复杂,数据结构就很容易变成一坨。我试了好几个方案,最后的思路是:把「判断成就需要的所有用户状态」单独抽成一个纯值类型 BadgeMetrics,所有成就计算都只从这一个快照做判断,不再散落地查数据库。
BadgeMetrics 目前有 19 个字段,大致分四类:行为统计(总距离、总时长、记录天数、连续打卡天数)、地理覆盖(中国省市数量、中国面积、全球面积、城市解锁数)、出行特征(夜间记录次数、周末记录天数、单次最长距离)、会员状态(是否激活 Pro、已激活天数)。字段多是因为成就系列跨度大,从「走了100km」到「连续打卡30天」到「解锁全部直辖市」,驱动维度差异很大,合并不了。
字段全貌大概是这样:
struct BadgeMetrics {
let totalDistanceKilometers: Double
let totalDurationHours: Double
let totalPoints: Int
let recordedDays: Int
let segmentCount: Int
let currentStreakDays: Int
let longestStreakDays: Int
let nightSegmentCount: Int
let weekendRecordDays: Int
let longestSegmentKilometers: Double
let recordingMonths: Int
let chinaProvinceCount: Int
let chinaMacroRegionCount: Int
let chinaAreaKm2: Double
let unlockedCountryCount: Int
let cityUnlockCount: Int
let globalAreaKm2: Double
let proMembershipActive: Bool
let proMembershipDays: Int
}
```
`build` 方法从三个持久层对象组装:`TraceStats`(行为统计聚合)、`[TraceSegment]`(分段轨迹列表)、`GeographicProfile`(地理覆盖状态)。调用时机是每次用户进入成就页面时同步执行一次,实测耗时在 0.5ms 以内,不需要缓存或异步。
这个分层的好处是成就判断逻辑全部变成纯函数——给定同样的 `BadgeMetrics`,判断结果永远一致,单测直接传一个 struct 进去就行,不需要 mock 数据库。后来加新成就系列,只需要在 `BadgeSeriesCatalog` 里注册一条记录,UI 层完全不动。
成就徽章分了五个 Track,用枚举管理:
```swift
enum BadgeTrack: String, CaseIterable {
case exploration // 累计探索面积/距离
case consistency // 连续打卡/月度记录
case china // 中国省市解锁
case world // 全球探索
case pro // Pro 会员专属
}
```
每个 Track 下有多个系列(Series),每个系列有多个等级(Tier)。`BadgeSeriesProgress` 负责计算当前到了哪一级、离下一级还差多少,UI 层只消费这个 progress 对象,不碰底层阈值逻辑。
---
## 中国坐标系这件事,绕不过去
做中国地图类 App 有个绕不过去的问题:GCJ-02(火星坐标)和 WGS-84(GPS 原始坐标)的偏移。
GPS 收到的是 WGS-84,国内地图服务普遍用 GCJ-02,不处理的话轨迹叠加到地图上会明显错位,省市边界判断也会出问题。我用的是 [eviltransform](https://github.com/googollee/eviltransform) 的算法逻辑自行移植到 Swift,在上海市区实测偏差约 3-5 米,基本可以接受。但边界附近的判断仍然存在问题——偏差不大,却刚好可能跨过省界。内置行政区边界数据做 point-in-polygon 判断时,如果用户走在省界附近几十米的地方,解锁结果有时候不太稳定。现在还没找到特别好的办法,这个坑算是留着的。
偶尔有用户反馈某个省明明去过但没解锁,基本上都是这个原因。说实话有点难受,但短期内边界精度问题很难彻底解决。
---
## 桌面 Widget:小细节但用户挺喜欢
做了一个 Widget,实时展示当日探索面积进度和离下一等级还差多少。用户开着 Widget 跑步时能看到数字慢慢涨,这个反馈还挺有意思的。
`WidgetKit` 的时间线刷新在低电量模式下会降得很厉害,靠系统调度根本不够用。我现在的方案是:用户结束出行回到 App 主界面时,在 `onAppear` 里主动调用 `WidgetCenter.shared.reloadAllTimelines()`,实测从 App 切回桌面大概 2-3 秒内 Widget 就能更新。另外在轨迹保存完成的回调里也触发一次 reload,确保数据落库之后 Widget 拿到的是最新值,不会出现「明明刚走完但 Widget 还是旧数据」的情况。
---
## 数据备份:GPX 导出和自定义格式
用户的轨迹数据是很私人的东西,我不想让它只锁在服务器上。支持两种导出格式:
**GPX** 是通用格式,可以导入 Strava、Garmin Connect 等 App。**Wingprint Backup** 是自定义的 `.wingprintbackup` 格式,本质是 JSON,注册了 `com.xun.wingprint.backup` 这个 UTType,可以完整恢复成就进度、统计数据、地理标注等 GPX 里没有的字段。
注册自定义 UTType 有个容易踩的坑:Info.plist 里要区分 `Exported Type Identifiers` 和 `Imported Type Identifiers`,自己定义的格式用 Exported,引入别人格式(比如 GPX)用 Imported。搞混了的话 Files App 里点击文件会打不开,排查起来有点费劲,我当时找了半天。
---
## 现在的状态
坦白说,下载量还在冷启动阶段,刚上线不久。评分是 5.0,两条评论,有一条写「人生总要留下一些什么——走过的路,见过的人,看过的风景」,我看到的时候挺触动的,感觉做这个东西的方向没走偏。
产品的核心链路已经跑通:GPS 记录 → 网格化面积计算 → 行政区解锁 → 成就徽章 → 社交分享,这条链路是通的,剩下的是推广和口碑积累的问题。
---
## 几个可以直接参考的点
如果你在做类似方向的 iOS App,这些坑我已经踩过了:
成就系统的数据建模,把「用户当前状态快照」抽成一个纯值 struct,所有判断逻辑做成纯函数,测试和维护都省事很多。字段多不是问题,分类清楚就行。
中国地图类 App,坐标系偏移一定要在数据入库前处理,别留到展示层。eviltransform 算法移植到 Swift 不难,市区精度够用,但省界附近的判断要做好心理准备。
自定义文件格式,注册 UTType 区分 Exported 和 Imported,别踩我踩过的坑。
Widget 刷新,别指望系统时间线足够及时。关键节点——用户回到 App、数据落库完成——这两个时机主动 `reloadAllTimelines()`,基本能保证 2-3 秒内同步。
---
App Store 搜「雁过留痕」可以找到(ID: 6759516186)。如果你也在做鸿蒙或 iOS 的地图、运动、游戏化方向的 App,欢迎交流,遇到的奇怪问题大概率我也遇到过。
635



被折叠的 条评论
为什么被折叠?



