一文学会iOS蓝牙开发

一文学会 iOS 蓝牙开发

背景

最近做 APP 对接蓝牙设备开发，这里分享一下 iOS 对接蓝牙设备中需要注意的东西，大致包含下面这些方面：

Xcode 蓝牙权限

如何扫描蓝牙设备、获取 Mac 地址

不同蓝牙设备切换

写入蓝牙命令

data 转 16 进制字符串

16 进制转 String

crc 算法

数据异或计算，字符串异或

负数异或计算

依次写入多个命令

蓝牙开发的大致流程

首先来了解一下蓝牙开发的流程，总结如下：

Xcode 配置蓝牙权限 -> 启动蓝牙 -> 扫描周围蓝牙 -> 连接指定蓝牙 -> 校验是否连接成功 -> 蓝牙读 / 写 -> 断开连接

流程图如下：

具体步骤

1. 配置 Xcode 蓝牙权限

General Tab 下，Frameworks,Libraries, and Embedded Content中添加 CoreBluetooth.framework，如下图：

Signing & Capabilities Tab 下，Background Modes中，勾选Uses Bluetooth LE accessories，如下图：

Info Tab 下，Custom iOS Target Properties中，添加Privacy - Bluetooth Peripheral Usage Description和Privacy - Bluetooth Always Usage Description

完成上面的步骤后，Xcode 蓝牙配置就完成了，然后来看如何初始化蓝牙。

2. 初始化蓝牙调用

再开始看代码前，可以先看下面的思维导图，来自iOS 蓝牙知识快速入门（详尽版）

有了大致印象后，然后来看右下那部分 CoreBluetooth 的使用。

初始化 CBCentralManager，CBCentralManager负责的是蓝牙初始化、扫描、连接，初始化方法中会弹出申请蓝牙权限申请，不需要显式声明

Copy
dispatch_queue_t queue = dispatch_queue_create("com.queue.bluetooth", DISPATCH_QUEUE_SERIAL);
NSDictionary *dic = @{
CBCentralManagerOptionShowPowerAlertKey: @YES,
CBCentralManagerOptionRestoreIdentifierKey: @"com.queue.bluetooth"
};
self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:queue options:dic];

3. 扫描周边蓝牙设备

扫描周边蓝牙设备，CBCentralManager初始化之后，调用扫描周边蓝牙设备方法，扫描发现蓝牙设备。
Ps: 如果蓝牙设备有低电量休眠功能，可以在这里提示用户手动先激活蓝牙，否则连接比较慢，或链接不上

Copy
// 开始扫描
- (void)startScan {
// 不重复扫描已发现设备
NSDictionary *dic = @{
CBCentralManagerScanOptionAllowDuplicatesKey: @NO,
CBCentralManagerOptionShowPowerAlertKey: @YES
};
// 开始扫描
[self.centralManager scanForPeripheralsWithServices:nil options:dic];
}

另外，如果不存在附近多台蓝牙设备来回切换的情况，可以用如下方法，快速连接上次连接过的设备。retrieveConnectedPeripheralsWithServices方法会获取蓝牙连接成功的设备，这些设备可能不是本 APP 连接的，所以使用时需要额外注意。

Copy
// 开始扫描
- (void)startScan {
// 不重复扫描已发现设备
NSDictionary *dic = @{
CBCentralManagerScanOptionAllowDuplicatesKey: @NO,
CBCentralManagerOptionShowPowerAlertKey: @YES
};
// 开始扫描
NSArray *arr = [self.centralManager retrieveConnectedPeripheralsWithServices:@[[CBUUID UUIDWithString:Target_SERVICE_UUID]]];
if (arr.count > 0) {
CBPeripheral *per = arr.firstObject;
self.peripheral = per;
[self.centralManager connectPeripheral:per options:nil];
} else {
// 开始扫描
[self.centralManager scanForPeripheralsWithServices:nil options:dic];
}
}

