iOS 独立开发实践:用 MapKit + 像素渲染实现 Citywalk 轨迹地图 App「雁过留痕」

前言

作为一个喜欢 Citywalk 的独立开发者,我一直有个痛点:手机相册里存了上千张街拍、截图、路线图,按时间排列根本找不到。想回忆半年前在某座城市走过的某条街,翻相册二十分钟无果是常态。

市面上的运动记录 App 太「硬核跑步」,社交类足迹 App 又要上传数据、强制分享。我想要的很简单------把走过的路安静地记下来,需要时 3 秒找到

于是我自己动手做了「雁过留痕」,一个面向日常散步/Citywalk 场景的轨迹记录工具。核心体验是:每走一公里点亮一个像素格,最终你的城市会变成一张像素风地图。本文聊聊这个项目的技术选型、架构思路和踩过的坑。

一、产品核心功能拆解

在开始写代码之前,先梳理需求:

  1. 像素地图渲染:将用户轨迹映射到网格坐标,每格代表约 1km²,走过即点亮
  2. 照片按地点自动归类:读取照片 EXIF GPS 信息,关联到对应网格
  3. 全本地存储:不上传任何数据,无社交功能,隐私优先
  4. 出片审美:地图截图需要好看到用户愿意分享到朋友圈

二、技术选型

模块 方案 理由
地图底层 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。

相关推荐
To_OC2 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic3 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy3 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志3 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble4 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯5 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜6 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司6 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
江华森7 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http
IT_陈寒7 小时前
Redis的KEYS命令把我搞崩溃了,改用SCAN才活过来
前端·人工智能·后端