移动开发
iOS 上的低功耗蓝牙与地理围栏:开发 Beacons 和 RunTracker 的经验
阅读需 11 分钟
iOS 上的低功耗蓝牙与地理围栏:开发 Beacons 和 RunTracker 的经验
本站的两个 iOS 项目 Beacons 和 RunTracker 基于同一个思路:应用不仅响应用户的点击,还要根据用户所在的位置做出反应。一个依靠低功耗蓝牙(BLE)信号,另一个依靠 GPS。开发它们让我认识到,难点并不在于调用 API,而在于弄清楚系统替你做了什么、没做什么,以及每个选择要付出多少电量代价。
BLE 的基本架构
低功耗蓝牙不需要配对。设备之间会组成小型的自组网络:在一个微微网(piconet)中,一个中心节点最多可以与另外七个设备通信。连接一旦建立,即使没有数据传输也会一直保持,直到其中一方主动断开。在射频层面,它工作在 2.4 GHz 的 ISM 频段,共有 40 个信道。
对应用开发者来说,最重要的是 GAP 和 GATT 两层:
- GAP(通用访问规范) 定义设备之间如何发现彼此。外设(peripheral,广播者)负责发送广播包;中心设备(central,观察者)负责监听,并决定是否发起连接。
- GATT(通用属性规范) 定义连接建立后如何交换数据。服务端保存数据,客户端读取或写入数据。
在更底层,属性协议(ATT)把每一项数据描述为一个属性,包含 16 位句柄、UUID、权限和值。它还定义了服务端主动推送更新的两种方式:不需要确认的通知(notification),以及需要客户端确认的指示(indication)。
用 Core Bluetooth 实现中心设备
在 iOS 上使用的框架是 Core Bluetooth。大多数应用扮演的是中心设备的角色:从心率带这样的外设读取数据。CBCentralManager 负责扫描、发现和连接,找到的每个设备都是一个 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])
}
}
之后的流程总是一样的:发现服务、发现特征值,然后要么读取某个值,要么用 setNotifyValue(true, for:) 订阅,并在 peripheral(_:didUpdateValueFor:error:) 中接收更新。
这部分我总结了三条经验:
- 在管理器报告
.poweredOn之前什么都不要做。提前扫描不会有任何效果,而且不会报错。 - 对要连接的外设保持强引用。一旦它被释放,连接就会断开,而且没有任何错误提示原因。
- 按服务 UUID 过滤扫描。扫描所有设备速度更慢,也更耗电。
外设角色(CBPeripheralManager)正好相反:你发布服务和特征值,并响应读、写和订阅请求。它的抽象层次更高,更像是在交换消息,而不是处理射频细节。
定位:精度越高越耗电
Core Location 会综合多种数据源:GPS(iPhone 使用辅助 GPS,定位速度快得多)、基站识别,以及基于已知网络地址的 Wi-Fi 定位。决定 RunTracker 设计的规则很简单:精度越高,耗电越多。因此应用把 GPS 精度做成了用户可调的设置(最佳、中等、低),与自动暂停、步频以及距离和时间间隔并列,而不是始终请求最高精度。
CLLocationManager 是入口。每个 CLLocation 包含坐标、水平和垂直精度、海拔、楼层(很少可用)以及时间戳。两点之间的距离是假设两者都处于海平面、并考虑地球曲率计算出来的。用来累计一次跑步的距离已经足够,但如果你关心海拔,就需要了解这一点。每次跑步结束后,会保存日期、以公里计的距离和用时。
地理围栏:让系统替你盯着
地理围栏在真实区域上划定虚拟边界,当用户穿越边界时通知你,即使应用没有在运行。你只需描述一个圆形区域,并让 location manager 对其进行监控:
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) {
// 响应进入事件
}
在依赖它之前,有几点值得了解:
- 一个应用最多只能同时监控 20 个区域。如果兴趣点更多,就只监控用户附近的那些,并随着用户移动不断更新列表。
- 穿越事件并不是即时触发的:用户需要越过边界一段最小距离,并在同一侧停留大约 20 秒,事件才会触发。
- 要让系统在后台唤醒应用,需要相应的授权,并开启后台定位模式。
iBeacon:先监控,再测距
iBeacon 是一种小型 BLE 发射器,广播由 UUID、major 值和 minor 值组成的标识,例如 B9407F30-F5F8-466E-AFF9-25556B57FE6D:8:2。典型的应用场景是商店:每个区域都有自己的 beacon,顾客走进某个区域时,应用就会突出显示该区域的优惠。
能够控制耗电的模式分为两步:先监控一个 beacon 区域——这一步开销很小,由系统负责;只有当用户进入该区域后,才开始测距(ranging)来估算距离远近。
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 和 nearest.minor 用来识别用户所在的区域
}
如果省略 major 和 minor 值,该区域会匹配所有使用这个 UUID 的 beacon,这在一个应用需要覆盖很多区域时非常有用。
测试 Beacons 时让我花费时间最多的一课是:信号强度并不等于距离。不要用它去换算米数。请使用 Core Location 提供的远近程度(immediate、near、far 或 unknown),并把负的精度值视为"未知"。另外,在没有开启测距的情况下,何时检测附近是否有 beacon 由系统决定,因此进入区域后可能要过 15 到 20 分钟才会被检测到。测试时这看起来像是 bug,但其实是预期行为。
我的总结
这两个项目教给我的是同一件事:尽可能把工作交给系统(区域监控、通知、后台唤醒),只有在真正需要时才开启那些代价高的部分,比如持续 GPS 定位和测距。尊重平台的限制,并在真机上测试:模拟器几乎无法告诉你任何关于射频信号和电量的真实情况。