与核心蓝牙一起工作的指南

206 阅读7分钟

与核心蓝牙合作

发布 者:Sergio Martinez

自从我们开始在日常生活中使用手机以来,已经有大约20年了。在这段时间里,越来越多的硬件被装入手机,这意味着手机现在也可以用于拍照、卫星导航和健康跟踪等各种活动。

每次有新的硬件被添加到手机中,底层的SDK都会被更新,以允许开发者从他们的应用程序中访问这些硬件。然而,现代手机SDK的庞大规模意味着很少有一个移动工程师能够使用手机所能提供的每一个硬件。

在我作为iOS开发者的职业生涯中,我有机会使用访问加速计、陀螺仪、摄像头、GPS、联网打印机以及最近的蓝牙低能量(BLE)的API。我发现BLE是其中使用起来最复杂的一个。

在这篇文章中,我将带你了解这种复杂性,首先是在iOS设备上使用BLE的理论,然后是我建立一个真实世界的商业应用的经验。虽然这篇文章的重点是iOS,但许多概念和教训都可以转移到Android设备上。

介绍一下核心蓝牙

核心蓝牙(CB)是苹果公司在iOS设备上访问BLE的框架。然而,关于如何使用CB的文档、案例研究和例子都很稀少。大多数时候,我发现官方文档 并不像我希望的那样有帮助。唯一的选择是官方的BLE文档,但那是非常技术性的,有点难以理解,一般来说对应用开发者帮助不大。

因此,我发现自己是通过艰苦的方式学习CB:阅读现有的代码,调试和手动测试。但在我进入困难之前,我将概述一些关键概念。

核心蓝牙概念

当我们想到设备之间的通信,我们通常区分(至少)两个角色:客户端和服务器,在蓝牙的背景下,这些被称为分别为 "外围 "和 "中心"。像大多数苹果框架一样,CB为开发者提供了方便的委托来实现上述角色:CBPeripheralManagerDelegateCBCentralManagerDelegate

设备是如何连接的?

除了定义对象来实现中央和外围的委托外,也有必要定义一个服务标识符。这是一个UUID,被设备用来识别自己的对等物。服务标识符的匹配是从开发者那里抽象出来的,但一旦匹配成功,就会调用委托方法。

为了这篇文章的目的,我将谈论读操作,以及如何利用特性将数据从一个外设发送到一个中央设备。写操作的工作方式非常类似,所以我将把这些留给你单独探讨。请注意,为了实现我们在这里做的事情,不需要配对。因此,我不会在这篇文章中介绍它。

蓝牙中心

作为中心的设备的作用是搜索并连接到任何具有匹配UUID的外围设备。didDiscover 回调方法将被调用,有一个peripheral 对象,我们可以用它来控制与它的连接。

func centralManager(_ central: CBCentralManager, 
                    didDiscover peripheral: CBPeripheral, 
                    advertisementData: [String : Any], 
                    rssi RSSI: NSNumber) {
    ...
    central.connect(peripheral)
    ..
}

现在我们已经连接了,我们想交换一些数据。为了请求特定的数据,我们定义了特征ID。这些可以被认为是将从外围设备发送至中央设备的属性。为了发现哪些特性是可用的,我们首先要求外围设备为我们提供与我们应用程序的相关ID相匹配的服务。

func centralManager(_ central: CBCentralManager, 
                    didConnect peripheral: CBPeripheral) {
    ...
    // retrieve the service definition/object
    peripheral.discoverServices([ServiceUUID])
    ...
}

然后我们实现CBPeripheralDelegate (不要与 `CBPeripheralManagerDelegate`),首先查找服务对象,然后查找我们所要的特征。

func peripheral(_ peripheral: CBPeripheral, 
                didDiscoverServices error: Error?) {
    ...
    guard let services = peripheral.services, 
          let service = services.first else {
        return
    }
    // request the definitions of the relevant characteristics
    peripheral.discoverCharacteristics(
        [Characteristic_A_UUID, Characteristic_B_UUID], 
        for: service
    )
    ...
}

func peripheral(_ peripheral: CBPeripheral, 
                didDiscoverCharacteristicsFor service: CBService,
                error: Error?) {
    ...
    // retrieves the characteristics definitions, not the values. 
    // This will contain only the characteristics requested above.
    guard let characteristics = service.characteristics, 
          characteristics.count > 0 else {
        return
    }
    ...
}

实现委托提供了我们想要的信息的定义,但不是数据本身。为了获得实际的数据,我们调用peripheral.readValue(for: characteristics.first) 。这将导致另一个委托方法的调用,在那里我们可以最终看到特性的值。

func peripheral(_ peripheral: CBPeripheral, 
                didUpdateValueFor characteristic: CBCharacteristic,
                error: Error?) {
    ...
    // value's type is Data
    characteristic.value
    ...
}

蓝牙外围设备

一个作为外围设备工作的设备将不得不公布一个服务,并响应中央的请求。幸运的是,实现一个外围设备比实现一个中心设备更简单。

在创建管理器对象时,我们指定服务ID和要广播的特性,并提供一个指向 `CBPeripheralManagerDelegate`:

// Remember to implement the CBPeripheralManagerDelegate.
let peripheral = CBPeripheralManager(delegate: self, queue: queue, options: nil)

// Create the service to advertise and set the characteristics
let service = CBMutableService(type: MyServiceUUID, primary: true)
service.characteristics = [Characteristic_A_UUID, Characteristic_B_UUID]

// Add service and start advertising
peripheral.add(service)
peripheral.startAdvertising([CBAdvertisementDataServiceUUIDsKey : [MyServiceUUID]])

