跳至正文
Ende Sun
返回博客

移动开发

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 定位和测距。尊重平台的限制,并在真机上测试:模拟器几乎无法告诉你任何关于射频信号和电量的真实情况。