4. 识别要连接蓝牙设备

扫描到蓝牙设备的处理。CBCentralManager初始化时设置了delegate，所以需要实现CBCentralManagerDelegate的代理方法。

其中centralManager:didDiscoverPeripheral:advertisementData:RSSI:方法是发现蓝牙设备的回调方法，在这个方法里，需要识别出要链接的蓝牙设备，然后调用连接方法。

这里需要注意的是，iOS 的蓝牙，没有办法直接获取蓝牙设备的Mac地址，所以需要提供设备方将蓝牙 Mac 地址，放到advertisementData中提供，这里需要跟设备厂商确认好，获取逻辑，例如advertisementData中哪个字段中包含有Mac地址，取值是第几位到第几位。然后可以先获取到对应的 data，再转为十六进制的 hex string, 再通过固定的规则取到Mac地址，然后根据Mac地址确定要链接的蓝牙设备。

当然也可以先通过简单的蓝牙名字过滤，然后再通过Mac地址进行进一步的确认唯一设备，找到要链接的设备后，再调用connectPeripheral:options:发起连接。

连接成功后，停止扫描蓝牙设备，设置蓝牙设备的代理，开始扫描服务。

Copy
#pragma mark - CBCentralManagerDelegate
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {

}

// 扫描到设备
- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary<NSString *,id> *)advertisementData RSSI:(NSNumber *)RSSI {
if (peripheral == nil) {
return;
}
if((__bridge CFUUIDRef )peripheral.identifier == NULL) return;

NSString *nameStr = peripheral.name;
if(nameStr == nil || nameStr.length == 0) return;
NSLog(@"nameStr: %@", nameStr);
NSLog(@"advertisementData: %@", advertisementData);
// 判断名字是否包含指定蓝牙设备名字
if (([nameStr caseInsensitiveCompare:@"TargetBluetoothName"] == NSOrderedSame)){
// 示例蓝牙地址放在`advertisementData`的`kCBAdvDataManufacturerData`中
// 先取到`kCBAdvDataManufacturerData`
NSData *manufacturerData = [advertisementData valueForKey:@"kCBAdvDataManufacturerData"];
// 然后转为十六进制字符串，这个方法后面提供
NSString *manufacturerDataStr = [BluetoothTool convertDataToHexStr:manufacturerData];
if (manufacturerDataStr) {
// 然后根据规则，取出`Mac地址`
NSString *deviceMacStr = [manufacturerDataStr substringWithRange:NSMakeRange(x, 12)];
// 然后判断`获取到的Mac地址`和要操作设备的`Mac地址`是否一致，一致则连接
if ([deviceMacStr caseInsensitiveCompare:targetDeviceMac] == NSOrderedSame) {
[self.centralManager connectPeripheral:peripheral options:nil]; //发起连接的命令
self.peripheral = peripheral;
}
}
}
}

// 连接成功
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
//连接成功之后寻找服务，传nil会寻找所有服务
[self.centralManager stopScan];
peripheral.delegate = self;
[peripheral discoverServices:nil];
}

// 连接失败
- (void)centralManager:(CBCentralManager *)central didFailToConnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error {
self.isConnected = NO;
NSLog(@"链接失败：%@", error.localizedDescription);
}

// 断开连接
- (void)centralManager:(CBCentralManager *)central didDisconnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error {
self.isConnected = NO;
NSLog(@"断开链接：%@", error.localizedDescription);
}

5. 扫描指定蓝牙设备的服务

扫描服务的处理。注意上面设置了peripheral.delegate，所以需要实现CBPeripheralDelegate的代理方法。

peripheral:didDiscoverServices:是发现服务的回调，在这个回调方法里，需要判断找到的服务 UUID 和要连接设备的服务 UUID（这个是提供蓝牙设备的厂商会提供，或者设备文档里会标明）是否一致，一致则继续下一步查找特征值。