现在我们的外围设备正在为我们的服务做广告。在这一点上,中心能够发现并建立与我们的外设的连接。

然而,与中心相比,外设所要做的就是响应读取请求。它与中心的所有其他互动都是由SDK处理的。这包括处理传入的连接以及提供服务和特性的细节。我们所需要做的就是响应特性的读取请求。

func peripheralManager(_ peripheral: CBPeripheralManager, 
                       didReceiveRead request: CBATTRequest) {
    ...
    switch request.characteristic.uuid {
    case Characteristic_A_UUID:
        request.value = valueOfA
    case Characteristic_B_UUID:
        request.value = valueOfB
    }
    peripheral.respond(to: request, withResult: .success)
    ...
}

艰苦的学习方式

在上一节中,我让你大致了解了核心蓝牙在iOS中是如何工作的。一旦我学到了这么多,我就对自己说 "我已经知道了"。但我不知道我的旅程才刚刚开始。在这一节中,我将解释一些我不得不努力学习的东西。

数据交换

我遇到的第一个问题是,由于某种原因,我的信息(特征)并不完整。事实证明,每次读取时可以发送的数据量是有上限的,尽管苹果文档没有提到这个重要的事实。

为了解决这个问题,外围设备需要使用读取请求中发送的偏移量,并作出相应的反应。

// data is the data relevant for the characteristic
guard request.offset < data.count else {
    peripheral.respond(to: request, withResult: .invalidOffset)
    return
}

guard request.offset >= data.count else {
    // the receiver already read all the data in its last read request
    peripheral.respond(to: request, withResult: .success)
    return
}
request.value = (request.offset == 0 ? 
                 data : 
                 data.subdata(in: request.offset..<data.count))
peripheral.respond(to: request, withResult: .success)

幸运的是这是一个简单的任务,很容易解决。然而令人沮丧的是,这并不在文档中。此外,这似乎是一个常见的问题,可以(或应该)由核心蓝牙处理,而不是由每个开发人员自己处理。

连接池

一旦我们进入生产阶段,设备之间的连接似乎就会毫无征兆地停止工作。过去连接得很好的设备,突然间就不能再连接了。

这在开发中不是问题,甚至当我们在现场测试时也不是问题。设备调试日志中没有任何信息,我们也想不出任何可能发生的原因。

如果我们重新启动应用程序进行调试,它最初会像预期的那样开始连接到其他设备。然而,过了一会儿,它又会停止,而且调试器无法看到任何委托方法被触发。强制停止扫描,然后再次启动,没有任何区别。

事实证明,iOS使用一个设备连接池。这是有道理的,但是Core Bluetooth不够聪明,不能检测到连接何时被终止。因此,所有的设备连接都留在连接池中,不管实际设备是否还在附近。最终,连接池达到容量,没有更多的设备可以连接。

例如,如果我在家里有两个测试设备连接在一起,并把其中一个带到办公室,一旦设备之间的距离足够远,实际的无线电连接就会丢失。不幸的是,连接池中的相应项目不会被自动删除,这意味着未来的连接空间(例如,与办公室的其他设备)不会被释放出来。

为了解决这个问题,我们需要自己从连接池中删除连接,并找到一些启发式的方法来触发删除。最后,我们使用时间戳来跟踪最后一次连接,但你也可以使用像距离和信号强度这样的东西,如果它更适合你的用例。

尽管这很令人困惑,但回过头来看,我明白了为什么Core Bluetooth要把这个问题留给开发者来实现。在有些情况下,我们可能不希望连接结束。例如,我可能在家里有一个信标,我希望我的设备能保持状态--即使它暂时不在范围内--而不是每次都要经历一个完整的发现周期。

识别外围设备

如前所述,当一个外设被发现并且didDiscover 委托方法被调用时,我们可以很容易地获得UUID。然而,事实证明,如果你连接到一个安卓设备,经过一段时间后,这个UUID会发生变化,这意味着UUID不能再用于唯一地识别该设备。

相反,如果你想保留与安卓设备的连接,每次调用didDiscover ,就不要理会peripheral.identifier.uuidString ,而是检查CBAdvertisementDataManufacturerDataKey 中提供的数据中的设备标识符。

var androidId = nil
if let manuData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, 
    manuData.count > 2 {

    // At this point we know this is an Android device as iOS does not send this.
    // We set a max of 8 bytes for an int64 to use as ID.
    // The first 2 bytes represent manufacturerID 
    // see https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers

    let identifierData = Data(manuData.subdata(in: 2..<min(8, manuData.count)))
    var longValueData = Data(identifierData)

    // we make sure the length is 8
    if longValueData.count < 8 {
        longValueData.append(Data(repeating: 0, count: 8 - longValueData.count))
    }

    if let longValue = longValueData.int64(0) {   
       androidId = Int64(longValue)
    }
}

将该ID与先前连接的外围设备进行比较,如果有必要,就手动断开它们。我们不想因为保留重复的连接而在连接池中占用不必要的位置,不是吗?

总结

使用核心蓝牙的工作很困难,但也很有意义。

文档和示例代码都很有限,而且很难调试和测试。有时候,日志似乎是没有顺序的,这意味着很难确定先发生了什么,或者为什么会发生。当有一个以上的设备连接时,这尤其困难。虽然测试用的模拟对象在原则上是有帮助的,但我对BLE的工作方式和数据交换的知识有限,所以很难创建有效的存根。

通过无尽的日志文件追踪设备ID是令人沮丧的,但也让我觉得找到并修复每个问题是一种成就。作为一名移动工程师,很少有机会与核心蓝牙一起工作,所以总的来说,我很喜欢这个机会。我只是希望在这里分享我的经验能帮助其他有机会使用它的人更容易一点。