iOS 后台 GPS 追踪器创建指南:技术解决方案与常见陷阱
2024 年 Google Timeline 关闭后,开发者们失去了一个可靠的路线分析工具。与其依赖云端方案,我们现在转向构建本地应用,来精确追踪油耗。本文将逐步介绍在 SwiftUI 中实现 GPS 追踪器,包括处理噪声数据、后台优化,以及通过 App Store 审核的经验。
CoreLocation 中后台追踪基础
关键挑战是在应用进入后台时获取坐标,同时不耗尽电池。所需配置如下:
- 在 CLLocationManager 中启用
allowsBackgroundLocationUpdates - 添加包含位置类型的能力(Background Modes entitlement)
- 在 Info.plist 中通过
NSLocationAlwaysAndWhenInUseUsageDescription提供使用理由
初始实现记录每个点,导致数据量过大(每次行程数千点)。通过组合以下参数进行优化:
desiredAccuracy = kCLLocationAccuracyBest— 优先使用 GPS 而非 Wi-Fi/蜂窝塔distanceFilter = 10— 忽略小于 10 米的移动- 过滤
horizontalAccuracy > 50— 剔除噪声数据
这些设置将每次行程数据量减少到 200–500 点。60+ 条路线后,数据库占用约 15 MB,对于 CoreData 来说完全可接受。
无 IMU 的卡尔曼滤波:高速公路追踪的数学方法
在 GPS 干扰路段(如 Krasnodar–Gelendzhik),经典的传感器方案不可用——后台下 CMMotionManager 被阻塞。我们不得不调整卡尔曼滤波,仅使用坐标工作:
func processLocation(_ location: CLLocation) -> CLLocation {
guard let previous = lastFilteredLocation else {
lastFilteredLocation = location
return location
}
let dt = location.timestamp.timeIntervalSince(previous.timestamp)
let processNoise = dt * speedVariance
predictedVariance += processNoise
let measurementVariance = location.horizontalAccuracy * location.horizontalAccuracy
let kalmanGain = predictedVariance / (predictedVariance + measurementVariance)
let lat = previous.coordinate.latitude
+ kalmanGain * (location.coordinate.latitude - previous.coordinate.latitude)
let lon = previous.coordinate.longitude
+ kalmanGain * (location.coordinate.longitude - previous.coordinate.longitude)
predictedVariance = (1 - kalmanGain) * predictedVariance
return CLLocation(
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon),
altitude: location.altitude,
horizontalAccuracy: sqrt(predictedVariance),
verticalAccuracy: location.verticalAccuracy,
timestamp: location.timestamp
)
}
该算法动态调整对测量的信任度:当 horizontalAccuracy 高(GPS 信号差)时,kalmanGain 降低,系统更多依赖先前数据。对于超过 10 秒的信号丢失,我们使用三次样条进行后处理。
SwiftUI 中按速度可视化路线
iOS 17+ 提供 MapPolyline,但每个对象仅支持一种颜色。为了实现动态速度着色,我们需要:
- 将轨迹拆分为速度范围均匀的片段
- 将每个片段渲染为独立的 MapPolyline
- 相邻片段重叠一个点
struct SpeedSegment {
let coordinates: [CLLocationCoordinate2D]
let color: Color
}
func buildSegments(from points: [LocationPoint]) -> [SpeedSegment] {
var segments: [SpeedSegment] = []
var currentCoords: [CLLocationCoordinate2D] = []
var currentColor: Color = .green
for point in points {
let color = speedColor(for: point.speed)
if color != currentColor && !currentCoords.isEmpty {
segments.append(SpeedSegment(
coordinates: currentCoords,
color: currentColor
))
currentCoords = [currentCoords.last!]
}
currentCoords.append(point.coordinate)
currentColor = color
}
if !currentCoords.isEmpty {
segments.append(SpeedSegment(
coordinates: currentCoords,
color: currentColor
))
}
return segments
}
系统使用 4 种颜色范围:
- 绿色: < 50 km/h(城市)
- 黄色:50–90 km/h(高速公路)
- 橙色:90–110 km/h(快速路)
- 红色:> 110 km/h
典型路线生成 20–50 条折线。500+ 点时,老设备可能有轻微延迟,但大多数场景性能良好。
Geohash 战争迷雾:地理空间优化
为了实现“战争迷雾”效果(隐藏未走过的路段),我们选择了 geohash 而非 quadtree 或直接点存储。优势:
- 通过前缀匹配快速查找
- 简单序列化到 CoreData
- 通过精度参数灵活调整
| 精度 | 单元格大小 | 应用场景 |
|------|------------------|--------------|
| 5 | ~4.9 × 4.9 km | 国家概览 |
| 6 | ~1.2 × 0.6 km | 城市概览 |
| 7 | ~150 × 150 m | 详细视图 |
实现包括:
- 根据 MKCoordinateSpan 动态切换精度
- 使用 Set<String> 检查可见单元格
- 0.7s easeOut 透明度动画消散
当前限制:矩形单元格边界。下步计划:边缘模糊的渐变渲染,更自然的外观。
关键要点
- 输入过滤至关重要:无 distanceFilter 和 horizontalAccuracy 检查,数据库体积膨胀 5–10 倍
- Live Activity 限制严格:iOS 最多允许 1 Hz 更新
- Geohash 优于 quadtree:移动场景下序列化更简单
- 无 IMU 的卡尔曼滤波 可处理长达 10 秒的信号丢失
- MapPolyline 需要分段 以实现动态着色——一条路线 = 20–50 个对象
Live Activity:对抗人为错误
问题:用户忘记停止记录,导致电池耗尽。解决方案:通过 ActivityKit 在锁屏显示指示器:
// 启动活动
let activity = try Activity.request(
attributes: TripActivityAttributes(carName: "Polo Sedan"),
content: .init(state: initialState)
)
// 每秒更新
await activity.update(.init(state: updatedState))
关键:更新频率上限约 1 Hz。更频繁发送会被系统忽略。这对速度表足够,但高频指标需数据聚合。
CoreData 中的数据存储
数据库模式针对路线和点快速访问进行了优化:
TripEntity (1) ──── (*) LocationPointEntity
├── startDate
├── distance
└── fuelCost
LocationPointEntity
├── latitude
├── longitude
└── speed
ExploredCellEntity
└── geohash (unique)
实现细节:
- geohash 单元格使用 NSMergeByPropertyObjectTrumpMergePolicy
- 通过时间戳自动删除旧记录
- tripID 和时间戳索引以加速查询
每条路线 500+ 点时,最小化 fetch 请求至关重要。所有 LocationPointEntity 操作在单一托管对象上下文中运行,并使用批量更新。
— Editorial Team
暂无评论。