peripheral:didDiscoverCharacteristicsForService:error:是发现特征的回调，用于获取读和写的特征。读和写的特征也是有 UUID 区分，有时候读和写也是同一个 UUID，同样是由厂商提供，或者文档标明。Ps: 这里需要注意的是，需要注意厂商提供的文档，有些厂商的设备获取到特征之后，需要写入指定信息，获取到指定的返回才算真正的连接成功

periphera:didUpdateValueForCharacteristic:error:是蓝牙设备返回数据的回调，即读数据的回调。这里需要注意，和蓝牙的操作和普通的执行命令不同，不是执行了就可以了；写入蓝牙执行命令后，要根据蓝牙设备返回数据判断命令是否执行成功。大部分复杂的逻辑都在这个方法里，因为这个方法返回的数据是 Data，需要将数据解密然后转为 Byte 或者 Hex Str 进行处理。

peripheral:didWriteValueForCharacteristic:是命令是否写入成功的回调，成功标明指令成功写入到的蓝牙设备，即蓝牙设备成功收到了指令，但是指令是否执行成功是要根据上面的返回数据的方法判断。

代码如下：

Copy
#pragma mark - CBPeripheralDelegate
//发现服务的回调
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(nullable NSError *)error {
if (!error) {
for (CBService *service in peripheral.services) {
NSLog(@"serviceUUID:%@", service.UUID.UUIDString);

if ([service.UUID.UUIDString caseInsensitiveCompare:TARGET_SERVICE_UUID] == NSOrderedSame) {
//发现特定服务的特征值
[service.peripheral discoverCharacteristics:nil forService:service];
}
}
}
}

// 发现特征的回调, 发现characteristics，由发现服务调用（上一步），获取读和写的characteristics
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(nullable NSError *)error {
for (CBCharacteristic *characteristic in service.characteristics) {
//有时读写的操作是由一个characteristic完成
if ([characteristic.UUID.UUIDString caseInsensitiveCompare:TARGET_CHARACTERISTIC_UUID_READ] == NSOrderedSame) {
self.read = characteristic;
// 订阅特征回复的数据
[self.peripheral setNotifyValue:YES forCharacteristic:self.read];
} else if ([characteristic.UUID.UUIDString caseInsensitiveCompare:TARGET_CHARACTERISTIC_UUID_WRITE] == NSOrderedSame) {
self.write = characteristic;
// 这里需要注意，根据实际情况，看是否需要执行特定指令，用于判断是否连接成功
[self makeConnectWrite];
}
}
}

//读数据的回调
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(nullable NSError *)error {
if (error) {
NSLog(@"===读取错误：%@",error);
return;
}

if([characteristic.UUID.UUIDString caseInsensitiveCompare:Target_CHARACTERISTIC_UUID_READ] == NSOrderedSame){
//获取订阅特征回复的数据
// 返回的数据是Data类型，将Data转为16进制的字符串用于处理，或者转为 Byte 进行处理；
NSString *value = [[BluetoothTool convertDataToHexStr:characteristic.value] uppercaseString];

// 根据文档，对于数据进行解密或者其他处理，然后通过指定的判断逻辑，判断是否指令是否执行成功

}
}

//是否写入成功的回调
- (void)peripheral:(CBPeripheral *)peripheral didWriteValueForCharacteristic:(CBCharacteristic *)characteristic error:(nullable NSError *)error {
if (error) {
NSLog(@"===指令写入错误：%@",error);
}else{
NSLog(@"===指令写入成功");
}
}

6. 批量写入多个指令

如果蓝牙设备不支持异步，且不支持并行写入，需要批量写入多个指令时需注意。可以通过创建队列，设置队列 dependency 的方式，指定写入指令依次一个个执行。

辅助方法

大部分转换方法来自IOS 蓝牙通信各种数据类型之间的转换，使用时按需使用即可。

Data 转 16 进制字符串

