一文学会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负责的是蓝牙初始化、扫描、连接,初始化方法中会弹出申请蓝牙权限申请,不需要显式声明
1 2 3 4 5 6
| 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: 如果蓝牙设备有低电量休眠功能,可以在这里提示用户手动先激活蓝牙,否则连接比较慢,或链接不上
1 2 3 4 5 6 7 8 9 10
| - (void)startScan { NSDictionary *dic = @{ CBCentralManagerScanOptionAllowDuplicatesKey: @NO, CBCentralManagerOptionShowPowerAlertKey: @YES }; [self.centralManager scanForPeripheralsWithServices:nil options:dic]; }
|
另外,如果不存在附近多台蓝牙设备来回切换的情况,可以用如下方法,快速连接上次连接过的设备。retrieveConnectedPeripheralsWithServices方法会获取蓝牙连接成功的设备,这些设备可能不是本APP连接的,所以使用时需要额外注意。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| - (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:发起连接。
连接成功后,停止扫描蓝牙设备,设置蓝牙设备的代理,开始扫描服务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| #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)){ NSData *manufacturerData = [advertisementData valueForKey:@"kCBAdvDataManufacturerData"]; NSString *manufacturerDataStr = [BluetoothTool convertDataToHexStr:manufacturerData]; if (manufacturerDataStr) { NSString *deviceMacStr = [manufacturerDataStr substringWithRange:NSMakeRange(x, 12)]; if ([deviceMacStr caseInsensitiveCompare:targetDeviceMac] == NSOrderedSame) { [self.centralManager connectPeripheral:peripheral options:nil]; self.peripheral = peripheral; } } } }
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral { [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:是命令是否写入成功的回调,成功标明指令成功写入到的蓝牙设备,即蓝牙设备成功收到了指令,但是指令是否执行成功是要根据上面的返回数据的方法判断。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| #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]; } } } }
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(nullable NSError *)error { for (CBCharacteristic *characteristic in service.characteristics) { 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){ 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 进制字符串处理,可能后面有需要进行算术运算,所以最好转为字符串后,统一转为大写处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| + (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进行位操作。
1
| NSString *hexStr = [NSString stringWithFormat:@"%02lx", (long)number];
|
16进制字符串转十进制数字,用于需要进行算术运算的情况,需要先将字符串转为十进制数字,运算后,再转为16进制字符串。Ps: **在这里转换时需要注意,如果算术运算后的数字小于0时,直接把十进制数字通过上面方法转16进制字符串再去异或会有问题。
1
| NSInteger num = strtoul(hexStr.UTF8String, 0, 16);
|
针对算术运算后小于0的数字的特殊处理如下:
1 2 3 4 5 6
| NSInteger num = num - randNum; if (num < 0) { num = 256 + num; } NSString *hexStr = [[NSString stringWithFormat:@"%02lx", (long)num] uppercaseString];
|
字符串异或方法
由于将 Data 转为了字符串,所以异或时需要对字符串进行异或,参考iOS 对两个相等长度的字符串进行异或运算,移除长度相等判断,改为按位异或
Ps:这里需要注意负数的情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| + (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 校验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| + (NSString *)crc8_maxin_byteCheckWithHexString:(NSString*)hexString { NSString * tempStr = hexString; NSArray *tempArray = [self getByteForString:hexString]; 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| + (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是否提供,如何判断蓝牙是否链接成功,以及指令加解密方法等。然后再通过系统提供的方法初始化蓝牙,封装处理蓝牙操作指令的方法和加解密方法。最后当所有完成后,记得断开蓝牙设备的链接。
参考