写在最前
最近我把自己做的一款 iOS App 雁过留痕(WingPrint) 上架到了 App Store。
一开始我以为,做这类产品最难的会是地图渲染、轨迹样式、统计展示这些"看得见"的部分。真正做进去以后才发现,最难的其实是另一件事:
你能不能在 iPhone 上长期、稳定、相对省电地把轨迹录下来。
因为只要录制不稳,后面所有东西都会一起失效:统计会失真,成长反馈会变空,分享内容会不好看,用户也不会再信任你的产品。
我在测试里反复遇到的,往往不是"功能没做出来",而是这些更烦的边界场景:地库出来后轨迹突然飘出一根长线,锁屏回来后记录间隔明显变长,弱信号时速度瞬间跳高,或者明明人没动却还在持续采样。真正把这些问题磨平,比把首页做漂亮难多了。
这类 App 真正会把人拖进泥潭的,往往不是首页 UI,而是这些边界问题:
- 后台定位会不会断
- 弱信号下会不会漂
- 锁屏、回前台、异常退出后能不能恢复
- 功耗和发热会不会劝退用户
- 同步和恢复会不会把本机数据搞坏
所以我后来做雁过留痕时,越来越明确一件事:
这不是一个"把轨迹画在地图上"的项目,而是一个"在真实世界里长期成立"的项目。
这篇文章不打算写成产品宣传稿,我想认真复盘一下:一个本地优先的 iOS 足迹 App,从 CoreLocation、CoreMotion、iCloud 到 StoreKit 2,到底踩了哪些坑,我最后又是怎么取舍的。

我想解决的,不是导航,而是"我曾来过"
雁过留痕的核心设计不是导航,而是下面这个循环:
记录 -> 反馈 -> 分享 -> 持续记录
它现在做的事并不复杂:记录轨迹、沉淀里程和覆盖范围、给出等级和解锁反馈、支持分享,也支持导入、导出、备份、恢复、回滚。
这里面最重要的一点是:
轨迹数据默认保存在本机,不依赖自建后端做主存储。
这也是后面所有技术选择的前提。
为什么我坚持做 Local-first
轨迹数据和普通内容数据不太一样,它天然更敏感,也更容易让用户焦虑:
- 会不会一直上传到开发者服务器?
- 会不会哪天同步出错把本机数据覆盖掉?
- 换手机以后还能不能恢复?
- 误导入、误恢复以后还能不能撤回?
所以我一开始就定了两个原则:
- 核心轨迹数据以本机为准
- 所有恢复类动作都尽量可回退
这直接影响了后面的存储设计、云同步策略和 UI 提示方式。

真正难的,是录制稳定性
很多人看到轨迹类产品,第一反应是地图渲染、轨迹样式、热力图这些视觉问题。
但真的做进去之后会发现,最难的根本不是"画出来",而是"稳定地录下来"。
我踩到的核心问题主要有这几个:
- 后台记录容易受系统调度影响
- 弱信号场景下会出现漂移、断轨、异常速度
- 长时间录制很容易和功耗、发热打架
- 用户手动强杀、关闭精确定位、拒绝运动权限,都会影响真实表现
也就是说,这类 App 的核心难题从来不是"能不能拿到位置",而是:
怎么在"连续性、可信度、功耗、系统约束"之间找到一个能长期工作的平衡点。
我现在的录制主链路
当前这套链路,核心是三层:
CoreLocationService -> TraceTracker -> LocalTraceStore
它们的分工大致是:
CoreLocationService负责系统定位、Motion 状态、动态采样策略、前后台联动TraceTracker负责位置点验收、续录意图、看门狗恢复、轨迹分段和诊断LocalTraceStore负责本地持久化、统计缓存、导入导出、替换/合并恢复
为了让这条链路在生命周期里尽量不断,我在 App 入口就把关键动作都挂上了:
swift
.onAppear {
ProSubscriptionManager.shared.startIfNeeded()
container.triggerAutoSync(reason: .appActive)
container.handleAppDidBecomeActive()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
await ProSubscriptionManager.shared.refreshFromStore()
container.triggerAutoSync(reason: .appActive)
container.handleAppDidBecomeActive()
}
if phase == .background {
container.persistCriticalData()
container.triggerAutoSync(reason: .appBackground)
container.handleAppDidEnterBackground()
}
}
这段代码不复杂,但它背后其实是在处理几个真实问题:录制状态别丢、订阅状态要刷新、自动同步要按生命周期触发。
CoreMotion 为什么值得接进来
我最近一个比较大的迭代,就是把 CoreMotion 引进来参与采样判断。原因很简单,单靠位置速度去猜"用户是在静止、步行还是车行",滞后和误判都会比较明显。
有了运动状态之后,可以更合理地做这些事情:
- 静止时更积极降载
- 步行时保持比较细的更新
- 车行时避免过度省电导致漏轨
这件事本质上不是"加一个炫酷能力",而是让动态采样终于有了一个更像现实世界的输入信号。
存储层我为什么暂时没上 SQLite
存储层我目前没有直接上 SQLite,而是先采用了文件型持久化。
现在的结构比较直接:
segments.json维护轨迹段清单- 每个 segment 的轨迹点独立写入 points 文件
对应代码大概是这样:
swift
let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let wingprintDir = root.appendingPathComponent("Wingprint", isDirectory: true)
let storageDir = wingprintDir.appendingPathComponent("TraceStoreV2", isDirectory: true)
let pointsDir = storageDir.appendingPathComponent("points", isDirectory: true)
pointsDirectoryURL = pointsDir
segmentsFileURL = storageDir.appendingPathComponent("segments.json")
这样做的好处是很现实的:
- 可读性强,导入导出和恢复都比较直接
- 对当前阶段的产品复杂度够用
- 做备份、镜像、替换恢复时更容易控制
代价也很明确:后期数据量继续变大后,存储层迟早要升级,一些复杂查询也不如数据库自然。我现在先接受这个取舍,因为当前阶段最贵的不是复杂查询能力,而是恢复链路的可控性和整体稳定性。所以我已经把 SQLite / GRDB 迁移放到了后续版本,再做统一评估,而不是和当前的定位稳定性优化同时推进。
轨迹产品不只要"能记录",还得"能修正"
我后来越来越强烈地意识到,轨迹记录产品不能只做"记录",还得做"修正"。
因为现实里总会遇到这些情况:
- 地库或者高架边缘定位漂了一段
- 弱信号下轨迹突然拐出一条很奇怪的线
- 某段历史轨迹就是想删掉
如果产品只能"全盘接受",那它就不是可信的个人档案,只是系统噪声的堆积。
所以我后来补了一整套轨迹修正能力:支持全历史或当前段范围切换,支持细/中/粗笔刷,支持擦除预览、撤销、保存,保存后还会对邻近断点做自动重连,减少轨迹碎片化。
这里我没有上很重的轨迹算法,而是先用了一个保守启发式:只有当相邻两组点的时间间隔不超过 360 秒、空间距离不超过 300 米时,才尝试把它们重新接起来。对应代码就是这种判断:
swift
let shouldReconnect =
timeGap >= 0 &&
timeGap <= editingReconnectMaxGapSeconds &&
distanceGap <= editingReconnectMaxGapMeters
这部分做完以后,我对产品的判断变得更明确了:
真实可用的轨迹产品,不只是"持续记录",还必须允许用户修正。

