Фильтр Калмана, geohash fog of war и три реджекта Apple: как я собрал GPS-трекер на SwiftUI

от автора

В 2024 году Google тихо убил Timeline в Google Maps. Историю местоположений перевели в «локальный режим», который на практике работает через раз – данные теряются, синхронизации нет, а у многих просто исчезли годы накопленной истории.

Для меня это было важно: я много езжу на машине и хотел знать простую вещь – сколько конкретная поездка стоит в бензине. Не средний расход за месяц по бортовику, а вот этот маршрут – сколько литров, сколько рублей. Ничего подходящего не нашёл и решил написать сам.

Контекст: я бэкенд-разработчик, до этого проекта не написал ни строчки на Swift. iOS для меня был чёрным ящиком – CoreLocation, MapKit, жизненный цикл приложения, фоновые режимы, App Store Review. Идея жила в голове три года, но подступиться не мог: казалось, что одному, без мобильного опыта, это неподъёмно. Сдвинулось всё, когда я начал работать с AI-агентами (Claude + Cursor) – но об этом ближе к концу.

Под катом (дальше) – про фоновый GPS-трекинг на iOS, фильтр Калмана без IMU, fog of war на geohash, кастомный рендеринг маршрутов, грабли App Store Review и про то, как бэкендер собрал iOS-приложение за месяц вечерами после работы.

маршрут на карте, окрашенный по скорости

маршрут на карте, окрашенный по скорости

Задача и ограничения

Нужен GPS-трекер для автомобильных поездок: нажал старт, убрал телефон, поехал. Приложение пишет трек в фоне, после поездки показывает маршрут, считает расход и стоимость. Всё офлайн, без серверов.

Ограничения:

  • iOS-only – один разработчик, ограниченное время

  • Нулевой опыт в iOS – бэкенд на основной работе, Swift вижу первый раз

  • Вечера и выходные – полноценная пятидневка днём, проект только после работы

  • iOS 17+ – осознанный выбор, потому что Map с MapPolyline в SwiftUI появился именно в 17-й версии. Альтернатива – оборачивать MKMapView через UIViewRepresentable, но это отдельный слой боли с координацией состояния

  • Без сторонних зависимостей – только системные фреймворки: CoreLocation, MapKit, CoreData, ActivityKit

  • Фоновая работа без убийства батареи – главный технический вызов

CoreLocation: фоновый трекинг

Основа – CLLocationManager с allowsBackgroundLocationUpdates = true. iOS позволяет получать координаты в фоне, но с условиями: нужен entitlement location в Background Modes, и описание зачем приложению Always-доступ к геолокации в Info.plist.

Первая версия писала каждую точку от CLLocationManager. За часовую поездку – тысячи координат, база раздувалась, отрисовка маршрута тормозила. Решение – фильтрация на входе:

  • desiredAccuracy = kCLLocationAccuracyBest – максимальная точность (GPS, не Wi-Fi/сотовые вышки)

  • distanceFilter = 10 – игнорировать обновления, если сместился меньше чем на 10 метров

  • Дополнительно: отбрасываем точки с horizontalAccuracy > 50 – это шум

После фильтрации средняя поездка – 200–500 точек. За 60+ поездок база занимает ~15 МБ.

Проблема: GPS-глушение

На трассе Краснодар – Геленджик есть участки, где GPS пропадает на 10–30 секунд. Навигатор показывает что ты едешь по полю. На карте – прямая линия через лес.

В automotive-навигаторах эту проблему решают sensor fusion: GPS + акселерометр + гироскоп. Но на iOS в фоновом режиме CMMotionManager недоступен – система не даёт читать IMU когда приложение не на экране.

Остаётся фильтр Калмана на чистом GPS – без инерциальных данных, только координаты и их точность.

func processLocation(_ location: CLLocation) -> CLLocation {    guard let previous = lastFilteredLocation else {        lastFilteredLocation = location        return location    }    let dt = location.timestamp.timeIntervalSince(previous.timestamp)    // Prediction step: uncertainty grows with time    let processNoise = dt * speedVariance    predictedVariance += processNoise    // Update step: blend prediction with measurement    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    let filtered = CLLocation(        coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon),        altitude: location.altitude,        horizontalAccuracy: sqrt(predictedVariance),        verticalAccuracy: location.verticalAccuracy,        timestamp: location.timestamp    )    lastFilteredLocation = filtered    return filtered}

Идея: если horizontalAccuracy у новой точки большая (GPS неуверен) – kalmanGain маленький и мы больше доверяем предыдущей позиции. Если точность хорошая – больше доверяем измерению.

Это работает для коротких пробелов (до ~10 секунд на скорости 80–100 км/ч). Для длинных дыр – пост-обработка после завершения поездки: интерполяция кубическими сплайнами между последней надёжной точкой перед провалом и первой после.

Рендеринг маршрута по скорости

Маршрут на карте окрашивается по скорости в каждой точке:

  • Зелёный: < 50 км/ч (город, парковка)

  • Жёлтый: 50–90 км/ч (городские магистрали)

  • Оранжевый: 90–110 км/ч (трасса)

  • Красный: > 110 км/ч

В SwiftUI (iOS 17+) для этого есть MapPolyline, но один MapPolyline – один цвет. Маршрут из 300 точек с меняющейся скоростью – это не один полилайн, а массив сегментов.

Решение: разбиваем массив координат на сегменты с одинаковым цветом. Каждый сегмент – отдельный MapPolyline с .stroke нужного цвета. Соседние сегменты перекрываются на одну точку, чтобы не было разрывов.

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            ))            // Overlap: last point of prev segment = first of next            currentCoords = [currentCoords.last!]        }        currentCoords.append(point.coordinate)        currentColor = color    }    if !currentCoords.isEmpty {        segments.append(SpeedSegment(            coordinates: currentCoords,            color: currentColor        ))    }    return segments}

