Mobile development
Bluetooth Low Energy and geofencing on iOS: lessons from building Beacons and RunTracker
6 min read
Bluetooth Low Energy and geofencing on iOS: lessons from building Beacons and RunTracker
Two of the iOS projects on this site, Beacons and RunTracker, rely on the same idea: the app reacts to where the user is, not only to what they tap. One does it with Bluetooth Low Energy signals, the other with GPS. Building them taught me that the hard part is not the API calls but understanding what the system does for you, what it does not, and what each choice costs in battery.
How BLE is organised
Bluetooth Low Energy does not require pairing. Devices form small ad hoc networks: in a piconet, one central node talks to up to seven others. Once a connection is established it stays open even when no data flows, until one side explicitly closes it. On the radio side it works in the 2.4 GHz ISM band with 40 channels.
The two layers that matter for an app developer are GAP and GATT:
- GAP (Generic Access Profile) defines how devices find each other. A peripheral (broadcaster) advertises packets; a central (observer) listens and decides whether to connect.
- GATT (Generic Attribute Profile) defines how data is exchanged once connected. The server holds the data; the client reads or writes it.
Underneath, the Attribute Protocol describes each piece of data as an attribute with a 16-bit handle, a UUID, permissions and a value. It also defines two ways for the server to push updates: notifications, which are not acknowledged, and indications, which the client acknowledges.
The central side with Core Bluetooth
On iOS the framework is Core Bluetooth. Most apps play the central role: they consume data from a peripheral such as a heart-rate monitor. CBCentralManager handles scanning, discovery and connection, and each device it finds is a 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])
}
}
From there the flow is always the same: discover services, discover characteristics, and then either read a value or subscribe with setNotifyValue(true, for:) and receive updates in peripheral(_:didUpdateValueFor:error:).
Three lessons from this part:
- Do nothing until the manager reports
.poweredOn. Scanning earlier silently does nothing. - Keep a strong reference to the peripheral you connect to. If it gets released, the connection drops and no error explains why.
- Filter the scan by service UUID. Scanning for everything is slower and drains more battery.
The peripheral role (CBPeripheralManager) is the mirror image: you publish services and characteristics and answer reads, writes and subscriptions. It works at a higher level of abstraction, closer to exchanging messages than to dealing with radio details.
Location: accuracy costs battery
Core Location combines several sources: GPS (assisted GPS on the iPhone, which gets a fix much faster), cell tower identification and Wi-Fi positioning based on known network addresses. The rule that shaped RunTracker is simple: more precision means more battery. That is why the app exposes GPS precision as a user setting (optimal, medium or low) next to auto-pause, cadence and distance and time intervals, instead of always asking for the best accuracy.
CLLocationManager is the entry point. Each CLLocation carries the coordinate, horizontal and vertical accuracy, altitude, floor (rarely available) and a timestamp. The distance between two locations is computed as if both were at sea level, taking the Earth's curvature into account. That is good enough to add up a run, but worth knowing if altitude matters to you. Every finished run is stored with its date, the distance in kilometres and the elapsed time.
Geofencing: let the system watch for you
Geofencing defines virtual boundaries over a real area and tells you when the user crosses them, even when your app is not running. You describe a circular region and ask the location manager to monitor it:
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) {
// react to the entry
}
Things worth knowing before relying on it:
- An app can monitor at most 20 regions at once. If you have more points of interest, monitor only the ones near the user and refresh the list as they move.
- Crossing is not instantaneous: the user has to move a minimum distance past the boundary and stay on the same side for around 20 seconds before the event fires.
- To be woken up in the background you need the right authorization and the location background mode enabled.
iBeacons: monitoring first, then ranging
An iBeacon is a small BLE transmitter that broadcasts an identifier made of a UUID, a major and a minor value, for example B9407F30-F5F8-466E-AFF9-25556B57FE6D:8:2. A typical use is a shop where each section has its own beacon and the app highlights offers when the customer walks into it.
The pattern that keeps battery usage low has two steps: monitor a beacon region, which is cheap and handled by the system, and only when the user enters it start ranging to estimate proximity.
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 and nearest.minor identify the section the user is in
}
Leaving out the major and minor values matches every beacon with that UUID, which is useful when a single app covers many regions.
The lesson that cost me the most time while testing Beacons: signal strength is not a distance. Do not compute metres from it. Use the proximity that Core Location gives you (immediate, near, far or unknown) and treat a negative accuracy value as "unknown". And without active ranging, the system decides when to check for beacons, so entering a region can take 15 to 20 minutes to be detected. During testing that looks like a bug; it is expected behaviour.
Takeaways
Both projects taught me the same thing: delegate as much as possible to the system (region monitoring, notifications, background wake-ups) and only switch on the expensive parts, continuous GPS and ranging, when you actually need them. Respect the platform limits and test on real devices: a simulator tells you very little about radio signals and battery.