В 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}
Отрисовка тумана
При каждом обновлении видимой области карты:
-
Вычисляем geohash-ячейки, попадающие в видимую область
-
Для каждой ячейки проверяем – есть ли она в
Set<String>посещённых -
Непосещённые ячейки закрываем полупрозрачным
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.
ссылка на оригинал статьи https://habr.com/ru/articles/1024896/