Saltar al contenido
Ende Sun
Volver al blog

Desarrollo móvil

Bluetooth Low Energy y geofencing en iOS: lo que aprendí construyendo Beacons y RunTracker

7 min de lectura

Bluetooth Low Energy y geofencing en iOS: lo que aprendí construyendo Beacons y RunTracker

Dos de los proyectos de iOS de esta web, Beacons y RunTracker, se apoyan en la misma idea: la app reacciona a dónde está el usuario, no solo a lo que toca en pantalla. Uno lo hace con señales de Bluetooth Low Energy y el otro con GPS. Construirlos me enseñó que lo difícil no son las llamadas a la API, sino entender qué hace el sistema por ti, qué no hace y cuánta batería cuesta cada decisión.

Cómo se organiza BLE

Bluetooth Low Energy no exige emparejamiento. Los dispositivos forman pequeñas redes ad hoc: en una piconet, un nodo central se comunica con hasta otros siete. Una vez establecida, la conexión sigue abierta aunque no circulen datos, hasta que una de las partes la cierra de forma explícita. En la parte de radio, trabaja en la banda ISM de 2,4 GHz con 40 canales.

Las dos capas que importan a quien desarrolla una app son GAP y GATT:

  • GAP (Generic Access Profile) define cómo se encuentran los dispositivos. Un periférico (broadcaster) anuncia paquetes; un central (observer) escucha y decide si se conecta.
  • GATT (Generic Attribute Profile) define cómo se intercambian los datos una vez conectados. El servidor guarda los datos; el cliente los lee o los escribe.

Por debajo, el Attribute Protocol describe cada dato como un atributo con un handle de 16 bits, un UUID, unos permisos y un valor. También define dos formas de que el servidor envíe cambios: las notificaciones, sin confirmación, y las indicaciones, que el cliente confirma.

El lado central con Core Bluetooth

En iOS el framework es Core Bluetooth. La mayoría de las apps hacen de central: consumen datos de un periférico, como un pulsómetro. CBCentralManager se encarga del escaneo, el descubrimiento y la conexión, y cada dispositivo encontrado es un CBPeripheral.

import CoreBluetooth

final class HeartRateReader: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    private let heartRateService = CBUUID(string: "180D")
    private var central: CBCentralManager!
    private var peripheral: CBPeripheral?

    override init() {
        super.init()
        central = CBCentralManager(delegate: self, queue: nil)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        guard central.state == .poweredOn else { return }
        central.scanForPeripherals(withServices: [heartRateService])
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any], rssi RSSI: NSNumber) {
        self.peripheral = peripheral
        central.stopScan()
        central.connect(peripheral)
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        peripheral.delegate = self
        peripheral.discoverServices([heartRateService])
    }
}

A partir de ahí el flujo es siempre el mismo: descubrir servicios, descubrir características y, después, leer un valor o suscribirse con setNotifyValue(true, for:) y recibir los cambios en peripheral(_:didUpdateValueFor:error:).

Tres lecciones de esta parte:

  • No hagas nada hasta que el manager informe de .poweredOn. Escanear antes no hace nada, y en silencio.
  • Guarda una referencia fuerte al periférico al que te conectas. Si se libera, la conexión se cae y ningún error explica por qué.
  • Filtra el escaneo por UUID de servicio. Buscar cualquier dispositivo es más lento y gasta más batería.

El rol de periférico (CBPeripheralManager) es la imagen inversa: publicas servicios y características y respondes a lecturas, escrituras y suscripciones. Trabaja a un nivel de abstracción más alto, más cercano a intercambiar mensajes que a pelearse con los detalles de la radio.

Localización: la precisión cuesta batería

Core Location combina varias fuentes: el GPS (GPS asistido en el iPhone, que obtiene la posición mucho más rápido), la identificación de la antena de telefonía y el posicionamiento por wifi basado en direcciones de red conocidas. La regla que dio forma a RunTracker es sencilla: más precisión significa más batería. Por eso la app ofrece la precisión del GPS como ajuste del usuario (óptima, media o baja), junto a la pausa automática, la cadencia y los intervalos de distancia y tiempo, en lugar de pedir siempre la máxima precisión.

