Volver al inicio

Rastreador GPS en iOS: filtro de Kalman e implementación de geohash

Artículo sobre la implementación de un rastreador GPS en segundo plano en SwiftUI usando filtro de Kalman y geohash. Desglose detallado de soluciones técnicas para procesamiento de datos, visualización de rutas y pasar la revisión de App Store.

Rastreador GPS en segundo plano en iOS: guía técnica completa
Advertisement 728x90

## Cómo crear un rastreador GPS en segundo plano en iOS: Soluciones técnicas y escollos

Tras el cierre de Google Timeline en 2024, los desarrolladores perdieron una herramienta fiable para el análisis de rutas. En lugar de soluciones basadas en la nube, ahora estamos creando apps locales que rastrean con precisión el consumo de combustible. Te guiaremos paso a paso en la implementación de un rastreador GPS en SwiftUI, cubriendo el manejo de datos ruidosos, la optimización en segundo plano y cómo superar la revisión de la App Store.

Bases del seguimiento en segundo plano en CoreLocation

El principal desafío es obtener coordenadas cuando la app está en segundo plano sin agotar la batería. Esto es lo que se requiere:

  • Activar allowsBackgroundLocationUpdates en CLLocationManager
  • Añadir el entitlement de Modos en segundo plano con el tipo de ubicación
  • Proporcionar justificación en Info.plist mediante NSLocationAlwaysAndWhenInUseUsageDescription

La implementación inicial, que registraba cada punto, generó un volumen excesivo de datos (miles de puntos por viaje). La optimización se logró combinando estos parámetros:

Google AdInline article slot
  • desiredAccuracy = kCLLocationAccuracyBest — prioriza GPS sobre torres Wi-Fi/celulares
  • distanceFilter = 10 — ignora movimientos inferiores a 10 metros
  • Filtrado por horizontalAccuracy > 50 — elimina datos ruidosos

Estos ajustes redujeron los datos a 200–500 puntos por viaje. Con más de 60 rutas, la base de datos ocupa ~15 MB, lo cual es aceptable para CoreData.

Filtro de Kalman sin IMU: Matemáticas para rastreo en autopistas

En tramos con interferencias GPS (Krasnodar–Gelendzhik), las soluciones clásicas basadas en sensores no están disponibles — CMMotionManager se bloquea en segundo plano. Tuvimos que adaptar el filtro de Kalman para trabajar solo con coordenadas:

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

El algoritmo ajusta dinámicamente la confianza en las mediciones: con alta horizontalAccuracy (señal GPS pobre), el kalmanGain disminuye y el sistema se apoya más en datos previos. Para interrupciones de señal superiores a 10 segundos, usamos postprocesamiento con splines cúbicos.

Google AdInline article slot

Visualización de rutas por velocidad en SwiftUI

iOS 17+ ofrece MapPolyline, pero está limitada a un color por objeto. Para colorear rutas dinámicamente según la velocidad, tuvimos que:

  • Dividir la pista en segmentos con rangos de velocidad uniformes
  • Renderizar cada segmento como un MapPolyline separado
  • Superponer segmentos adyacentes en un punto
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
}

El sistema usa 4 rangos de color:

  • Verde: < 50 km/h (ciudad)
  • Amarillo: 50–90 km/h (autopistas)
  • Naranja: 90–110 km/h (carreteras de peaje)
  • Rojo: > 110 km/h

Una ruta típica genera 20–50 polilíneas. Con 500+ puntos, puede haber retrasos en dispositivos antiguos, pero el rendimiento es sólido para la mayoría de casos.

Google AdInline article slot

Niebla de guerra con Geohash: Optimización geoespacial

Para el efecto de "niebla de guerra" (ocultar tramos no recorridos), elegimos geohash sobre quadtree o almacenamiento directo de puntos. Ventajas:

  • Búsqueda rápida mediante coincidencia de prefijos
  • Serialización simple a CoreData
  • Flexibilidad mediante parámetro de precisión

| Precisión | Tamaño de celda | Aplicación |

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

| 5 | ~4.9 × 4.9 km | Resumen del país |

| 6 | ~1.2 × 0.6 km | Resumen de ciudad |

| 7 | ~150 × 150 m | Vista detallada |

La implementación incluye:

  • Cambio dinámico de precisión según MKCoordinateSpan
  • Comprobación de celdas visibles mediante Set<String>
  • Animación de disipación con opacidad 0.7s easeOut

Limitación actual: límites rectangulares de celdas. Próximo: renderizado de gradiente con desenfoque de bordes para un aspecto más natural.

Lecciones clave

  • El filtrado de entrada es crítico: sin distanceFilter y comprobaciones de horizontalAccuracy, la base de datos se infla 5–10 veces
  • Live Activity tiene límites estrictos: 1 Hz es el máximo de actualizaciones que permite iOS
  • Geohash supera a quadtree en escenarios móviles por serialización más simple
  • Filtro de Kalman sin IMU maneja interrupciones de señal de hasta 10 segundos
  • MapPolyline necesita segmentación para coloreado dinámico — una ruta = 20–50 objetos

Live Activity: Combatiendo el error humano

Problema: los usuarios olvidan detener la grabación, agotando la batería. Solución: un indicador en la pantalla de bloqueo mediante ActivityKit:

// Iniciar actividad
let activity = try Activity.request(
    attributes: TripActivityAttributes(carName: "Polo Sedan"),
    content: .init(state: initialState)
)

// Actualizar cada segundo
await activity.update(.init(state: updatedState))

Punto clave: la frecuencia de actualización está limitada a ~1 Hz. Enviar datos más a menudo es ignorado por el sistema. Es suficiente para un velocímetro, pero las métricas de alta frecuencia necesitan agregación de datos.

Almacenamiento de datos en CoreData

El esquema de la base de datos está optimizado para acceso rápido a rutas y puntos:

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

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

ExploredCellEntity
    └── geohash (unique)

Detalles de implementación:

  • NSMergeByPropertyObjectTrumpMergePolicy para celdas geohash
  • Eliminación automática de registros antiguos mediante timestamp
  • Indexación por tripID y timestamp para consultas más rápidas

Con 500+ puntos por ruta, minimizar las solicitudes de fetch es crucial. Todas las operaciones de LocationPointEntity se ejecutan en un único contexto de objeto gestionado con actualizaciones por lotes.

— Editorial Team

Advertisement 728x90

Leer después