На карте 20–50 полилайнов вместо одного. По производительности – нормально, MapKit справляется. При 500+ точках начинает быть заметно на старых устройствах, но для типичной поездки достаточно.

Fog of war на geohash

Карта закрыта полупрозрачным оверлеем, который рассеивается там, где проехал – как fog of war в стратегиях. Технически это самая интересная часть.

Почему geohash

Нужна структура данных, которая отвечает на вопрос «проезжал ли я через эту область?» для произвольной точки на карте. Варианты:

  • Хранить все GPS-точки и проверять расстояние – O(n) на каждый запрос, не масштабируется

  • Quadtree – хорошо, но сложно сериализовать в CoreData

  • Geohash – строка фиксированной длины, кодирующая прямоугольную область. Сравнение – простой prefix match. Хранение – Set<String>. Сериализация тривиальная

Geohash кодирует координату в строку вида sczg4p. Длина строки определяет точность:

Precision

Размер ячейки

Применение

5

~4.9 × 4.9 км

Обзор страны

6

~1.2 × 0.6 км

Обзор города

7

~150 × 150 м

Детальный вид

Алгоритм кодирования – чередующееся бинарное деление по долготе и широте:

func encode(latitude: Double, longitude: Double, precision: Int) -> String {    let base32 = Array("0123456789bcdefghjkmnpqrstuvwxyz")    var latRange = (-90.0, 90.0)    var lonRange = (-180.0, 180.0)    var isEven = true    var bit = 0    var ch = 0    var hash = ""    while hash.count < precision {        if isEven {            let mid = (lonRange.0 + lonRange.1) / 2            if longitude >= mid {                ch |= (1 << (4 - bit))                lonRange.0 = mid            } else {                lonRange.1 = mid            }        } else {            let mid = (latRange.0 + latRange.1) / 2            if latitude >= mid {                ch |= (1 << (4 - bit))                latRange.0 = mid            } else {                latRange.1 = mid            }        }        isEven.toggle()        bit += 1        if bit == 5 {            hash.append(base32[ch])            bit = 0            ch = 0        }    }    return hash}

Отрисовка тумана

При каждом обновлении видимой области карты:

  1. Вычисляем geohash-ячейки, попадающие в видимую область

  2. Для каждой ячейки проверяем – есть ли она в Set<String> посещённых

  3. Непосещённые ячейки закрываем полупрозрачным MKPolygon

На низком зуме (вся страна) это precision 5 – десятки ячеек. На высоком зуме (улица) – precision 7, сотни ячеек. Переключение precision происходит по порогу MKCoordinateSpan.

Анимация рассеивания

Во время записи поездки, когда водитель проезжает через новую ячейку – туман рассеивается с анимацией 0.7 секунды easeOut. Реализовано через withAnimation на opacity оверлея конкретной ячейки. Визуально приятно – видно как карта «открывается» в реальном времени.

Текущие ограничения

Geohash-ячейки прямоугольные – границы тумана выглядят «пиксельно», как в играх 90-х. В бэклоге – переход на градиентный рендеринг с размытием краёв, чтобы туман выглядел плавно. Если кто-то реализовывал подобное на MapKit – буду рад обсудить подходы.

Live Activity: зачем и как

Без визуального индикатора записи я регулярно забывал остановить трекинг. Телефон записывал GPS всю ночь, утром батарея на нуле. Live Activity на экране блокировки решает проблему – видно что запись идёт, даже когда телефон в кармане.

ActivityKit API:

// Запускlet attributes = TripActivityAttributes(carName: "Polo Sedan")let state = TripActivityAttributes.ContentState(    speed: 0,    distance: 0,    duration: 0)let activity = try Activity.request(    attributes: attributes,    content: .init(state: state, staleDate: nil))// Обновление (каждую секунду из CLLocationManager delegate)let updatedState = TripActivityAttributes.ContentState(    speed: currentSpeed,    distance: totalDistance,    duration: elapsedTime)await activity.update(.init(state: updatedState, staleDate: nil))

Нюанс: iOS ограничивает частоту обновлений Live Activity – примерно раз в секунду, чаще бесполезно. Для спидометра достаточно.

Хранение данных: CoreData

Схема простая:

TripEntity (1) ──── (*) LocationPointEntity    │    ├── startDate: Date    ├── endDate: Date    ├── distance: Double    ├── maxSpeed: Double    ├── avgSpeed: Double    └── fuelCost: DoubleLocationPointEntity    ├── latitude: Double    ├── longitude: Double    ├── speed: Double    ├── altitude: Double    ├── timestamp: Date    └── horizontalAccuracy: Double

Fog of war хранится отдельно: ExploredCellEntity с единственным полем geohash: String и уникальным индексом. При записи новой точки – вычисляем geohash нужных precision и вставляем в базу (с NSMergeByPropertyObjectTrumpMergePolicy чтобы дубли игнорировались).

Средняя поездка – 200–500 LocationPointEntity. За 60+ поездок суммарно ~15 МБ. CoreData с SQLite справляется без проблем.

Три реджекта Apple

Приложение отклоняли три раза перед одобрением. Каждый – про UX и локализацию, не про функциональность.

Реджект 1: «Кнопка старта не работает». Я сделал long-press 0.4 сек на кнопке записи, чтобы случайно не начать трекинг в кармане. Ревьюер тапнул – ничего не произошло – rejected. В описании кнопки ничего про long-press не было. Заменил на slide to start – и UX лучше, и ревьюеру понятно.

Реджект 2: «Цель геолокации слишком общая». В NSLocationAlwaysUsageDescription было что-то вроде «для записи маршрута». Apple хочет конкретнее: что именно записывается, зачем нужен Always-доступ (а не When In Use), как пользователь может это контролировать.

Реджект 3: русский текст в системном алерте. Захардкодил NSLocationAlwaysUsageDescription на русском. У ревьюера iPhone на английском – он видит нелокализованный кириллический текст в системном диалоге. Решение: InfoPlist.strings с локализацией.

Практическое наблюдение (не гарантия Apple): после реджекта я отменял текущее ревью через Resolution Center и отправлял новую сборку с нуля. Ответ приходил за 2–5 часов вместо обычных нескольких дней. Возможно, отменённые ревью попадают в приоритетную очередь – а может просто повезло.

## Как бэкендер собрал iOS-приложение: AI как второй разработчик

Три года я откладывал этот проект, потому что ждал «нормальную команду» – iOS-разработчика, дизайнера. Команда не появилась.

Сдвинулось, когда я попробовал работать с AI-агентами: Claude Code в терминале и Cursor в IDE. Не «попросил AI написать приложение» – это так не работает. Скорее, AI стал вторым разработчиком, который знает Swift лучше меня, но которому нужно объяснять контекст и проверять результат.

Как это выглядело на практике:

Где AI реально ускорил. Бойлерплейт SwiftUI – экраны, навигация, модели данных. Для бэкендера, который не знает разницу между @State и @StateObject, это критично: AI генерирует рабочий каркас, я разбираюсь что он написал и почему. CoreData schema, Info.plist entitlements, ActivityKit boilerplate – всё то, что нужно знать «как правильно», но невозможно нагуглить за разумное время если ты не в экосистеме.

Где AI бесполезен. GPS-трекинг в фоне, работа с батареей, edge cases реальных поездок – тут нужно тестировать на устройстве, в машине, на реальной трассе. AI не знает, что на M4 между Горячим Ключом и Джубгой глушат GPS. Фильтр Калмана я отдавал агенту трижды – каждый раз получал математически корректный, но практически бесполезный результат. Сел и написал сам, по конкретным данным с конкретных поездок.

Продуктовые решения. Что показывать на экране записи, как группировать поездки, какой precision у geohash на каком зуме – AI может предложить варианты, но решение всегда за мной. Он не знает, что мне важнее видеть стоимость бензина, а не среднюю скорость.

По времени: от первого коммита до App Store – месяц вечерами. Без AI-агентов, с моим нулевым знанием Swift – это был бы год, если бы вообще дошёл до релиза. С ними – я не стал iOS-разработчиком, но смог собрать конкретный продукт, который сам использую каждый день.

Стоимость: Claude ~$120/мес (но это и рабочие задачи, грубо половина на проект).

Открытый код

Репозиторий

Решение открыть код было прагматичным: приложение работает с геолокацией 24/7 – люди имеют право видеть, что оно делает с их данными. Плюс когда код публичный – стыдно коммитить костыли.

Swift, SwiftUI, ноль сторонних зависимостей. Если кому интересно покопаться в реализации geohash fog of war или фильтра Калмана для GPS – welcome.

Что дальше

Основные задачи в бэклоге:

  • Автостарт/автостоп – определять начало и конец поездки по данным с акселерометра (когда приложение на переднем плане) или по значительным изменениям геолокации (startMonitoringSignificantLocationChanges)

  • Экспорт в GPX/JSON – чтобы данные не были заложником одного приложения

  • Градиентный fog of war – уйти от прямоугольных geohash-ячеек к размытию краёв

  • CarPlay – виджет на экране мультимедиа автомобиля

Если есть вопросы по реализации – пишите в комментариях или в issues на GitHub.


Приложение в App Store

Исходный код на GitHub

ссылка на оригинал статьи https://habr.com/ru/articles/1024896/