云同步这件事,我没有把它做成"强同步覆盖"
另一个花时间很多的模块是云备份和恢复。这部分最容易出事故的,不是"同步失败",而是"同步成功但把用户本地数据弄坏了"。
所以我在设计上尽量避免那种非常激进的同步语义,而是更偏向:可导出、可导入、可恢复、可回滚。
现在这套云能力里,我比较在意的几个点是:
- iCloud 容器可用性检测
- 自动同步策略支持 Wi-Fi、充电、低电量条件
- 导入前做预检
- 恢复前创建安全快照
- 出问题时可以撤销恢复
- iCloud 容器不可用时,支持本地镜像回退
这类设计不会让产品显得"很智能",但会让用户更安心。而对轨迹类产品来说,安心比"炫技同步"重要得多。
商业化我为什么放得比较克制
雁过留痕现在已经接入了 StoreKit 2,支持月订阅、年订阅和恢复购买。
但我在权益设计上尽量避免一个问题:免费版什么都不能用,逼着用户先付钱。
我更倾向于让免费版先把核心价值跑通:能记录、能看统计、能做基础备份恢复;然后把 Pro 放在更明确的增值点上,比如稳定性增强、自动同步、个性化轨迹样式和图标、更完整的自动化体验。
订阅链路本身并不复杂,核心就是 Product.products、purchase() 和 AppStore.sync():
swift
func purchase(planID: String) async throws -> PurchaseOutcome {
guard let product = productsByID[planID] else {
throw SubscriptionError.productNotFound
}
let result = try await product.purchase()
switch result {
case let .success(verification):
let transaction = try verify(verification)
await transaction.finish()
await refreshEntitlements()
return .purchased
case .pending:
return .pending
case .userCancelled:
return .cancelled
@unknown default:
return .cancelled
}
}
真正麻烦的不是 API,而是你怎么设计免费版和 Pro 的边界,既别过度阉割,也别把增值点做虚。

我没有想到,Game Center 会比预期更适合这个产品
最开始我把排行榜和成就当作一个"也许可以有"的增强项。但实际做进去以后发现,它跟足迹记录是相容的。
因为足迹记录天然有长期积累属性,而长期积累天然适合做轻量成长反馈。所以我后来给它接了排行榜、成就、名次变化反馈、突破提示和战绩分享卡。
它不是这个产品的核心,但它确实让"长期记录"这件事更有反馈感。
到现在为止,我最深的体会是什么
如果只看功能列表,一个轨迹记录 App 好像无非就是:
"定位 + 地图 + 统计 + 备份 + 订阅"
但真正做下来,我最大的体会是:
这类产品的难点几乎都藏在边界里。
比如后台能不能稳、弱信号怎么处理、功耗怎么控制、错误数据怎么修、恢复动作怎么回滚、订阅怎么接得克制但清晰。
这些问题单独看都不算新鲜,但放在同一个消费级 App 里,你必须一边做功能,一边做取舍,一边做边界管理。
对我来说,雁过留痕到现在还远没有"完成",但至少已经从"想法"变成了一个真正能跑起来、能上架、也能持续迭代的产品。
后面我还会继续做什么
接下来我会继续把重心放在两件事上:
- 录制稳定性和功耗的继续平衡
- 存储层和长期数据能力的后续演进
前者决定这个产品是不是"可信",后者决定它能不能真正承载长期积累。
如果你也做过定位、后台、同步、恢复这类偏边界型功能,应该会很容易理解这种感觉:真正难的从来不是把功能做出来,而是让它在真实世界里长期成立。
最后补一张上架图,欢迎各位体验交流,福利好说,哈哈~
