Zpět na domů

GPS tracker v iOS: filtr Kalmana a geohash implementace

Článek o implementaci GPS trackeru na pozadí v SwiftUI s využitím filtru Kalmana a geohash. Podrobný rozbor technických řešení pro zpracování dat, vizualizaci tras a projití App Store Review.

GPS tracker na pozadí v iOS: úplné technické průvodce
Advertisement 728x90

# Jak vytvořit GPS tracker na pozadí v iOS: technická řešení a úskalí

Po deaktivaci Google Timeline v roce 2024 přišli vývojáři o spolehlivý nástroj pro analýzu tras. Namísto cloudových řešení je nutné vytvářet lokální aplikace s přesným sledováním spotřeby paliva. Probereme implementaci GPS trackeru ve SwiftUI, včetně zpracování šumivých dat, optimalizace práce na pozadí a úspěšného projití recenze App Store.

Základy pozadového trackingu v CoreLocation

Klíčovým problémem je získávání souřadnic při minimalizovaném aplikaci bez vybíjení baterie. K tomu je potřeba:

  • Aktivovat allowsBackgroundLocationUpdates v CLLocationManager
  • Přidat entitlement Background Modes s typem location
  • Uvést motivaci v Info.plist přes NSLocationAlwaysAndWhenInUseUsageDescription

Počáteční implementace s ukládáním každé body vedla k nadměrnému objemu dat (tisíce bodů na cestu). Optimalizace byla dosažena kombinací parametrů:

Google AdInline article slot
  • desiredAccuracy = kCLLocationAccuracyBest — volba GPS místo Wi-Fi/buněčných věží
  • distanceFilter = 10 — ignorování posunů menších než 10 metrů
  • Filtrování podle horizontalAccuracy > 50 — odstraňování šumivých dat

Tyto nastavení snížily objem dat na 200–500 bodů na cestu. Při 60+ trasách databáze zabírá ~15 MB, což je přijatelné pro CoreData.

Kalmanův filtr bez IMU: matematika pro silniční trasy

Na úsecích s rušením GPS (Krasnodar–Gelendžik) nejsou klasická řešení s senzory dostupná — CMMotionManager je blokován v pozadovém režimu. Bylo nutné adaptovat Kalmanův filtr pro práci pouze se souřadnicemi:

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
    )
}

Algoritmus dynamicky upravuje důvěru k měřením: při vysoké horizontalAccuracy (špatný GPS signál) se kalmanGain snižuje a systém se více spoléhá na předchozí data. Pro výpadky signálu delší než 10 sekund se používá post-processing kubickými splajny.

Google AdInline article slot

Vizualizace trasy podle rychlosti ve SwiftUI

iOS 17+ poskytuje MapPolyline, ale jeho omezení je jedna barva na objekt. Pro dynamické zbarvení trasy podle rychlosti bylo potřeba:

  • Rozdělit stopu na segmenty se stejným rozsahem rychlostí
  • Každý segment vykreslit samostatným MapPolyline
  • Zajistit překrytí sousedních segmentů jedním bodem
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
}

Systém používá 4 barevné rozsahy:

  • Zelená: < 50 km/h (město)
  • Žlutá: 50–90 km/h (dálnice)
  • Oranžová: 90–110 km/h (silnice)
  • Červená: > 110 km/h

Na typické trase vznikne 20–50 polylinií. Při 500+ bodech jsou na starších zařízeních patrné zpoždění, ale pro většinu scénářů je výkon dostatečný.

Google AdInline article slot

Geohash Fog of War: geoprostřední optimalizace

Pro efekt „mlhy války“ (skrytí neprojetých úseků) byl zvolen geohash místo quadtree nebo přímého ukládání bodů. Výhody:

  • Rychlé hledání přes prefixové porovnání
  • Jednoduchá serializace do CoreData
  • Flexibilita přes parametr precision

| Precision | Velikost buňky | Použití |

|-----------|----------------|---------|

| 5 | ~4,9 × 4,9 km | Přehled země |

| 6 | ~1,2 × 0,6 km | Přehled města |

| 7 | ~150 × 150 m | Detailní pohled |

Implementace zahrnuje:

  • Dynamické přepínání precision podle MKCoordinateSpan
  • Kontrolu viditelných buněk přes Set<String>
  • Animaci rozptylu s opacity 0.7s easeOut

Aktuální omezení — obdélníkové hranice buněk. V plánech přechod na gradientový rendering s rozmazáním okrajů pro přirozený vzhled.

Co je důležité

  • Filtrace na vstupu je kritická: bez distanceFilter a filtru horizontalAccuracy se databáze nafoukne 5–10krát
  • Live Activity má přísné limity: aktualizace jednou za sekundu — maximum, co iOS dovolí
  • Geohash je efektivnější než quadtree pro mobilní scénáře díky jednoduché serializaci
  • Kalmanův filtr bez IMU funguje až do 10sekundových výpadků signálu
  • MapPolyline vyžaduje segmentaci pro dynamické zbarvení — jedna trasa = 20–50 objektů

Live Activity: boj s lidským faktorem

Problém: uživatelé zapomínají zastavit záznam, což vede k vybití baterie. Řešení — indikátor na zamykací obrazovce přes ActivityKit:

// Spuštění aktivity
let activity = try Activity.request(
    attributes: TripActivityAttributes(carName: "Polo Sedan"),
    content: .init(state: initialState)
)

// Aktualizace každou sekundu
await activity.update(.init(state: updatedState))

Důležité: frekvence aktualizací je omezena na ~1 Hz. Pokusy o častější odesílání vedou k ignorování požadavků systémem. Pro spidometr to stačí, ale pro vysokočetné metriky bude nutná agregace dat.

Ukládání dat v CoreData

Schéma databáze je optimalizováno pro rychlý přístup k trasám a bodům:

TripEntity (1) ──── (*) LocationPointEntity
    ├── startDate
    ├── distance
    └── fuelCost

LocationPointEntity
    ├── latitude
    ├── longitude
    └── speed

ExploredCellEntity
    └── geohash (unique)

Specifika implementace:

  • Použití NSMergeByPropertyObjectTrumpMergePolicy pro geohash buňky
  • Automatické mazání starých záznamů přes timestamp
  • Indexování podle tripID a timestamp pro zrychlení dotazů

Při práci se 500+ body na trasu je kritické minimalizovat počet fetch požadavků. Všechny operace s LocationPointEntity probíhají v jednom managed object context s batch aktualizacemi.

— Editorial Team

Advertisement 728x90

Číst dál