Comment créer un traqueur GPS en arrière-plan sur iOS : Solutions techniques et pièges
Après l'arrêt de Google Timeline en 2024, les développeurs ont perdu un outil fiable pour l'analyse des itinéraires. Au lieu de solutions basées sur le cloud, nous développons désormais des applications locales qui suivent précisément la consommation de carburant. Nous allons détailler l'implémentation d'un traqueur GPS en SwiftUI, en couvrant la gestion des données bruitées, l'optimisation en arrière-plan et la traversée du processus de révision de l'App Store.
Bases du suivi en arrière-plan avec CoreLocation
Le défi principal consiste à obtenir des coordonnées lorsque l'app est en arrière-plan sans vider la batterie. Voici ce qu'il faut :
- Activer
allowsBackgroundLocationUpdatesdans CLLocationManager - Ajouter l'entitlement Background Modes avec le type location
- Fournir une justification dans Info.plist via
NSLocationAlwaysAndWhenInUseUsageDescription
L'implémentation initiale, qui enregistrait chaque point, générait un volume de données excessif (milliers de points par trajet). L'optimisation est venue de la combinaison de ces paramètres :
desiredAccuracy = kCLLocationAccuracyBest— privilégie le GPS aux tours Wi-Fi/cellulairesdistanceFilter = 10— ignore les déplacements inférieurs à 10 mètres- Filtrage par
horizontalAccuracy > 50— élimine les données bruitées
Ces réglages réduisent les données à 200–500 points par trajet. Avec plus de 60 trajets, la base de données occupe ~15 Mo, ce qui est acceptable pour CoreData.
Filtre de Kalman sans IMU : Mathématiques pour le suivi sur autoroute
Sur les tronçons avec brouillage GPS (Krasnodar–Gelendzhik), les solutions classiques basées sur capteurs ne sont pas disponibles — CMMotionManager est bloqué en arrière-plan. Nous avons adapté le filtre de Kalman pour fonctionner uniquement avec les coordonnées :
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
)
}
L'algorithme ajuste dynamiquement la confiance dans les mesures : avec une horizontalAccuracy élevée (mauvais signal GPS), le kalmanGain diminue, et le système s'appuie davantage sur les données précédentes. Pour les pertes de signal supérieures à 10 secondes, nous utilisons un post-traitement avec des splines cubiques.
Visualisation des itinéraires par vitesse en SwiftUI
iOS 17+ propose MapPolyline, mais limitée à une couleur par objet. Pour une coloration dynamique basée sur la vitesse, nous avons dû :
- Diviser la trace en segments de plages de vitesse uniformes
- Rendre chaque segment comme une MapPolyline séparée
- Chevaucher les segments adjacents d'un point
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
}
Le système utilise 4 plages de couleurs :
- Vert : < 50 km/h (ville)
- Jaune : 50–90 km/h (autoroutes)
- Orange : 90–110 km/h (voies rapides)
- Rouge : > 110 km/h
Un trajet typique génère 20–50 polylignes. Avec 500+ points, il peut y avoir du lag sur les anciens appareils, mais les performances sont solides pour la plupart des cas.
Geohash pour brouillard de guerre : Optimisation géospatiale
Pour l'effet de « brouillard de guerre » (cacher les sections non parcourues), nous avons choisi geohash plutôt que quadtree ou stockage direct de points. Avantages :
- Recherche rapide par correspondance de préfixe
- Sérialisation simple vers CoreData
- Flexibilité via paramètre de précision
| Précision | Taille de cellule | Application |
|-----------|-------------------|----------------------|
| 5 | ~4,9 × 4,9 km | Vue d'ensemble pays |
| 6 | ~1,2 × 0,6 km | Vue d'ensemble ville |
| 7 | ~150 × 150 m | Vue détaillée |
L'implémentation inclut :
- Changement dynamique de précision basé sur MKCoordinateSpan
- Vérification des cellules visibles via Set<String>
- Animation de dissipation avec opacité 0,7 s easeOut
Limitation actuelle : frontières de cellules rectangulaires. Prochainement : rendu de gradient avec flou des bords pour un aspect plus naturel.
Points clés
- Le filtrage d'entrée est crucial : sans distanceFilter et vérifications horizontalAccuracy, la base de données gonfle de 5–10x
- Live Activity a des limites strictes : 1 Hz est la fréquence max autorisée par iOS
- Geohash surpasse quadtree pour les scénarios mobiles grâce à une sérialisation plus simple
- Filtre de Kalman sans IMU gère jusqu'à 10 secondes de pertes de signal
- MapPolyline nécessite une segmentation pour la coloration dynamique — un trajet = 20–50 objets
Live Activity : Combattre l'erreur humaine
Problème : les utilisateurs oublient d'arrêter l'enregistrement, vidant la batterie. Solution : un indicateur sur l'écran de verrouillage via ActivityKit :
// Lancer l'activité
let activity = try Activity.request(
attributes: TripActivityAttributes(carName: "Polo Sedan"),
content: .init(state: initialState)
)
// Mise à jour toutes les secondes
await activity.update(.init(state: updatedState))
Point clé : la fréquence de mise à jour est limitée à ~1 Hz. Envoyer plus souvent est ignoré par le système. C'est suffisant pour un compteur de vitesse, mais les métriques haute fréquence nécessitent une agrégation de données.
Stockage des données dans CoreData
Le schéma de base de données est optimisé pour un accès rapide aux trajets et points :
TripEntity (1) ──── (*) LocationPointEntity
├── startDate
├── distance
└── fuelCost
LocationPointEntity
├── latitude
├── longitude
└── speed
ExploredCellEntity
└── geohash (unique)
Détails d'implémentation :
- NSMergeByPropertyObjectTrumpMergePolicy pour les cellules geohash
- Suppression automatique des anciens enregistrements via timestamp
- Indexation par tripID et timestamp pour des requêtes plus rapides
Avec 500+ points par trajet, minimiser les fetch requests est crucial. Toutes les opérations LocationPointEntity s'exécutent dans un seul managed object context avec des mises à jour par lots.
— Editorial Team
Aucun commentaire pour le moment.