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。

相关推荐
skyey2 小时前
页面加载时,深色模式闪白的问题解决
前端
IT_陈寒2 小时前
Java 并行流把我坑惨了,这6小时加班值了
前端·人工智能·后端
anOnion11 小时前
构建无障碍组件之Menu Button pattern
前端·html·交互设计
用户479492835691512 小时前
claude Fable用不了?把Gpt 5.5pro接到你的claude code里
前端·后端
zhangxingchao14 小时前
Kotlin常用的Flow 操作符整理
前端
IT_陈寒16 小时前
React的useState居然还有这种坑?我差点删库跑路
前端·人工智能·后端
Pedantic17 小时前
SwiftUI 手势笔记
前端·后端
橙子家17 小时前
浏览器缓存之【结构化数据库与缓存】: IndexedDB、Cache storage 和 Storage buckets
前端
user205855615181318 小时前
X6 中边悬浮置顶,规避 `mouseleave` 事件丢失问题
前端