蓝牙返回的数据是 NSData 类型，此时可以调用下面方法将 NSData 转为 16 进制字符串，然后针对字符串取指定位进行处理。
Ps: 这里需要注意，由于转为 16 进制字符串处理，可能后面有需要进行算术运算，所以最好转为字符串后，统一转为大写处理。

Copy
// 将NSData转为16进制的字符串, <0x00adcc asdfgwerf asdddffdfd> -> @"0x00adccasdfgwerfasdddffdfd"
+ (NSString *)convertDataToHexStr:(NSData *)data {
if (!data || [data length] == 0) {
return @"";
}
NSMutableString *string = [[NSMutableString alloc] initWithCapacity:[data length]];

[data enumerateByteRangesUsingBlock:^(const void *bytes, NSRange byteRange, BOOL *stop) {
unsigned char *dataBytes = (unsigned char*)bytes;
for (NSInteger i = 0; i < byteRange.length; i++) {
NSString *hexStr = [NSString stringWithFormat:@"%x", (dataBytes[i]) & 0xff];
if ([hexStr length] == 2) {
[string appendString:hexStr];
} else {
[string appendFormat:@"0%@", hexStr];
}
}
}];
return string;
}

十进制数字转 16 进制字符串，主要用于按位操作，可以通过转为 String，然后通过 String 按 Range 进行位操作。

Copy
NSString *hexStr = [NSString stringWithFormat:@"%02lx", (long)number];

16 进制字符串转十进制数字，用于需要进行算术运算的情况，需要先将字符串转为十进制数字，运算后，再转为 16 进制字符串。Ps: ** 在这里转换时需要注意，如果算术运算后的数字小于 0 时，直接把十进制数字通过上面方法转 16 进制字符串再去异或会有问题。

Copy
NSInteger num = strtoul(hexStr.UTF8String, 0, 16);

针对算术运算后小于 0 的数字的特殊处理如下：

Copy
NSInteger num = num - randNum;
if (num < 0) {
// 如果数字小于0，则用256+这个负数，再拿结果去转16进制字符串
num = 256 + num;
}
NSString *hexStr = [[NSString stringWithFormat:@"%02lx", (long)num] uppercaseString];

字符串异或方法

由于将 Data 转为了字符串，所以异或时需要对字符串进行异或，参考iOS 对两个相等长度的字符串进行异或运算，移除长度相等判断，改为按位异或

Ps：这里需要注意负数的情况

Copy
+ (NSString *)xorPinvWithHexString:(NSString *)hexStr withPinv:(NSString *)pinv {
NSString *resultStr;
NSRange range = NSMakeRange(0, 2);
for (NSInteger i = range.location; i < [hexStr length]; i += 2) {
unsigned int anInt;
NSString *hexCharStr = [hexStr substringWithRange:range];
NSString *pinxStr = [BluetoothTool pinxCreator:hexCharStr withPinv:pinv];
if (resultStr == nil) {
resultStr = pinxStr;
} else {
resultStr = [NSString stringWithFormat:@"%@%@", resultStr, pinxStr];
}
range.location += range.length;
range.length = 2;
}
return resultStr;
}

+ (NSString *)pinxCreator:(NSString *)pan withPinv:(NSString *)pinv
{
if (pan.length != pinv.length)
{
return nil;
}
const char *panchar = [pan UTF8String];
const char *pinvchar = [pinv UTF8String];

NSString *temp = [[NSString alloc] init];

for (int i = 0; i < pan.length; i++)
{
int panValue = [self charToint:panchar[i]];
int pinvValue = [self charToint:pinvchar[i]];

temp = [temp stringByAppendingString:[NSString stringWithFormat:@"%X",panValue^pinvValue]];
}
return temp;
}

+ (int)charToint:(char)tempChar
{
if (tempChar >= '0' && tempChar <='9')
{
return tempChar - '0';
}
else if (tempChar >= 'A' && tempChar <= 'F')
{
return tempChar - 'A' + 10;
}

return 0;
}