CLLocationManager es el punto de entrada. Cada CLLocation incluye la coordenada, la precisión horizontal y vertical, la altitud, la planta (casi nunca disponible) y una marca de tiempo. La distancia entre dos posiciones se calcula como si ambas estuvieran a nivel del mar, teniendo en cuenta la curvatura de la Tierra. Para sumar los kilómetros de una carrera es suficiente, pero conviene saberlo si la altitud te importa. Cada carrera terminada se guarda con su fecha, la distancia en kilómetros y el tiempo empleado.

Geofencing: que el sistema vigile por ti

El geofencing define límites virtuales sobre una zona real y te avisa cuando el usuario los cruza, incluso con la app cerrada. Describes una región circular y pides al location manager que la monitorice:

let center = CLLocationCoordinate2D(latitude: 40.4168, longitude: -3.7038)
let region = CLCircularRegion(center: center, radius: 150, identifier: "meeting-point")
region.notifyOnEntry = true
region.notifyOnExit = true
locationManager.startMonitoring(for: region)

func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
    // reaccionar a la entrada
}

Conviene saber esto antes de depender de ello:

  • Una app puede monitorizar como máximo 20 regiones a la vez. Si tienes más puntos de interés, monitoriza solo los cercanos al usuario y actualiza la lista a medida que se mueve.
  • El cruce no es instantáneo: el usuario tiene que recorrer una distancia mínima más allá del límite y permanecer en el mismo lado unos 20 segundos antes de que salte el evento.
  • Para que el sistema despierte la app en segundo plano hacen falta la autorización adecuada y el modo de localización en segundo plano activado.

iBeacons: primero monitorizar, después medir la proximidad

Un iBeacon es un pequeño emisor BLE que difunde un identificador formado por un UUID, un valor mayor y un valor menor, por ejemplo B9407F30-F5F8-466E-AFF9-25556B57FE6D:8:2. Un uso típico es una tienda en la que cada sección tiene su propio beacon y la app destaca las ofertas cuando el cliente entra en ella.

El patrón que mantiene bajo el consumo de batería tiene dos pasos: monitorizar una región de beacons, algo barato que gestiona el sistema, y solo cuando el usuario entra en ella empezar el ranging para estimar la proximidad.

let uuid = UUID(uuidString: "B9407F30-F5F8-466E-AFF9-25556B57FE6D")!
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
let beaconRegion = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: "store")
locationManager.startMonitoring(for: beaconRegion)

func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
    guard let region = region as? CLBeaconRegion else { return }
    manager.startRangingBeacons(satisfying: region.beaconIdentityConstraint)
}

func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
                     satisfying constraint: CLBeaconIdentityConstraint) {
    guard let nearest = beacons.first(where: { $0.proximity != .unknown }) else { return }
    // nearest.major y nearest.minor identifican la sección en la que está el usuario
}

Si se omiten los valores mayor y menor, la región coincide con cualquier beacon de ese UUID, algo útil cuando una misma app cubre muchas zonas.

La lección que más tiempo me costó al probar Beacons: la intensidad de la señal no es una distancia. No calcules metros a partir de ella. Usa la proximidad que te da Core Location (inmediata, cerca, lejos o desconocida) y trata un valor de precisión negativo como "desconocido". Además, sin ranging activo es el sistema quien decide cuándo comprobar si hay beacons al alcance, así que detectar la entrada en una región puede tardar entre 15 y 20 minutos. Mientras pruebas parece un error; es el comportamiento esperado.

Con qué me quedo

Los dos proyectos me enseñaron lo mismo: delegar en el sistema todo lo posible (monitorización de regiones, notificaciones, reactivación en segundo plano) y encender las partes caras, el GPS continuo y el ranging, solo cuando de verdad hacen falta. Respetar los límites de la plataforma y probar en dispositivos reales: un simulador dice muy poco sobre señales de radio y batería.