前言
作为一个喜欢 Citywalk 的独立开发者,我一直有个痛点:手机相册里存了上千张街拍、截图、路线图,按时间排列根本找不到。想回忆半年前在某座城市走过的某条街,翻相册二十分钟无果是常态。
市面上的运动记录 App 太「硬核跑步」,社交类足迹 App 又要上传数据、强制分享。我想要的很简单------把走过的路安静地记下来,需要时 3 秒找到。
于是我自己动手做了「雁过留痕」,一个面向日常散步/Citywalk 场景的轨迹记录工具。核心体验是:每走一公里点亮一个像素格,最终你的城市会变成一张像素风地图。本文聊聊这个项目的技术选型、架构思路和踩过的坑。
一、产品核心功能拆解
在开始写代码之前,先梳理需求:
- 像素地图渲染:将用户轨迹映射到网格坐标,每格代表约 1km²,走过即点亮
- 照片按地点自动归类:读取照片 EXIF GPS 信息,关联到对应网格
- 全本地存储:不上传任何数据,无社交功能,隐私优先
- 出片审美:地图截图需要好看到用户愿意分享到朋友圈
二、技术选型
| 模块 | 方案 | 理由 |
|---|---|---|
| 地图底层 | MapKit | 原生性能好,不需要额外 SDK 体积 |
| 像素渲染 | Core Graphics + Metal | CG 做静态绘制,Metal 处理大量网格动画 |
| 轨迹存储 | Core Data + 本地文件 | 纯本地方案,配合 iCloud 备份 |
| 照片索引 | Photos Framework | 读取 GPS 元数据,建立地理索引 |
| 定位服务 | Core Location | 后台持续定位 + 智能省电策略 |
为什么不用第三方地图 SDK?
考虑过高德和 Mapbox,但最终选择 MapKit 原因有三:
- 包体积敏感:独立开发 App 不想因为地图 SDK 多 30MB
- 隐私承诺:用户数据不经过第三方服务器更容易自圆其说
- 自定义渲染层:MapKit 的 MKOverlayRenderer 足够灵活,可以叠加像素网格
三、像素地图的实现思路
这是整个 App 最核心的视觉特性。思路如下:
swift
// 将经纬度映射到像素网格坐标
func coordinateToGrid(_ coordinate: CLLocationCoordinate2D, gridSize: Double) -> GridPoint {
let latIndex = Int(floor(coordinate.latitude / gridSize))
let lonIndex = Int(floor(coordinate.longitude / gridSize))
return GridPoint(x: lonIndex, y: latIndex)
}
gridSize 的选择直接影响体验。经过多轮测试:
- 0.005°(约 500m):太密,城市很快被点满,缺乏成就感
- 0.02°(约 2km):太稀疏,散步一小时可能只亮一格
- 0.01°(约 1km):刚好,一次 Citywalk 大概能点亮 3-8 格,节奏舒适
渲染层实现
swift
class PixelGridOverlayRenderer: MKOverlayRenderer {
var litGrids: Set<GridPoint> = []
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
for grid in litGrids {
let rect = gridToMapRect(grid)
if mapRect.intersects(rect) {
let drawRect = self.rect(for: rect)
context.setFillColor(gridColor(for: grid).cgColor)
context.fill(drawRect)
}
}
}
}
颜色策略上,我做了渐变:新点亮的格子颜色饱和度更高,时间越久越趋向柔和色调,这样整张地图既有层次又不刺眼。
四、照片按地点自动归类
利用 Photos Framework 读取用户相册中带 GPS 信息的照片:
swift
let fetchOptions = PHFetchOptions()
fetchOptions.predicate = NSPredicate(format: "location != nil")
let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions)
assets.enumerateObjects { asset, _, _ in
if let location = asset.location?.coordinate {
let grid = self.coordinateToGrid(location, gridSize: 0.01)
self.bindPhoto(asset, toGrid: grid)
}
}
这样用户搜索地名时,实际上是在搜索网格关联的地理编码信息,再展示该网格下的所有照片。比按日期找快了一个数量级。
五、本地存储与隐私设计
「不上传不社交」不是一句口号,而是架构层面的决策:
- 不集成任何第三方统计 SDK(没有 Firebase、没有友盟)
- 网络权限直接不申请(Info.plist 里不声明 NSAppTransportSecurity 例外)
- Core Data 存储轨迹数据,用户可随时在设置中导出或清除
这对独立开发者是减负:不用写后端、不用管服务器、不用处理 GDPR。
六、性能优化踩坑记录
问题 1:网格数量过多导致渲染卡顿
当用户累计走过 2000+ 格时,draw(_:zoomScale:in:) 每次被调用都遍历全量数据。
解决方案 :空间索引。用四叉树(Quadtree)存储已点亮的网格,draw 时只查询当前可见区域内的格子:
swift
let visibleGrids = quadtree.query(region: mapRect.toBoundingBox())
帧率从 30fps 以下拉回 60fps。
问题 2:后台定位功耗
长时间后台定位是 iOS 的电量杀手。采用分段策略:
- 用户在移动中:
kCLLocationAccuracyHundredMeters,10 秒一次 - 用户静止超过 2 分钟:切换到 significant location change monitoring
- App 进入前台时:补一次精确定位修正
实测日常使用一天增加约 3-5% 电量消耗,可以接受。
七、出片审美:让截图值得分享
独立 App 没有推广预算,用户自发分享是最好的传播。所以「地图截图好不好看」是产品级需求。
几个设计决策:
- 像素格带圆角 + 微阴影,避免纯色方块的廉价感
- 背景地图调为低饱和灰度,让像素格成为视觉焦点
- 提供深色/浅色两套配色,截图适配不同社交平台背景
- 分享图自动加上「已探索 XX 格 / XX km²」的数据水印
八、2.4 版本现状与后续规划
当前 2.4 版本功能基本稳定,已经满足了我自己的核心需求:3 秒内找到任意一次出行记录。
后续想做的:
- 时间轴回放:把一年的轨迹做成动画
- Widget 小组件:桌面展示当前城市探索进度
- 多城市切换视图
写在最后
独立开发最有意思的地方是------你就是自己的第一个用户。我做「雁过留痕」的初衷纯粹是解决自己相册混乱的问题,像素地图的成就感是意外收获。
如果你也在做 MapKit 相关的项目,或者对像素风格地图渲染有更好的方案,欢迎评论区交流。
本文所述为个人独立开发项目的技术实践记录,App 名称「雁过留痕」,当前版本 2.4。