CRC8 算法

注意厂商提供的校验算法，如果是 CRC8 校验的，可以参考下面的，但是还要注意是否是 CRC8 maxin 校验，最好可以在线尝试下。下面的代码参考iOS 蓝牙开发中的 CRC8 校验，是 CRC8 maxin 校验。

Copy
// CRC8校验
+ (NSString *)crc8_maxin_byteCheckWithHexString:(NSString*)hexString {
NSString * tempStr = hexString;
NSArray *tempArray = [self getByteForString:hexString];
// NSArray * tempArray = [tempStr componentsSeparatedByString:@" "];//分隔符
unsigned char testChars[(int)tempArray.count];
for(int i=0;i<tempArray.count;i++){
NSString * string = tempArray[i];
unsigned char fristChar = [self hexHighFromChar:[string characterAtIndex:0]];
unsigned char lastChar = [self hexLowFromChar:[string characterAtIndex:1]];
unsigned char temp = fristChar+lastChar;
testChars[i] = temp;
}
unsigned char res = [self crc8_maxin_checkWithChars:testChars length:(int)tempArray.count];
return [NSString stringWithFormat:@"%x", res];
}

+(unsigned char)hexHighFromChar:(unsigned char) tempChar{
unsigned char temp = 0x00;
switch (tempChar) {
case 'a':temp = 0xa0;break;
case 'A':temp = 0xA0;break;
case 'b':temp = 0xb0;break;
case 'B':temp = 0xB0;break;
case 'c':temp = 0xc0;break;
case 'C':temp = 0xC0;break;
case 'd':temp = 0xd0;break;
case 'D':temp = 0xD0;break;
case 'e':temp = 0xe0;break;
case 'E':temp = 0xE0;break;
case 'f':temp = 0xf0;break;
case 'F':temp = 0xF0;break;
case '1':temp = 0x10;break;
case '2':temp = 0x20;break;
case '3':temp = 0x30;break;
case '4':temp = 0x40;break;
case '5':temp = 0x50;break;
case '6':temp = 0x60;break;
case '7':temp = 0x70;break;
case '8':temp = 0x80;break;
case '9':temp = 0x90;break;
default:temp = 0x00;break;
}
return temp;
}

+(unsigned char)hexLowFromChar:(unsigned char) tempChar{
unsigned char temp = 0x00;
switch (tempChar) {
case 'a':temp = 0x0a;break;
case 'A':temp = 0x0A;break;
case 'b':temp = 0x0b;break;
case 'B':temp = 0x0B;break;
case 'c':temp = 0x0c;break;
case 'C':temp = 0x0C;break;
case 'd':temp = 0x0d;break;
case 'D':temp = 0x0D;break;
case 'e':temp = 0x0e;break;
case 'E':temp = 0x0E;break;
case 'f':temp = 0x0f;break;
case 'F':temp = 0x0F;break;
case '1':temp = 0x01;break;
case '2':temp = 0x02;break;
case '3':temp = 0x03;break;
case '4':temp = 0x04;break;
case '5':temp = 0x05;break;
case '6':temp = 0x06;break;
case '7':temp = 0x07;break;
case '8':temp = 0x08;break;
case '9':temp = 0x09;break;
default:temp = 0x00;break;
}
return temp;
}

+ (NSArray *)getByteForString:(NSString *)string {
NSMutableArray *strArr = [NSMutableArray array];
for (int i = 0; i < string.length/2; i++) {
NSString *str = [string substringWithRange:NSMakeRange(i * 2, 2)];
[strArr addObject:str];
}
return [strArr copy];
}

16 进制字符串转为 Data

这个方法用于发送指令给蓝牙，由于所有逻辑都是转为 16 进制字符串处理的，而蓝牙设备只接收 Data，所以需要将 16 进制字符串转为 Data，再发送给蓝牙。

