Jak stworzyć tracker GPS działający w tle na iOS: rozwiązania techniczne i pułapki
Po wyłączeniu Google Timeline w 2024 roku deweloperzy stracili niezawodne narzędzie do analizy tras. Zamiast rozwiązań chmurowych trzeba tworzyć lokalne aplikacje z dokładnym rozliczaniem zużycia paliwa. Omówimy implementację trackera GPS w SwiftUI, w tym przetwarzanie danych z szumami, optymalizację pracy w tle oraz przejście przez recenzję App Store.
Podstawy śledzenia w tle w CoreLocation
Kluczowy problem — uzyskiwanie współrzędnych przy zminimalizowanej aplikacji bez drenowania baterii. W tym celu należy:
- Aktywować
allowsBackgroundLocationUpdatesw CLLocationManager - Dodać entitlement Background Modes z typem location
- Wpisać motywację w Info.plist poprzez
NSLocationAlwaysAndWhenInUseUsageDescription
Początkowa implementacja z zapisem każdej punktu doprowadziła do nadmiernej ilości danych (tysiące punktów na przejazd). Optymalizacja osiągnięta kombinacją parametrów:
desiredAccuracy = kCLLocationAccuracyBest— wybór GPS zamiast Wi-Fi/wieży komórkowychdistanceFilter = 10— ignorowanie przesunięć mniejszych niż 10 metrów- Filtrowanie po
horizontalAccuracy > 50— odrzucanie danych z szumami
Te ustawienia zmniejszyły objętość danych do 200–500 punktów na przejazd. Przy 60+ trasach baza zajmuje ~15 MB, co jest akceptowalne dla CoreData.
Filtr Kalmana bez IMU: matematyka dla tras samochodowych
Na odcinkach z zagłuszaniem GPS (Krasnodar–Gelendżik) klasyczne rozwiązania z czujnikami są niedostępne — CMMotionManager jest blokowany w trybie tła. Trzeba było zaadaptować filtr Kalmana do pracy tylko z współrzędnymi:
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
)
}
Algorytm dynamicznie reguluje zaufanie do pomiarów: przy wysokiej horizontalAccuracy (słaby sygnał GPS) kalmanGain maleje, a system bardziej polega na poprzednich danych. Dla zaników sygnału dłuższych niż 10 sekund stosowana jest postobróbka splajnami sześciennymi.
Wizualizacja trasy według prędkości w SwiftUI
iOS 17+ udostępnia MapPolyline, ale jego ograniczenie — jeden kolor na obiekt. Dla dynamicznego kolorowania trasy według prędkości potrzebne było:
- Podział śladu na segmenty o tym samym zakresie prędkości
- Każdy segment narysować osobnym MapPolyline
- Zapewnić nakładanie sąsiednich segmentów na jeden punkt
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
}
System używa 4 zakresów kolorystycznych:
- Zielony: < 50 km/h (miasto)
- Żółty: 50–90 km/h (drogi szybkiego ruchu)
- Pomarańczowy: 90–110 km/h (trasa)
- Czerwony: > 110 km/h
Na typowej trasie generuje się 20–50 polilinii. Przy 500+ punktach na starszych urządzeniach widoczne są opóźnienia, ale dla większości scenariuszy wydajność jest wystarczająca.
Geohash Fog of War: optymalizacja geoprzestrzenna
Dla efektu „mgły wojny” (ukrywanie nieprzeszłych odcinków) wybrano geohash zamiast quadtree lub bezpośredniego przechowywania punktów. Zalety:
- Szybkie wyszukiwanie poprzez porównanie prefiksowe
- Prosta serializacja w CoreData
- Elastyczność dzięki parametrowi precision
| Precyzja | Rozmiar komórki | Zastosowanie |
|-----------|-----------------|--------------|
| 5 | ~4,9 × 4,9 km | Przegląd kraju |
| 6 | ~1,2 × 0,6 km | Przegląd miasta |
| 7 | ~150 × 150 m | Widok szczegółowy |
Realizacja obejmuje:
- Dynamiczne przełączanie precyzji według MKCoordinateSpan
- Sprawdzanie widocznych komórek poprzez Set<String>
- Animację rozproszenia z opacity 0.7s easeOut
Obecne ograniczenie — prostokątne granice komórek. W planach przejście na renderowanie z gradientem i rozmytymi krawędziami dla naturalnego efektu.
Co jest ważne
- Filtrowanie na wejściu jest kluczowe: bez distanceFilter i filtra horizontalAccuracy baza danych rośnie 5–10 razy
- Live Activity ma sztywne limity: aktualizacje co sekundę — maksimum, co pozwala iOS
- Geohash jest efektywniejszy od quadtree w scenariuszach mobilnych dzięki prostocie serializacji
- Filtr Kalmana bez IMU działa przy przerwach w sygnale do 10 sekund
- MapPolyline wymaga segmentacji dla dynamicznego kolorowania — jedna trasa = 20–50 obiektów
Live Activity: walka z czynnikiem ludzkim
Problem: użytkownicy zapominają zatrzymać nagrywanie, co prowadzi do rozładowania baterii. Rozwiązanie — wskaźnik na ekranie blokady za pomocą ActivityKit:
// Uruchomienie aktywności
let activity = try Activity.request(
attributes: TripActivityAttributes(carName: "Polo Sedan"),
content: .init(state: initialState)
)
// Aktualizacja co sekundę
await activity.update(.init(state: updatedState))
Ważne: częstotliwość aktualizacji ograniczona do ~1 Hz. Próby częstszych wysyłek powodują ignorowanie przez system. Dla prędkościomierza wystarcza, ale dla metryk wysokiej częstotliwości potrzebna agregacja.
Przechowywanie danych w CoreData
Schemat bazy zoptymalizowany pod szybki dostęp do tras i punktów:
TripEntity (1) ──── (*) LocationPointEntity
├── startDate
├── distance
└── fuelCost
LocationPointEntity
├── latitude
├── longitude
└── speed
ExploredCellEntity
└── geohash (unique)
Cechy implementacji:
- Użycie NSMergeByPropertyObjectTrumpMergePolicy dla komórek geohash
- Automatyczne usuwanie starych wpisów na podstawie timestamp
- Indeksowanie po tripID i timestamp dla przyspieszenia zapytań
Przy obsłudze 500+ punktów na trasie kluczowe jest minimalizowanie liczby fetch requests. Wszystkie operacje na LocationPointEntity w jednym managed object context z batch updates.
— Editorial Team
Brak komentarzy.