原文:Core Bluetooth Tutorial for iOS: Heart Rate Monitor
作者:Jawwad Ahmad
译者:kmyhy更新说明:本教程基于 Xcode 9 和 iOS 11,作者 Jawwad Ahmad。原版 O-C 教程作者是 Steven Daniel。
如今便携设备越来越盛行,设备之间的通信使得这些工具及其产生的数据以更一种更有效的方式发挥出更大用途。为此,苹果推出了 Core Bluetooth 框架,它能够和各种物理设备发生通讯,比如心率监测器,数字恒温器、健身器材等。凡是你能够通过 BLE(低功耗蓝牙)无线技术连上的东西,Core Bluetooth 框架都能连接。
在本教程中,你将学习 Core Bluetooth 的主要概念,如何发现、连接和查询来自于兼容设备的数据。你将用这些技术编写一个能够和蓝牙心率传感器通讯的心率监测 app。
本教程使用的蓝牙心率传感器叫做 Polar H7 蓝牙心率传感器,但其实用哪种心率传感器都无所谓。
首先,来看一下几个蓝牙技术名词:centrals、peripherals、services 和 characteristics。
Centrals 和 Peripherals
一个蓝牙设备可以既是 central 又是 peripheral:
- Central 中心设备:从蓝牙设备接收数据。
- Peripheral 外围设备:发布数据供其它设备进行消费的蓝牙设备。
在本教程中,iOS 设备是中心设备,它会从外围设备接收心率数据。
广告包
蓝牙外围设备会以广告包(advertising packets)形式广播某些数据。这些数据包包含诸如外围设备名称、主要功能等信息。它们还包含一些关于该外围设备能够提供的数据类型的附加数据。
中心设备的责任是扫描这些广告包,找出它所关心的外围设备,连接某个设备以便接收数据。
服务和特征
广告包非常小,不可能包含很大的信息。要获得更多数据,中心设备需要连接上某个外围设备。
外围设备数据分成服务(services) 和特征(characteristics):
- 服务:一种数据集合,加上相关行为,描述某种功能或者一个外设的特性。例如,对于心率传感器来说,会拥有一个心率(Heart Rate)服务。一个外设可以拥有不止一种服务。
- 特征:提供关于某个外设服务的更具体的信息。例如,对于心率服务,会包含一个特征-心率测量,这个特征包含了每分钟内的心跳次数。一个服务可以包含不止一个特征。其它心率服务的特征还有人体传感器位置,这是一个字符串,描述目标传感器的身体部位。
每个服务和特征都用一个 UUID 来代表,这个 UUID 是一个 16 位或者 128 位值。
开始
首先,下载本教程的开始项目。这是一个很简单的 app,显示了目标传感器的身体部位和心率。开始项目的心率传感器数据显示的是占位字符串。
开始项目:
最终项目:
注意:iOS 模拟器不支持蓝牙——你必须在真机上运行。
在开始编码前,你必须设置项目的 Team。选择项目导航器中的最上层,选择 HeartRateMonitor 这个 target,在 General ▸ Signing 一栏,设置 Team 为你的 Apple ID(此外,你还需要设置项目的 Bundle ID…)
然后,运行 app。如果出现报错,你在设备的设置程序中找到 通用 ▸ 设备管理 一项,信任你的 Apple ID。然后,你就能够从 Xcode 中运行 app 到 iOS 设备上。
准备 Core Bluetooth
首先需要导入 Core Bluetooth 框架。打开 HRMViewController.swift 添加:
import CoreBluetooth
Core Bluetooth 中的大部分工作是在委托方法中进行的。中心设备用 CBCentralManager 表示,它的委托是 CBCentralManagerDelegate。CBPeripheral 表示外设,它的委托是 CBPeripheralDelegate。
你可以用 Xcode 来为你添加所需方法。第一件事情就是实现 CBCentralManagerDelegate,但你可以用 Xcode 的 fix-it 功能添加所需方法。
在 HRMViewController.swift 的类声明之外添加一个扩展:
extension HRMViewController: CBCentralManagerDelegate {
}
你会看到有一个 Xcode 报错出现了。点击红点,展开错误信息,然后点击 Fix 让 Xcode 添加所需的协议方法。
问我要不要添加协议空方法?我当然要了,这不是废话么?
Xcode 为你添加了一个 centralManagerDidUpdateState(_:) 方法。在这个方法中添加一个空的 switch 语句,用于处理各种中心设备的状态:
switch central.state {
}
很快,你会发现又有错误了:switch 语句必须被穷举。点击红点,点击 Fix 让 Xcode 添加全部的 case 子句:
Xcode,你太贴心了!
Xcode 会很给力地为你添加下面的代码:
你可以将其中的占位符替换成下列代码,或者用复制粘贴替换整条 swich 语句:
switch central.state {
case .unknown:
print("central.state is .unknown")
case .resetting:
print("central.state is .resetting")
case .unsupported:
print("central.state is .unsupported")
case .unauthorized:
print("central.state is .unauthorized")
case .poweredOff:
print("central.state is .poweredOff")
case .poweredOn:
print("central.state is .poweredOn")
}
如果你现在 build & run,控制台什么也不会打印,因为你还没有创建 CBCentralManager。
在 bodySensorLocationLabel 属性下面添加一个实例变量:
var centralManager: CBCentralManager!
然后,在 viewDidLoad() 开头初始化它:
centralManager = CBCentralManager(delegate: self, queue: nil)
Build & run,你会看到控制台中打印出:
central.state is .poweredOn
注意:如果设备上的蓝牙是关闭的,你看到的会是
central.state is .poweredOff
。如果是这样,请打开蓝牙,然后重启 app。
现在中心设备已经开启,接下来就是让中心设备找出心率传感器。用蓝牙的术语来说,中心设备必须扫描外围设备。
扫描外围设备
因为你要加的方法太多了,我就不给出完整的方法名了,而是提示你如何找到你想要的方法。比如,你想看 centralManager 中是否有一个方法你可以用它来进行扫描。
在初始化 centralManager 之后添加一句,首先输入 centralManager.scan,看是否能够找到一个可用的方法:
scanForPeripherals(withServices: [CBUUID]?, options: [String: Any]?) 方法看起来很合适。选择它,在 withServices: 参数中传入 nil,然后删除 options: 参数,因为我们用不到它。最终变成这样:
centralManager.scanForPeripherals(withServices: nil)
Build & run。扫一眼控制台,注意看 API MISUSE 消息:
API MISUSE: <CBCentralManager: 0x1c4462180> can only accept this command while in the powered on state
呃,说得有理。你应该在 central.state 为 .poweredOn 的时候才进行扫描。
将 scanForPeripherals 一句从 viewDidLoad() 中移到 centralManagerDidUpdateState(_:) 的 .poweredOn case 分支内。现在的 .poweredOn 分支应该是这样:
case .poweredOn:
print("central.state is .poweredOn")
centralManager.scanForPeripherals(withServices: nil)
}
Build & run,查看控制台。API MISUSE 消息消失了。漂亮!但怎样才能找到心率传感器呢?
很可能我们需要用某个委托方法来判断它所找到的外围设备。用蓝牙术语来讲,查找外设被称作发现(discovering),因此委托方法中应该包含 discovr 一词。
在 centralManagerDidUpdateState(_:) 方法的下面,先输入 discover。方法名太长了,根本无法看完整,但以 centralManager 开头的那个方法无疑是正确的:
选择那个方法,将里面的内容替换成 print(peripheral),变成:
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
print(peripheral)
}
Build & run,你会看到许多蓝牙设备,这就要看在你周围有多少便携设备了。
<CBPeripheral: 0x1c4105fa0, identifier = D69A9892-...21E4, name = Your Computer Name, state = disconnected>
<CBPeripheral: 0x1c010a710, identifier = CBE94B09-...0C8A, name = Tile, state = disconnected>
<CBPeripheral: 0x1c010ab00, identifier = FCA1F687-...DC19, name = Your Apple Watch, state = disconnected>
<CBPeripheral: 0x1c010ab00, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>
其中有一个就是你的心率传感器,如果你正好戴着它,而且也有一个有效的心率的话。
扫描拥有指定服务的外设
如果你只扫描心率传感器就更好了,因为这是你最感兴趣的外设,不是吗?用蓝牙术语来说,你只想扫描提供心率服务的外设。要这样做,你需要知道心率服务的 UUID。在蓝牙服务规范页的服务列表中搜索 heart rate,找到了这个:0x180D.
通过这个 UUID,我们可以创建一个 CBUUID 对象并传递给 scanForPeripherals(withServices:) 方法,这个参数实际上是一个数组。也就是说,它是一个只包含一个 CBUUID 对象的数组,因为你只对心率服务感兴趣。
在文件头部 import 语句之下添加这句:
let heartRateServiceCBUUID = CBUUID(string: "0x180D")
将 canForPeripherals(withServices: nil) 修改为:
centralManager.scanForPeripherals(withServices: [heartRateServiceCBUUID])
Build & run,这次发现的只有心率传感器:
<CBPeripheral: 0x1c0117220, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>
<CBPeripheral: 0x1c0117190, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>
接下来需要保存心率外围设备的引用,然后停止扫描。
就在 centralManager 之下添加一个 CBPeripheral 类型的实例变量 :
var heartRatePeripheral: CBPeripheral!
一旦找到了外设,保存它的引用并停止扫描。在 centralManager(_:didDiscover:advertisementData:rssi:) 的 print(peripheral) 之后添加:
heartRatePeripheral = peripheral
centralManager.stopScan()
Build & run,你会看到这次只打印了一个外设。
<CBPeripheral: 0x1c010ccc0, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>
连接一个外设
要想从外设获取数据,你必须连接上它。在 centralManager.stopScan() 后面,开始输入 centralManager.connect 你会看到提示了一个 connect(peripheral: CBPeripheral, options: [String: Any]?) 方法:
选择它 ,第一个参数传入 heartRatePeripheral 然后删除 options: 参数,最终变成:
centralManager.connect(heartRatePeripheral)
漂亮!你不光是发现了心率传感器,而且还连接上了它!但怎样确认它真的是连上了能?肯定有一个委托方法是和 connect 有关系的。在
centralManager(_:didDiscover:advertisementData:rssi:)
委托方法之后,输入 connect,选择 centralManager(_:didConnect:)
:
修改方法中内容:
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
print("Connected!")
}
Build & run;你会在控制台中看到 Connected! 表明你真的已经连上了!
Connected!
发现外设服务
连接上外设了,接下来是发现外设上的服务。是的,虽然你已经在扫描外设时指明了心率服务,也知道这个外设肯定支持该服务,但为了使用这个服务,你还是得发现这个服务。
在连接成功之后,调用 discoverServices(nil) 方法来发现外设的 services:
heartRatePeripheral.discoverServices(nil)
在这里,你可以传入服务的 UUID,但现在,我们想发现所有有效的服务,看看这个心率传感器还能干些什么。
Build & run 你会在控制台中看到两个 API MISUSE 消息:
API MISUSE: Discovering services for peripheral <CBPeripheral: 0x1c010f6f0, ...> while delegate is either nil or does not implement peripheral:didDiscoverServices:
API MISUSE: <CBPeripheral: 0x1c010f6f0, ...> can only accept commands while in the connected state
第二条消息表明外围设备只能在连接上之后才能接受命令。这是因为你初始化了一个到外设的连接,但没有等它完成连接就调用了 discoverServices(:) 方法。
将 heartRatePeripheral.discoverServices(nil)
移到 centralManager(_:didConnect:) 方法中,位于 print("Connected!"). centralManager(_:didConnect:)
一句下面:
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
print("Connected!")
heartRatePeripheral.discoverServices(nil)
}
Build & run。现在只会提示另一条 API MISUSE 消息了:
API MISUSE: Discovering services for peripheral <CBPeripheral: ...> while delegate is either nil or does not implement peripheral:didDiscoverServices:
Core Bluetooth 框架告诉你,你正在发现服务,但你没有实现 peripheral(_:didDiscoverServices:) 委托方法。
通过方法名可知,这是一个关于外围设备的委托方法,因此你必须实现 CBPeripheralDelegate 来实现它。
在文件末尾添加这个扩展:
extension HRMViewController: CBPeripheralDelegate {
}
由于这个委托没有 required 方法,这次 Xcode 无法为我们自动添加必须的方法了。
在这个扩展中,输入 discover 并选择 peripheral(_:didDiscoverServices:):
注意,这个方法不会提供一个已经发现的服务的列表,而只提供了一个 peripheral 对象。这是因为 peripheral 对象有一个属性保存了服务列表。在这个方法中添加代码:
guard let services = peripheral.services else { return }
for service in services {
print(service)
}
Build & run,打开控制台。你会看到没有任何输出,事实上,你仍然还是会看到那 API MISUSE 消息。知道为什么吗?
因为你没有为 heartRatePeripheral 对象指定一个 delegate。在 centralManager(_:didDiscover:advertisementData:rssi:)
方法的 heartRatePeripheral = peripheral
一句后添加:
heartRatePeripheral.delegate = self
Build & run,你会看到控制台中输出:
<CBService: 0x1c046f280, isPrimary = YES, UUID = Heart Rate>
<CBService: 0x1c046f5c0, isPrimary = YES, UUID = Device Information>
<CBService: 0x1c046f600, isPrimary = YES, UUID = Battery>
<CBService: 0x1c046f680, isPrimary = YES, UUID = 6217FF4B-FB31-1140-AD5A-A45545D7ECF3>
要获得你感兴趣的服务,可以将服务的 CBUUID 传递给 discoverServices(_:)
方法。因为你只对心率服务感兴趣,所以将 centralManager(_:didConnect:)
方法中的 discoverServices(nil)
的一句改为:
heartRatePeripheral.discoverServices([heartRateServiceCBUUID])
Build & run,你会看到你只看到心率服务被打印:
<CBService: 0x1c046f280, isPrimary = YES, UUID = Heart Rate>
发现服务的特征
心率测量是心率服务的一个特征。在 peripheral(_:didDiscoverServices:)
方法的 print(service)
一句后添加:
print(service.characteristics ?? "characteristics are nil")
Build & run,观察控制台中的输出:
characteristics are nil
要获取某个服务的特征,你必须显式地请求对该服务的特征进行发现:
将刚刚的 print 语句替换成:
peripheral.discoverCharacteristics(nil, for: service)
Build & run,控制台中的 API MISUSE 又提示我们下一步该做什么了:
API MISUSE: Discovering characteristics on peripheral <CBPeripheral: 0x1c0119110, ...> while delegate is either nil or does not implement peripheral:didDiscoverCharacteristicsForService:error:
你必须实现 peripheral(_:didDiscoverCharacteristicsFor:error:)
方法。在 peripheral(_:didDiscoverServices:)
添加这个方法,以打印出特征对象:
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
print(characteristic)
}
}
Build & run。你会看到控制台中打印:
<CBCharacteristic: 0x1c00b0920, UUID = 2A37, properties = 0x10, value = (null), notifying = NO>
<CBCharacteristic: 0x1c00af300, UUID = 2A38, properties = 0x2, value = (null), notifying = NO>
这表明心率服务有两个特征。如果你使用的传感器不是 Polar H7,可能还会有其它特征。一个特征的 UUID 是 UUID 2A37, 另一个是 2A38。哪个才是心率测量特征呢?你可以在蓝牙规范页的特征一节中搜索这两个 UUID 看看。
在蓝牙规范页中,你会看到 2A37 表示心率测量(Heart Rate Measurement),而 2A38 表示身体传感器部位(Body Sensor Location)。
在文件头部,heartRateServiceCBUUID 的下面添加两个常量。在 UUID 前面可以添加 0x 前缀,这是可选的。
let heartRateMeasurementCharacteristicCBUUID = CBUUID(string: "2A37")
let bodySensorLocationCharacteristicCBUUID = CBUUID(string: "2A38")
每个特征都有一个 CBCharacteristicProperties 属性叫做 properties,它是一个 OptionSet。在 CBCharacteristicProperties 的文档中我们可以看到 properties 被分为了不同的类型,但这里你只用关心两种:.read 和 .notify。对于不同的特征值,我们要用不同的方式获取。
查看特征的 properties
在 peripheral(_:didDiscoverCharacteristicsFor:error:)
的 print(characteristic)
一句后添加下列代码,以查看特征的 properties:
if characteristic.properties.contains(.read) {
print("\(characteristic.uuid): properties contains .read")
}
if characteristic.properties.contains(.notify) {
print("\(characteristic.uuid): properties contains .notify")
}
Build & run,你会在控制台中看到:
2A37: properties contains .notify
2A38: properties contains .read
特征 2A37 characteristic — 心率测量 — 在它的值发生变化时会通知你,因此你必须通过订阅的方式来接收这个改变。特征 2A38 — 传感器佩戴位置 — 允许你直接读取……当然也不完全是直接的。你会在下一节明白这个意思。
获取传感器佩戴位置
因为读取传感器佩戴位置相对于心率来说要简单,我们先来获取它。
在刚刚添加的的 print(“(characteristic.uuid): properties contains .read”) 后面添加:
peripheral.readValue(for: characteristic)
但是这个值会被读进哪里呢?Build & run 试试,看 Xcode 控制台中会给我们什么暗示:
API MISUSE: Reading characteristic value for peripheral <CBPeripheral: 0x1c410b760, ...> while delegate is either nil or does not implement peripheral:didUpdateValueForCharacteristic:error:
Core Bluetooth 框架告诉你,你正在读取某个特征值,但 peripheral(_:didUpdateValueFor:error:) 没有实现。乍一看,这个方法好像只有对那些会在改变后通知你的特征有用,比如心率。但是,对于通过 read 方式直接读取的值也需要实现这个方法。read 操作是异步的:你提交一个 read 请求,当值被成功读取后才通知你。
在 CBPeripheralDelegate 扩展中新增方法:
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
switch characteristic.uuid {
case bodySensorLocationCharacteristicCBUUID:
print(characteristic.value ?? "no value")
default:
print("Unhandled Characteristic UUID: \(characteristic.uuid)")
}
}
Build & run;你会看到控制台中打印出 “1 bytes”,当你直接打印一个 Data 对象时,打印出来的就是这个样子。
转换特征值的二进制值
要了解如何转换特征中的数据,必须参考蓝牙规范中的关于这个特征的描述。点击蓝牙特征中的 Body Sensor Location 链接,你会看到这个:
这个规范表明了 Body Sensor Location 是一个 8 位值,它的值有 255 个,目前只使用到了 0-6。根据这个规范,添加一个助手方法到 CBPeripheralDelegate 扩展中:
private func bodyLocation(from characteristic: CBCharacteristic) -> String {
guard let characteristicData = characteristic.value,
let byte = characteristicData.first else { return "Error" }
switch byte {
case 0: return "Other"
case 1: return "Chest"
case 2: return "Wrist"
case 3: return "Finger"
case 4: return "Hand"
case 5: return "Ear Lobe"
case 6: return "Foot"
default:
return "Reserved for future use"
}
}
因为这个规范中说这个数据只有一个字节,你可以调用 Data 对象的 first 方法来获取它的第一个字节。
修改 peripheral(_:didUpdateValueFor:error:) 方法为:
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
switch characteristic.uuid {
case bodySensorLocationCharacteristicCBUUID:
let bodySensorLocation = bodyLocation(from: characteristic)
bodySensorLocationLabel.text = bodySensorLocation
default:
print("Unhandled Characteristic UUID: \(characteristic.uuid)")
}
}
这里调用了助手方法来刷新 UI 中的标签。Build & run,你会看到传感器的佩戴位置显示了:
我还能把它戴在哪呢?
获得心率测量数据
最后是你期待已久的事情!
心率测量数据的特征属性中包含了 .notify,因此你必须向它订阅接收更新通知。你要用的这个方法有点奇怪: 它叫做 setNotifyValue(_:for:)
。
在 peripheral(_:didDiscoverCharacteristicsFor:error:) 方法的 print(“(characteristic.uuid): properties contains .notify”): 一句后添加:
peripheral.setNotifyValue(true, for: characteristic)
Build & run,你会看到有一连串的 “Unhandled Characteristic UUID: 2A37” 消息打印出来:
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
恭喜你!在这个特征值中包含有你的心率。心率测量规范中的描述比传感器佩戴位置的描述要复杂的多。来看看都有什么:心率测量特征:
第一个字节是一堆标志,而第一个字节的第一个 bit 用于表示数据是 8-bit 还是 16-bit。0 表示数据格式为 UINT8,也就是 8 位数字,1 则表示数据是一个 UINT16,16 位数字。
之所以这样,是因为你的心跳数在绝大部分情况下不可能超过每分钟 255 下,那么用一个 8 位数就足够了。在极少数情况下,心跳超过了 255 bpm,则需要加长一个字节才能表示得下了。最大可以达到 65,535 bpm!
因此你现在需要判断心率是 1 个字节还是 2 个字节。第一个字节用于各种标志,因此心率要么是第 2 个字节,要么是第 2、第 3 个字节。在 Flags 的 Format 一栏写的是 8bit,你就应该知道 flags 字段的长度为一个字节。
注意,在最后一栏中,也就是标题为 Requires 的那栏,当这个 bit 值 0 时显示 C1,当这个值为 1 时显示 C2。
向下拉到 C1 和 C2 字段,你立即会看见在这个规范后面是第一个字节:
在 CBPeripheralDelegate 扩展最后添加一个助手方法,用于从特征中获得心率值:
private func heartRate(from characteristic: CBCharacteristic) -> Int {
guard let characteristicData = characteristic.value else { return -1 }
let byteArray = [UInt8](characteristicData)
let firstBitValue = byteArray[0] & 0x01
if firstBitValue == 0 {
// 心率值是第 2 个字节
return Int(byteArray[1])
} else {
// 心率值是第 2、3 个字节
return (Int(byteArray[1]) << 8) + Int(byteArray[2])
}
}
characteristic.value 是一个 Data 对象,你可以将它转成一个字节数组。根据第一个字节的第一位数,你可以读取第二个字节 byteArray[1] 或者将第 2-3 个字节合起来获取数据。第二个字节被左移 8 位,相当于乘以 256。这相当于:(第 2 字节 * 256) + (第 3 字节)。
最后,在 peripheral(_:didUpdateValueFor:error:) 方法的 default 分支之前增加一个 case 分支,以便从特征值中读取心率。
case heartRateMeasurementCharacteristicCBUUID:
let bpm = heartRate(from: characteristic)
onHeartRateReceived(bpm)
onHeartRateReceived(_:) 方法会用指定心率刷新 UI。
Build & run,你会看到心率数显示了。做一点运动,看看你的心率有没有增加!
接下来做什么?
这里是完整的项目代码。
在本教程中,你学习了 Core Bluetooth 框架,以及如何用它连接和从蓝牙设备中读取数据。
你还可以看看苹果官方的 iOS app 能效指南中的蓝牙最佳体验一节。
如果想学习 iBeacon,请阅读我们的 iOS iBeacon Swift 教程。
有任何问题和建议,请在下面留言!