Powrót do strony głównej

GPS-śledzik w iOS: filtr Kalmana i implementacja geohash

Artykuł o implementacji śledzika GPS w tle na SwiftUI z użyciem filtra Kalmana i geohash. Szczegółowy rozkład technicznych rozwiązań do przetwarzania danych, wizualizacji tras i przejścia App Store Review.

Śledzik GPS w tle w iOS: pełne techniczne przewodnictwo
Advertisement 728x90

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ć allowsBackgroundLocationUpdates w 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:

Google AdInline article slot
  • desiredAccuracy = kCLLocationAccuracyBest — wybór GPS zamiast Wi-Fi/wieży komórkowych
  • distanceFilter = 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.

Google AdInline article slot

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.

Google AdInline article slot

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

Advertisement 728x90

Czytaj dalej