Ps：这里最好也先将字符串转为大写，再转为 Data

Copy
// 将16进制的字符串转为NSData, 传入的字符串转为128位字符，不足位补数字，如果需要对应位，截取位置即可。 @"0a1234 0b23454" -> <0a1234 0b23454>
+ (NSData *)convertHexStrToData:(NSString *)hexStr {
if (!hexStr || [hexStr length] == 0) {
return nil;
}

NSMutableData *hexData = [[NSMutableData alloc] initWithCapacity:20];
NSRange range;
if ([hexStr length] % 2 == 0) {
range = NSMakeRange(0, 2);
} else {
range = NSMakeRange(0, 1);
}
for (NSInteger i = range.location; i < [hexStr length]; i += 2) {
unsigned int anInt;
NSString *hexCharStr = [hexStr substringWithRange:range];
NSScanner *scanner = [[NSScanner alloc] initWithString:hexCharStr];

[scanner scanHexInt:&anInt];
NSData *entity = [[NSData alloc] initWithBytes:&anInt length:1];
[hexData appendData:entity];

range.location += range.length;
range.length = 2;
}
return hexData;
}

踩坑

蓝牙初始化崩溃，Assertion failure in -[CBCentralManager initWithDelegate:queue:options:], CBCentralManage...

是因为新建项目没有开启蓝牙权限，将 Project -> Target -> Signing & Capabilities 中 Background Modes 下Use Bluetooth LE accessories勾选上即可，如下图所示:

多台设备切换连接错乱

多台设备来回切换时发现有错乱的情况，即原来是连接的蓝牙设备 1，然后针对蓝牙设备 2 发送指令，结果指令操作到了蓝牙设备 1 上，起初以为是没有调用断开连接的方法，或者断开的时间不够久。排查后发现是因为用了retrieveConnectedPeripheralsWithServices导致的。每次断开连接后，再次连接时，通过retrieveConnectedPeripheralsWithServices获取到的第一个设备仍是刚刚断开连接的设备，所以再次连接时，就连接了错误的蓝牙设备。

异或结果错误

在开发中还遇到了另外一个问题，就是逻辑和加密算法都没问题的情况下，偶尔出现指令失效的情况。起初以为是蓝牙设备的问题，因为有些指令能成功，而有些不能。排查后发现，是因为算法中涉及算术运算部分，出现负数时，指令就会失败，再仔细研究后发现，是负数转 16 进制再去异或运算时，出现问题。解决办法是，针对出现负数的情况，改为 (256 + 负数) 转为正值，然后再转 16 进制再去异或计算。

上线后，有用户反馈，APP 进入后台时，提示如下信息

『xxx』想要使用蓝牙进行新连接，您可以在设置中允许新的连接。

一开始以为是后台有蓝牙活动，排查后发现，进入后台时会调用，蓝牙断开连接的方法。所以不是后台活动的问题。和用户沟通后发现是用户蓝牙开关关闭，进入后台会提示这个，打开时就没有这个问题。是因为在断开连接的方法里，默认使用了初始化的CBCentralManager，而没有判断蓝牙开关是否开启。

总结

在对接蓝牙设备时，首先需要在 Xcode 中配置蓝牙权限，然后通读设备厂商提供的文档，着重注意蓝牙设备的 Mac 地址如何提供，蓝牙设备的服务 UUID 和读写 UUID 是否提供，如何判断蓝牙是否链接成功，以及指令加解密方法等。然后再通过系统提供的方法初始化蓝牙，封装处理蓝牙操作指令的方法和加解密方法。最后当所有完成后，记得断开蓝牙设备的链接。

参考

iOS 蓝牙知识快速入门（详尽版）

iOS 蓝牙开发 CoreBluetooth 全面讲解

IOS 蓝牙通信各种数据类型之间的转换

iOS 对两个相等长度的字符串进行异或运算

iOS 蓝牙开发中的 CRC8 校验