iOS蓝牙开发CoreBluetooth全面讲解
关于Core Bluetooth
蓝牙低功耗无线技术基于蓝牙 4.0 规范,其中定义了一组用于在低功耗设备之间进行通信的协议。CoreBluetooth.framework是蓝牙低功耗协议栈的抽象。也就是说,它对开发人员隐藏了规范的许多底层细节,使您更容易开发与蓝牙低功耗设备交互的应用程序。
下面是一个蓝牙低功耗协议栈的描述图:
GATT:是 Generic ATtribute Profile 的首字母缩写词,它定义了两个低功耗蓝牙设备使用称为服务(CBService)和特征(CBCharacteristic)的概念来回传输数据的方式,它具有一个或多个蓝牙服务(CBService),由唯一的 id 标识区分,每个服务又包含一个或多个蓝牙特征(CBCharacteristic),每个特征也是由唯一的 id 标识区分。GATT 客户端实现了扫描正在广播的蓝牙设备,连接到选定的蓝牙外围设备,发现服务,发现特征,然后读取、写入或建立连接以接收来自特征的通知。
ATT:是Attribute Protocol 的首字母缩写词,是低功耗蓝牙协议栈中的一种协议。它定义了数据在蓝牙服务器(外设)数据库中的表示方式以及读取或写入数据的方法。例如:健身追踪器会收集有关您的步数和心率的数据。它充当服务器,保存这些数据,直到客户端智能手机请求它。该数据作为属性存储在蓝牙外设服务器上。
L2CAP:是Logical Link Control and Adaptation Protocol 的首字母缩写词,即逻辑链路控制和适配协议,是蓝牙系统中的核心协议。
关于GATT与ATT的区别可以查看这篇文章,这里就不过多的阐述。
想要更详细了解Bluetooth,可以查看Bluetooth官方网站
中央设备,即:手机,电脑等(CBCentralManager)与外围设备,即:健身追踪器等(CBPeripheral)是CoreBluetooth.framework框架的核心部分。中央设备通常使用外围设备提供的信息来完成某些任务,而外围设备通常具有其他设备所需的数据,如:心率数据。当中心设备发现这样的外围设备时,中心设备请求连接到外围设备并开始探索外围设备的数据并与之交互。
下图描述了中心设备与外围设备的关系
CoreBluetooth.framework简化了常见的蓝牙任务,如果您正在开发实现中心设备角色的应用程序,Core Bluetooth 可以轻松发现和连接外围设备,以及探索和交互外围设备的数据。另外,Core Bluetooth 还可以轻松的让您的本地设备(手机)实现外围设备角色。
下面对CoreBluetooth.framework框架中所涉及的类的描述做一下简单的介绍
外围设备可能包含一项或多项服务或提供有关其连接信号强度的有用信息,服务(CBService)是用于完成设备(或该设备的一部分)的功能或特征(CBCharacteristic)的数据和相关行为的集合。
服务本身由特征或包含的服务(即对其他服务的引用)组成。特征提供了有关外围设备服务的更多详细信息。例如,一个心率服务可能包含一个描述设备心率传感器预期身体位置的特征和另一个传输心率测量数据的特征,如下图:
在CoreBluetooth.framework框架中使用CBCentralManager作为中央设备,使用CBPeripheral作为外围设备,当然,有时手机也可作为外围设备,此时的外围设备使用CBPeripheralManager表示,而中心设备采用CBCentral表示。但大多数情况手机都作为中央设备使用,它们的关系可以用下图表示:
外围设备的数据用CBService和CBCharacteristic表示,它们之间的关系树如下图:
而当手机端作为外围设备时,它们的关系可以用下图表示:
本地外围设备(手机)的数据采用CBMutableService和CBMutableCharacteristic表示,它们之间的关系树如下图:
接下来让我们一步一步学习CoreBluetooth.framework框架的具体使用
一、手机端作为中央设备,来实现一些蓝牙任务。
具体包括:
- 启动中央管理器对象(CBCentralManager)
- 扫描并连接到正在做广播的外围设备
- 连接到外围设备后探索外围设备上的数据
- 向外围服务的特征值发送读写请求
- 订阅特征值以在更新时得到通知
在使用CoreBluetooth.framework时,我们需要向用户申请使用蓝牙的权限,必须在Info.plist中添加如下说明:
Privacy - Bluetooth Always Usage Description: 我们需要使用蓝牙设备连接,请您允许
1、启动中央管理器对象
因为CBCentralManager是作为中央设备管理器的核心类,因此我们需要对其进行初始化,方法很简单
init(delegate: CBCentralManagerDelegate?, queue: DispatchQueue?, options: [String : Any]? = nil)
delegate:中央设备的回调代理
queue:中央设备代理操作的所在队列,即delegate执行的所在队列。
options:其他附加选项,它包括如下可选项
-
CBCentralManagerOptionShowPowerAlertKey:对应一个Bool值,表示如果蓝牙在CBCentralManager实例化时是关闭的,则显示给用户一个警告对话框,提示用户去设置中开启蓝牙,缺省下不会触发弹窗警告,此时你可以自定义这个提示框。
-
CBCentralManagerOptionRestoreIdentifierKey:对应一个String值,用于后台运行时处理恢复中央设备的唯一标示符
上面的初始化完成后,中央管理器的状态回调将会被触发,如下:
func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOff: print("蓝牙已关闭,不可用") case .poweredOn: print("蓝牙已开启") @unknown default: print("其他状态") } }
状态包括如下几种:
-
unknown:未知状态
-
resetting:与系统服务的连接暂时丢失
-
unsupported:该平台不支持蓝牙低能耗中央设备
-
unauthorized:应用程序未被授权使用蓝牙低能耗角色。
-
poweredOff:蓝牙当前处于关闭状态,不可用
-
poweredOn:蓝牙当前处于打开状态,可用
有时我们需要主动查询用户对蓝牙使用的授权情况,CBCentralManager为我们提供了两种方法,一个实例方法,一个类方法,如下:
@available(iOS, introduced: 13.0, deprecated: 13.1) open var authorization: CBManagerAuthorization { get } @available(iOS 13.1, *) open class var authorization: CBManagerAuthorization { get }
2、扫描并连接到正在做广播的外围设备
当蓝牙状态处于poweredOn时,我们才可以进行下一步操作,扫描附近的外围设备,调用CBCentralManager实例的如下方法:
open func scanForPeripherals(withServices serviceUUIDs: [CBUUID]?, options: [String : Any]? = nil)
serviceUUIDs:指定你要查找的外围设备服务器ID,如果你不确定你要找的外围设备是哪个,你可以传nil,中央管理器会返回所有发现的外围设备。在实际开发中通常是需要指定您的外围设备ID。
options:可选项,它包括如下选项
-
CBCentralManagerScanOptionAllowDuplicatesKey:对应一个Bool值,指示扫描应该在不进行重复过滤的情况下运行。缺省情况下,发现多个相同的外围设备合并成一个发现事件回调,当为true时,它会对多个相同外围设备追个发起发现事件回调,但这样做可能对电池寿命和应用性能有不利影响。(由于外围设备会每秒钟不断的对外广播多个数据包,这样就会出现重复的发现事件)。关闭默认行为对于某些用例可能很有用,例如根据外围设备的接近度(使用外围设备接收信号强度指示器 (RSSI) 值)启动与外围设备的连接。
-
CBCentralManagerScanOptionSolicitedServiceUUIDsKey:对应一个Bool值,指示如果成功建立连接时应用程序挂起,系统应该显示给定外设的连接警报。这对于没有指定中央设备支持后台模式并且不能显示自己的警报的应用程序很有用。如果有多个应用程序为给定的外设请求通知,最近在前台的应用程序将收到警报。
每当扫描到外设都会执行下面的回调:
// 扫描到外部设备 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { // 扫描到新的外设,根据外设的唯一标示符,确定是您想要链接的外设,然后停止扫描 print("扫描到外设:【UUID: \(peripheral.identifier.uuidString), name: \(peripheral.name ?? "null")】;广播的数据:\(advertisementData);信号强度:\(RSSI)") if peripheral.identifier.uuidString == "xxx" { self.peripheral = peripheral central.stopScan() // 停止扫描 central.connect(peripheral, options: nil) } }
新的外设作为CBPeripheral对象返回。一旦您找到了您有兴趣连接的所有外围设备,请停止扫描其他设备以节省电量以节省电量。
CBPeripheral类包含一个name和identifier属性,你可以通过这两个属性中的任意一个作为你要找的外设标记,通常我们使用identifier。
advertisementData:是外设对外广播的数据,包括外设的本地名称(有可能不存在)、外设提供的服务UUID列表等。对应于CBPeripheralManager类的startAdvertising广播方法传递的参数。
rssi:外设的信号强度,dBm中外设的当前RSSI。127是保留值,表示RSSI不可用。设备内部的蓝牙无线电为每个可见设备提供 RSSI 测量值。它以分贝、dBm 为单位测量,采用对数刻度,为负数,值越小表示设备越远。例如,-20 到 -30 dBm 的值表示设备接近,而 -120 的值表示设备接近检测上限。
3、连接外围设备,探索外围设备上的数据
首先是让中央设备与外围设备建立连接,调用中央设备的如下方法:
open func connect(_ peripheral: CBPeripheral, options: [String : Any]? = nil)
peripheral:待连接的外围设备
options:可选项,包括如下选项
-
CBConnectPeripheralOptionNotifyOnConnectionKey:对应一个Bool值,指示如果成功建立连接时应用程序挂起,系统应该显示给定外设的连接警报。这对于没有指定中央设备支持后台模式并且不能显示自己的警报的应用程序很有用。如果有多个应用程序为给定的外设请求通知,最近在前台的应用程序将收到警报。
-
CBConnectPeripheralOptionNotifyOnDisconnectionKey:对应一个Bool值,指示如果应用程序暂停,系统应该对从给定外设接收到的所有通知显示一个警报。这对于没有指定中央设备支持后台模式并且不能显示自己的警报的应用程序很有用。如果有多个应用程序为给定的外设请求通知,最近在前台的应用程序将收到警报。
-
CBConnectPeripheralOptionNotifyOnNotificationKey:对应一个Bool值,指示如果应用程序暂停,系统应该对从给定外设接收到的所有通知显示一个警报。这对于没有指定中央设备支持后台模式并且不能显示自己的警报的应用程序很有用。如果有多个应用程序为给定的外设请求通知,最近在前台的应用程序将收到警报。
-
CBConnectPeripheralOptionEnableTransportBridgingKey:对应一个Bool值,表明系统将在连接外围设备的低能量传输时启动经典传输配置。
-
CBConnectPeripheralOptionRequiresANCS:对应一个Bool值,表示外设连接需要ANCS(苹果通知中心服务)。
建立连接动作会触发两种回调,一个成功,一个失败:
// 外设连接失败 func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { } // 外设连接成功 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { }
与外围设备建立了连接后,有时会因为一些原因导致连接中断或者您调用了中央设备的cancelPeripheralConnection取消连接,此时你会收到下面回调:
// 与外设断开连接 func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { if let error = error { print("与外设断开连接:\(error)") return } self.peripheral = nil }
4、发现外围设备服务并向外围服务的特征值发送读写请求
接下来是查找外围设备的服务列表,此时需要操作外围设备对象CBPeripheral,调用其搜索服务方法如下:(调用前需要先设置其代理,以便执行相应的回调)
// 外设连接成功 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { peripheral.delegate = self // 首先设置代理 peripheral.discoverServices(nil) // 搜索外设服务列表 }
discoverServices方法接收一个服务ID数组,如果你传的是nil,它将返回这个外围设备上的所有服务,通常在实际开发中需要指定这个服务ID,因为外设中可能包含很多服务,不一定都是你需要的,而搜索全部服务可能会浪费电池寿命并浪费不必要的时间。
寻找到外围设备的服务后,会执行外围设备代理的如下回调:
// 搜索外设服务回调 func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error = error { print("搜索外设服务失败:\(error)") return } guard let services = peripheral.services else { return } for service in services { // 遍历服务 if service.uuid.uuidString == "xxx" { // 找到需要的服务 break } } }
服务列表中包含的对象是CBService,它有个uuid属性,用于区分服务。
紧接着我们需要查找该服务下的特征列表,用于对特征值的读写操作。查找特征列表如下:
open func discoverCharacteristics(_ characteristicUUIDs: [CBUUID]?, for service: CBService)
characteristicUUIDs:特征的唯一标示符,如果你传nil,它将查找该服务下的全部特征,通常在实际开发中你需要指定一个或多个特征ID。
service:待查找的特征服务对象
查找特征列表的操作会执行如下回调:
// 搜索服务特征列表回调 func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error = error { print("搜索服务特征失败:\(error)") return } guard let characteristics = service.characteristics else { return } for characteristic in characteristics { // 遍历特征 if characteristic.uuid.uuidString == "xxx" { // 找到需要的特征 break } } }
特征列表中包含的对象是CBCharacteristic,他有个uuid属性,用于区分特征。
5、订阅特征值以在更新时得到通知
特征包含一个值value属性,代表相关服务的特性,例如:健康温度计服务的温度测量特征可能具有以摄氏度指示温度的值,您可以通过直接读取或订阅特征来检索特征的值。
当您直接读取特征值之前,您需要调用外设的相关的读取方法,否则该值为nil,方法如下:
open func readValue(for characteristic: CBCharacteristic)
我们看到它并没有返回值,这说明它是异步的,你需要实现外设的回调方法如下:
// 特征值更新回调 func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { print("特征值读取失败:\(error)") return } if let data = characteristic.value { print("特征值:\(data)")// 取具体类型的值时,需要根据相关的数据表结构做类型转换 } }
并非所有特征都是可读的。您可以通过检查特征的 properties 属性是否包含 read 常量来确定特征是否可读,就像下面这样:
if characteristic.properties.contains(.read) { // 检查特征值是否可读 peripheral.readValue(for: characteristic)// 读取特征值 }
如果您尝试读取不可读的特征值,didUpdateValueFor回调将会返回错误error。
上面介绍的是直接读取特征值的方法,我们也可以对特征进行订阅,一旦外设有新的值发送时,我们就可以收到这个新值。
往往订阅操作对我们很有帮助,例如:我们需要实时监测心率变化。你可以通过调用外设的如下方法来订阅你关心的特征值。
open func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic)
enabled:是否开启订阅,true:开启订阅。
characteristic:待订阅的特征对象。
当您订阅(或取消订阅)一个特征的值时,外围设备调用如下回调
// 特征值订阅状态回调 func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { if let error = error { print("特征值订阅失败:\(error)") return } }
并非所有的特征值都可以订阅,您可以通过检查特征的 properties 属性是否包含notify或indicate常量来确定特征是否允许订阅,就像下面这样:
if characteristic.properties.contains(.notify) || characteristic.properties.contains(.indicate) { // 检查特征是否允许订阅 peripheral.setNotifyValue(true, for: characteristic) // 开启订阅 }
订阅一旦成功,当外设的特征值发生变化后,您就会在didUpdateValueFor回调中收到新的值。
接下来是向外设传递值,有时写一个特征的值是有意义的。例如,如果您的应用程序与蓝牙低功耗数字恒温器交互,您可能希望为恒温器提供一个设置房间温度的值,如果一个特征的值是可写的,您可以通过调用外设的如下方法进行写值:
open func writeValue(_ data: Data, for characteristic: CBCharacteristic, type: CBCharacteristicWriteType)
data:写入的值
characteristic:待写入的特征对象
type:写入类型,包括如下类型
-
withResponse:向外设传递特征值,并返回写入状态
-
withoutResponse:向外设传递特征值,不会返回写入状态
检查该特征值支持的写入类型,您可以通过判断特征的 properties 属性中,是否包含 write 或者 writeWithoutResponse。
当我们采用withResponse方式的写入操作,您可以通过以下代理回调,对写操作状态进行处理:
// 写操作回调 func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { print("特征值写操作失败:\(error)") return } // 成功 }
而当您采用withoutResponse的方式写入操作,您将不会收到上面的回调。
以上就是手机端作为中央设备,来实现一些蓝牙操作的任务。
二、手机端作为外围设备,来实现一些蓝牙任务。
具体包括:
- 启动外围管理器对象(CBPeripheralManager)
- 在本地外围设备上设置服务和特征
- 将您的服务和特征发布到设备的本地数据库
- 广播您的服务
- 响应来自连中央设备的读写请求
- 将更新的特征值发送到订阅的中心
在使用CoreBluetooth.framework时,我们需要向用户申请使用蓝牙的权限,必须在Info.plist中添加如下说明:
Privacy - Bluetooth Peripheral Usage Description: 我们将使用您的设备作为外设,来广播数据
注意:这里的权限与作为中央设备申请的权限不一样。
1、启动外围管理器对象
外围设备管理类对应CBPeripheralManager
public init(delegate: CBPeripheralManagerDelegate?, queue: DispatchQueue?, options: [String : Any]? = nil)
delegate:外围设备回调代理
queue:回调代理执行的所在队列,nil:主队列
options:可选参数,包括以下可选项
-
CBPeripheralManagerOptionShowPowerAlertKey:对应一个Bool值,表示如果蓝牙在CBPeripheralManager实例化时是关闭的,则显示给用户一个警告对话框,提示用户去设置中开启蓝牙,缺省下不会触发弹窗警告,此时你可以自定义这个提示框。
-
CBPeripheralManagerOptionRestoreIdentifierKey:对应一个String值,用于后台运行时处理恢复中央设备的唯一标示符
初始化完成后,会执行外围设备的状态更新回调:
// 外设状态变化回调 func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { switch peripheral.state { case .poweredOff: print("蓝牙已关闭") case .poweredOn: print("蓝牙已开启") @unknown default: print("其他状态") } }
2、在本地外围设备上设置服务和特征
服务(CBMutableService)和特征(CBMutableCharacteristic)由 128 位蓝牙特定 UUID 标识,在CoreBluetooth.framework框架中用CBUUID表示。尽管并非所有标识服务或特性的 UUID 都由蓝牙特别兴趣组 (SIG) 预定义,但蓝牙 SIG 已定义并发布了许多常用的 UUID,为方便起见,这些 UUID 已缩短为 16 位。例如,蓝牙 SIG 已预定义将心率服务标识为 180D 的 16 位 UUID,此 UUID 是从其等效的 128 位 UUID 0000180D-0000-1000-8000-00805F9B34FB 缩短的,它基于蓝牙 4.0 规范第 3 卷 F 部分第 3.2.1 节中定义的蓝牙基本 UUID。
CBUUID 类提供了工厂方法,使您在开发应用程序时更容易处理长 UUID,例如,您可以简单地使用 UUIDWithString 方法从服务的预定义 16 位 UUID 创建 CBUUID 对象,而不是在代码中传递心率服务的 128 位 UUID 的字符串表示形式,如下所示:
let serviceUUID = CBUUID(string: "180D")
string:接收一个 16-bit, 32-bit, or 128-bit 的UUID字符串,上面的例子是一个16-bit的字符串
当您指定从一个16-bit字符串构建CBUUID时,CoreBluetooth会预先填充128-bit UUID的其余部分。
在创建自定义服务和特征时,我们需要为其准备服务和特征的UUID,如果你不想使用SIG提供已有的UUID,您也可以自己通过uuidgen命令行工具生成一个128-bit的UUID,它是以连字符分隔的 ASCII 字符串形式的唯一 128 位值,如下:
# 在命令行中键入 uuidgen 命令
uuidgen
7518B097-23A6-4B3C-B54C-33BEB4D9BCBC
之后您可以通过CBUUID的工厂方法初始化。
let serviceUUID = CBUUID(string: "7518B097-23A6-4B3C-B54C-33BEB4D9BCBC")
接下来就是为我们的外设创建服务和特征,并组织它们之间的关系树
首先创建一个服务对象CBMutableService
let serviceUUID = CBUUID(string: "0000180D-0000-1000-8000-00805F9B34FB") // 服务的ID let service = CBMutableService(type: serviceUUID, primary: true) // 创建服务
初始化可变服务对象的第二个参数primary我们设置为true,表名这个服务是关键服务,而不是次要服务。主要服务描述了设备的主要功能,并且可以被另一个服务包含(引用)。次要服务描述了仅在引用它的另一个服务的上下文中相关的服务。例如,心率监测器的主要服务可能是从监测器的心率传感器发布的心率数据,而次要服务可能是发布传感器的电池数据。
有了服务,接下来我们需要为该服务关联特征对象,创建特征对象如下:
let characteristicUUID = CBUUID(string: "9B7332B4-16B3-41A3-BDAF-3005F14E5543")// 特征ID let characteristic = CBMutableCharacteristic(type: characteristicUUID, properties: [.read, .write], value: nil, // 特征值 permissions: [.readable, .writeable]) // 创建特征
properties:描述该特征的属性操作,读、写操作。当然也可以设置其他类型,如允许订阅该特征值。(这里可以结合上面中央设备读取外设服务的特征值来理解)
value:该特征对象的初始值,当您为特征指定一个值(value),该值将被缓存,并且您必须为该特征的properties和permissions设置为可读。如果您需要一个特性的值是可写的,或者如果您希望该特性所属的已发布服务的生命周期内该值会发生变化,则必须将该值指定为 nil。遵循这种方法可确保每当外围管理器从连接的中心接收到读取或写入请求时,外围管理器都会动态处理和请求该值。
permissions:指定该特征对象的权限,可读、可写。
接下在就是让这个特征与我们的服务做关联:
service.characteristics = [characteristic]
3、将您的服务和特征发布到设备的本地数据库
有了服务和特征,作为外设,你需要对外发布你的服务和特征,而CoreBluetooth框架很容易做到这点,就想下面这样简单:
myPeripheralManager.add(service) // 发布服务
之后外设管理器将会收到如下回调,告诉您发布的服务是否成功:
// 调用add(service)后的回调 func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { if let error = error { print("发布服务失败:\(error)") return } print("成功发布服务") }
一旦您成功添加了您的服务,该服务已缓存,您将无法再对其进行更改。
4、广播您的服务
外设管理器有了服务和特征,接下来就是对外广播您的服务,让中央设备可以扫描到您。就像下面这样简单:
let serviceUUID = CBUUID(string: "0000180D-0000-1000-8000-00805F9B34FB") // 服务的ID peripheral.startAdvertising([ CBAdvertisementDataLocalNameKey: "drbox", // 外设的名字 CBAdvertisementDataServiceUUIDsKey: [serviceUUID] // 外设对外广播的服务 ])
这里调用外设服务管理对象的startAdvertising方法进行广播,它包含下面两个属性:
-
CBAdvertisementDataLocalNameKey:对应字符串类型,设置外设的本地名称
-
CBAdvertisementDataServiceUUIDsKey:对应CBUUID集合,指定外设的服务ID集合
之后外设管理器会执行如下回调,告诉您服务广播是否成功:
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { if let error = error { print("广播服务失败:\(error)") return } print("开始对外广播服务") }
数据广播是在“尽力而为”的基础上进行的,因为空间有限,并且可能同时有多个应用程序在广播。当您的应用处于后台时,广告行为也会受到影响。一旦您开始广播数据,远程中央设备就可以发现并启动与您建立连接。
5、响应来自中央设备的读写请求
连接到一个或多个远程中央设备后,您可能会开始接收来自它们的读取或写入请求。因此您必须有效的响应这些请求,当一个中央设备开始读取您的一个特征值时,外设管理器将执行如下回调:
// 收到读取特征值的请求 func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { // 匹配中央设备请求的特征对象 guard let characteristic = characteristic, request.characteristic.uuid == characteristic.uuid else { // 这里是本地外设发布的特征对象 return // 请求的特征不匹配,这里不需要为该请求响应 } // 匹配到特征对象后,开始判断请求的数据偏移量是否超出特征值的有效范围 guard let value = characteristic.value, request.offset <= value.count else { // 这里需要给这次请求一个错误响应 peripheral.respond(to: request, withResult: .invalidOffset) return } // 开始响应请求,为读取请求返回响应的值 // 1 request.value = value.subdata(in: request.offset..<value.count) // 2 peripheral.respond(to: request, withResult: .success) // 响应成功 }
外设管理器将中央设备请求封装成一个CBATTRequest对象,我们可以通过该对象知道是哪个中央设备请求的,也可以知道这次请求的是哪个特征值。对于没有匹配到的特征值或无法处理的情况,您不必对该请求做出响应。一旦您匹配到响应的特征值,您要为这次请求做出响应的回应,这里我们做了读取值的范围校验,当然您也需要校验特征值的properties和permissions。
为本次请求做出回应是通过调用外设管理器对象的respond(to:withResult)方法进行响应,它对应一个result类型,具体类型可在这里查阅。
处理写入特征值的请求和写入很相似,同样当外设管理器收到写入请求后,它会执行如下回调:
// 收到写入特征值的请求 func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { guard let characteristic = characteristic else { // 这里是本地外设发布的特征对象 return } // 匹配特征 for request in requests { guard request.characteristic.uuid == characteristic.uuid else { continue } characteristic.value = request.value // 为每个请求做出响应 peripheral.respond(to: request, withResult: .success) } }
上面只是一个简单的写入操作,有时在处理写入操作时,您要考虑到请求的偏移属性offset。
6、将更新的特征值发送到订阅的中心
通常远程中央设备会订阅您的一个或多个特征值,当您的特征值发生变化时,您有责任将这个新值通知给远程中央设备。具体是这样做的,当远程中央设备订阅您的特征值时,您的外设管理器会收到如下回调:
// 远程中央设备订阅特征 func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { // 您可以保存central和特征UUID,用于标记哪个远程中央设备订阅哪个特征。当特征值发生变化后,可以根据这些,调用如下方法发出通知 let newData = Data([0x001]) peripheral.updateValue(newData, for: self.characteristic!, onSubscribedCentrals: [central]) // 该方法应该是在值发生改变的地方调用 }
当您调用方法updateValue(_:for:onSubscribedCentrals:)将更新的特征值发送到订阅的中心时,您可以在最后一个参数中指定要更新的中央设备,如果您指定 nil,则所有已连接和订阅的中心都会更新(并且任何尚未订阅的已连接中心都将被忽略)。
该方法返回一个布尔值,表示更新是否成功发送到订阅的中心,如果用于传输更新值的底层队列已满,则该方法返回 false。直到当传输队列中有更多空间可用时,外围设备管理器将执行如下回调:
// 订阅更新失败时,此时可以继续发送了 func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { // 此时可以再次调用updateValue进行更新 }
有时您也需要处理远程中央设备取消订阅的操作,当远程中央设备取消订阅时,会执行外围设备管理器的如下代理:
// 远程中央设备取消订阅特征 func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { // 处理取消订阅方法,可以将订阅的central和特征置空即可 }
以上就完成了远程中央设备的订阅操作,不过需要注意一点,根据您的特征值的大小,通知可能不会传输所有数据。当特征值过大时,不建议远程中央设备采用订阅的形式获取特征值,而是直接通过主动读取的方法来获取全部值。
三、蓝牙任务后台运行的处理。
对于 iOS 应用程序,了解您的应用程序是在前台运行还是在后台运行至关重要。应用程序在后台的行为与在前台的行为不同,因为 iOS 设备上的系统资源更受限制。默认情况下,当您的应用程序处于后台或挂起状态时,中央和外围设备端的许多常见核心蓝牙任务都会被禁用。也就是说,您可以声明您的应用程序支持核心蓝牙后台执行模式,以允许您的应用程序从挂起状态唤醒以处理某些与蓝牙相关的事件。即使您的应用程序不需要全方位的后台处理支持,它仍然可以在重要事件发生时要求系统发出警报。
即使您开启了后台运行模式,您的app也不能永远运行。在某些时候,系统可能需要终止您的应用程序以释放当前前台应用程序的内存 - 导致任何活动或挂起的连接丢失。从 iOS 7 开始,Core Bluetooth 支持保存中央和外围管理器对象的状态信息,并在应用启动时恢复该状态。您可以使用此功能来支持涉及蓝牙设备的长期操作。
与大多数 iOS 应用程序一样,除非您请求执行特定后台任务的权限,否则您的应用程序会在进入后台状态后不久转换到暂停状态。在挂起状态下,您的应用程序无法执行与蓝牙相关的任务,也不会知道任何与蓝牙相关的事件,直到它恢复到前台。
作为中央设备的app,仅前台应用程序(未声明支持任一核心蓝牙后台执行模式的应用程序)在后台或挂起时无法扫描和发现正在广播的外围设备。作为外围设备的app,对外广播被禁用,任何试图访问应用程序已发布服务的动态特征值的中央设备都会收到错误。
因此基于这些情况,当您没有设置后台模式时,假如此时您的app正在与其他中央设备同步数据中,此时您的用户切换了app,当您的app与中央设备断开连接时,您的app将不会知道发生了任何断开连接,直到您的应用程序恢复到前台。
因此您需要一套机制,来解决这个问题。
当仅前台应用程序处于挂起状态时发生的所有与蓝牙相关的事件都由系统排队,并仅在应用程序恢复到前台时才传递给应用程序。而Core Bluetooth 提供了一种在某些中央设备角色事件发生时提醒用户的方法。然后用户可以根据这些警报来决定特定事件是否需要将应用程序带回前台。
针对于app作为中央设备时,在初始化CBCentralManager类时,通过传递options参数的如下选项之一:
- CBConnectPeripheralOptionNotifyOnConnectionKey:对应一个Bool值,表示如果成功建立连接时应用程序被挂起,系统应该显示给定外设的连接警报。
- CBConnectPeripheralOptionNotifyOnDisconnectionKey:对应一个Bool值,表示如果应用程序在断开连接时挂起,系统应显示给定外设的断开警报。
- CBConnectPeripheralOptionNotifyOnNotificationKey:对应一个Bool值,表示如果应用程序挂起,系统应该对从给定外设接收到的所有通知显示一个警报。
设置蓝牙后台执行模式
如果您的应用需要在后台运行以执行某些与蓝牙相关的任务,则必须在您的app项目中的Info.plist中设置后台执行模式。当您的应用声明这一点时,系统会将其从挂起状态唤醒,以允许其处理与蓝牙相关的事件。这种支持对于与定期传输数据的蓝牙低功耗设备交互的应用程序非常重要,例如心率监测器。
应用程序可以声明两种核心蓝牙后台执行模式,一种是用于实现中央设备角色的应用程序,另一种是用于实现外围设备角色的应用程序。如果您的应用同时实现了这两种角色,那么您需要为您的app声明它支持两种后台执行模式。您只需要在您的Info.plist中添加一个key为UIBackgroundModes,value为Array的配置,其中array包含以下字符串:
- bluetooth-central:app作为中央设备与外围设备连接时
- bluetooth-peripheral:app作为外围设备时
app作为中央设备使用后台模式时,Core Bluetooth 框架允许您的应用在后台运行以执行某些与蓝牙相关的任务。当您的应用程序处于后台时,您仍然可以发现和连接外围设备,并探索外围设备数据并与之交互。
虽然您可以在应用程序处于后台时执行许多与蓝牙相关的任务,但请记住,当您的应用程序处于后台时扫描外围设备的操作与您的应用程序处于前台时不同。特别是,当您的应用在后台扫描设备时,有以下注意的地方:
- CBCentralManagerScanOptionAllowDuplicatesKey选项key的设置将被忽略,多个相同的外围设备的发现,将被合并为单个事件发现。
- 如果正在扫描外围设备有多个应用程序都在后台,则中央设备扫描广播的数据包的间隔会增加。因此,发现广播的外围设备可能需要更长的时间。
app作为外围设备使用后台模式时,Core Bluetooth 框架允许您的应用在后台运行以执行某些与蓝牙相关的任务。当您的应用程序处于后台时,系统会唤醒您的应用以处理读取、写入和订阅事件。
除了允许您的应用程序被唤醒以处理来自连接中央设备的读取、写入和订阅请求外,Core Bluetooth 框架还允许您的应用程序在后台状态下进行广播。而在后台进行广播时与在前台时有所不同,具体如下:
- CBAdvertisementDataLocalNameKey选项key的设备将被忽略,并且外围设备的本地名称将不被广播。
- CBAdvertisementDataServiceUUIDsKey 广告键值中包含的所有服务 UUID 都放置在一个特殊的“溢出”区域;它们只能被指定扫描它们的 iOS 设备发现。
- 如果有多个外围设备在后台执行,那么您的外围设备发送广播包的频率可能会降低。
任何声明支持任一核心蓝牙后台执行模式的应用程序都必须遵循一些基本准则:
- 应用程序应该是基于会话的,并提供一个允许用户决定何时开始和停止传输蓝牙相关事件的界面。
- 在后台被唤醒后,应用程序有大约 10 秒的时间来完成任务。理想情况下,它应该尽快完成任务并允许自己再次挂起。在后台花费太多时间执行的应用程序可能会被系统限制或终止。
- 应用程序不应将被唤醒作为执行与系统唤醒应用程序的原因无关的无关任务的机会,也就是说,处理后台任务时,不要处理与后台模式无关的其他任务。
在后台执行长期操作
某些应用程序可能需要在后台执行长期操作,例如:假设您正在为与门锁(配备蓝牙低功耗技术)通信的 iOS 设备开发家庭安全应用程序。应用程序和锁相互作用,在用户离开家时自动锁门,在用户返回时自动解锁门——所有这些都在应用程序处于后台时进行。当用户离开家时,iOS 设备最终可能会超出锁的范围,导致与锁的连接丢失。此时,应用程序可以简单地调用 CBCentralManager 类的 connectPeripheral:options: 方法,并且由于连接请求不会超时,用户回家后,iOS 设备将重新连接。
现在假设用户离开家几天。如果用户外出时应用程序被系统终止,用户回家后应用程序将无法重新连接锁,用户可能无法解锁门。对于此类应用程序,能够继续使用 Core Bluetooth 执行长期操作至关重要。
状态保存和恢复
因为Core Bluetooth内置了状态保存和恢复,您的应用可以选择启用此功能,以要求系统保留应用中央和外围管理器的状态,并继续代表它们执行某些与蓝牙相关的任务,即使您的应用不再运行。当其中一项任务完成时,系统会在后台重新启动您的应用程序,并让您的应用程序有机会恢复其状态并适当地处理事件。在上面描述的门锁的情况下,系统会监控连接请求,并重新启动应用程序以处理 centralManager:didConnectPeripheral: 当用户返回家并完成连接请求时委托回调。
核心蓝牙支持实现中央设备角色、外围设备角色或两者兼有的应用的状态保存和恢复,当您的应用实现中央设备角色并添加对状态保存和恢复的支持时,当系统即将终止您的应用程序以释放内存时,系统会保存您的中央管理器对象的状态(如果您的应用程序有多个中央管理器,您可以选择希望系统跟踪哪些管理器),系统跟踪的状态包括如下:
- 中央管理器正在扫描的服务(以及扫描开始时指定的任何扫描选项)
- 中央管理器尝试连接或已经连接的外围设备
- 中央管理器订阅的特征
实现外围角色的应用程序同样可以利用状态保存和恢复。对于 CBPeripheralManager 对象,系统会跟踪:
- 外设管理器对外广播的数据
- 外设管理器发布到设备数据库的服务和特征
- 外部中央设别订阅了您的特征值的状态
当您的应用程序被系统重新启动到后台时(例如,因为发现了您的应用程序正在扫描的外围设备),您可以重新实例化您的应用程序的中央和外围管理器并恢复它们的状态。下面将详细描述了如何在您的应用程序中使用状态保存和恢复。
添加对状态保存和恢复的支持
Core Bluetooth 中的状态保存和恢复是一项可选功能,需要您的应用程序的帮助才能工作,您可以按照以下流程在您的应用中添加对此功能的支持:
1、(必需)在分配和初始化中央或外围管理器对象时选择状态保存和恢复。
对于中央管理设备如下:
centralManager = CBCentralManager(delegate: self, queue: nil, options: [ CBCentralManagerOptionRestoreIdentifierKey: "用于保存中央设备状态的唯一标示符" ])
对于外围管理设备如下:
myPeripheralManager = CBPeripheralManager(delegate: self, queue: nil, options: [ CBPeripheralManagerOptionRestoreIdentifierKey: "用于保存外围设备状态的唯一标示符" ])
2、(必需)在系统重新启动您的应用程序后,重新实例化任何中央或外围管理器对象。
在app重新启动时,会执行这个代理方法didFinishLaunchingWithOptions,您可以从该options中获取您要恢复的外设或中央设备的唯一标示符,就像下面这样:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { if let centralsIdentifiers = launchOptions?[UIApplication.LaunchOptionsKey.bluetoothCentrals] as? [String] { print("待恢复的中央设备:\(centralsIdentifiers)") } if let peripheralsIdentifiers = launchOptions?[UIApplication.LaunchOptionsKey.bluetoothPeripherals] as? [String] { print("待恢复的外围设备:\(peripheralsIdentifiers)") } return true }
当然对于我们在app整个生命周期内,维护了一个或多个中央设备对象或外围设备对象,并且在初始化时已经硬编码指定了用于恢复状态的唯一标示符,就像第1步中的那样,那么第2步的操作也就不必了。
恢复机制只会在您的app被关闭的情况下,并且期间有正在执行一些与蓝牙相关的任务时,才会执行恢复操作。
3、(必需)实现中央管理器或外围设备管理器的以下代理:
中央管理器
// 中央管理器状态恢复处理 func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { print("中央管理器状态恢复:\(dict)") if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], peripherals.count > 0 { // 恢复 let peripheral = peripherals[0] self.peripheral = peripheral // 保存引用 } }
外围管理器
// 外围管理器状态恢复处理 func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { print("外围设备管理器状态恢复:\(dict)") if let services = dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService], services.count > 0 { // 恢复对 } }