3月中旬跳槽了,一直在新公司「填坑」,看着「先人」写的代码,觉得是有改善空间的,所以这次想聊下这部分内容——iOS蓝牙开发中如何更好地更好地收发数据。
适读对象:
- 想初步了解iOS蓝牙开发的朋友(最好连计算机基础都没有,就像我这种没有计算机科班基础的伪程序猿(真文科汪));
- 做过蓝牙开发,但是没有很「优雅」地收发数据的朋友(直接用C语言char数组装回来,用下标索引去取用)。
注意:
- 本文所说的蓝牙,指BLE(Bluetooth Low Energy/低功耗蓝牙)。一般应用苹果的官方框架CoreBluetooth开发。当然,会有不同的第三方框架,最近我做的项目用的就是第三方框架BabyBluetooth。
- 本文部分代码,有两种版本,应用苹果框架CoreBluetooth时,用的是Swift。用BabyBluetooth时,用的是Objective-C。
我们会从哪里拿到数据?
我们先简单回顾一下整个蓝牙数据接收的一般流程:
- 1、蓝牙在不断地在广播信号;
- 2、APP扫描;
- 3、发现设备(根据名称或「服务」的UUID来辨别是不是我们要连接的设备);
- 4、连接(成功);
- 5、调用方法发现「服务」;
- 6、调用方法发现服务」里的「特征」;
- 7、发现硬件用于数据输人的「特征」,保存(APP发送数据给硬件时要用到这个「特征」);
- 8、发现硬件用于数据输出的「特征」,进行「监听」(硬件就是从这个「特征」中发送数据给手机端);
- 9、利用数据输入「特征」发送数据,或者等待数据输出「特征」发出来的数据。
其中第7~8步的代码(Swift版)如下:
// 第7、8步:
// 发现特征的回调(委托)方法(假设在这之前已经「成功连接」、「发现服务」)
func peripheral(peripheral: CBPeripheral, didDiscoverCharacteristicsForService service: CBService, error: NSError?) {
print("发现设备有\(service.characteristics?.count)个特征, 是:\(service.characteristics)")
// 用for循环,找到自己要的特征(以UUID为辨别依据)
for characteristic in service.characteristics! {
switch characteristic.UUID {
// 7、发现数据写入的特征(我们的硬件是:FF01)
case kCharacteristicDataInUUID:
print("这是用于数据写入的特征,它的UUID是:\(characteristic.UUID)")
// 8、发现硬件输出数据(APP读取硬件数据)的特征(我们的硬件是:FF02)
case kCharacteristicDataOutUUID:
// 监听DataOut特征
print("这是用于读取数据的特征,它的UUID是:\(characteristic.UUID)")
// 8、进行监听
peripheral.setNotifyValue(true, forCharacteristic: characteristic)
default:
print("default")
}
}
}
// 第9步:
// 最终,蓝牙发过来的数据,我们会在这个回调方法中拿到
func peripheral(peripheral: CBPeripheral, didUpdateNotificationStateForCharacteristic characteristic: CBCharacteristic, error: NSError?) {
print("收到从蓝牙「FFF2特征」发出的数据:\(characteristic.value)")
// value是一个「NSData?」类型的对象
}
所以,我们最终会在peripheral(_:didUpdateNotificationStateForCharacteristic:error:)
方法中拿到数据。Objective-C对应的方法是peripheral:didUpdateNotificationStateForCharacteristic: error:
注意,要先用setNotifyValue(_:forCharacteristic characteristic:)
监听对应的特征,才能在上述方法拿到数据。
如果在Objective-C中,会长这样子(不是官方的框架,用的是BabyBluetooth框架):
// BabyBluetooth这个框架框架将监听和回调写在一起(用Block实现),能让代码不至于那么分散:
// 也就是上面的第8、9两步合在一个方法中了
[_baby notify:peripheral characteristic:_dataOutCharacteristic block:^(CBPeripheral *peripheral, CBCharacteristic *characteristics, NSError *error) {
NSLog(@"收到从蓝牙「FFF2特征」发出的数据: %@", characteristics.value);
}
我们会拿到什么样的数据?
好了,经过上面的一系列稍显繁琐的步骤,我们从蓝牙那边拿到了「NSData?」类型(Objective-C对应的是「NSData」类型)的数据。
我们打印一个「NSData?」对象看看:
print("收到从蓝牙「FFF2特征」发出的数据:\(characteristic.value)")
在控制台,会这样输出类似这样的东西:
收到蓝牙发出来的数据:
这些是什么鬼?
这要从NSData说起,NSData是怎么样的数据呢?要经过怎么的处理,才能变成我们自己需要的数据呢?
苹果的官方文档《Binary Data Programming Guide》中的章节:Accessing and Comparing BytesAccessing and Comparing Bytes说得比较详细,英文好的朋友可以看看。
我们暂且这样理解:NSData(NSMutableData)是二进制数据对象——苹果将二进制数据封装成对象,让我们可以用面向对象的思维去操作这些数据。
我们可以通过原始的二进制数据(Raw Bytes)去生成NSData对象,也可以通过NSData存取/访问(Accessing)这些二进制数据。
你在逗我么?说好的二进制数据呢?不应该全部是0、1么?为什么会有d啊、a啊、f啊,罩杯么?
莫生气,
只是用十六进制呈现给我们而已,也就是0xda、0x13、0xff、0xff、0xff、0x64、0x00、0x99,蓝牙传了这8个十六进制的数(8个byte)给我们。
为什么不直接用二进制?好,我知道你不死心的,二进制是这样的:<11011010 00010011 11111111 11111111 11111111 01100100 00000000 10011001>
晕没有?你要继续坚持用二进制吗?「阿尔法狗」倒应该是很乐意的。
正因为二进制与十六进制之间的转换比较简单,所以在计算机领域,16进制比较通用。这就解释了为什么我们打印出来的NSData对象最终以十六进制方式呈现(上面才仅仅是8个byte的0和1。1KB=1024Bytes,给你0.5KB的0和1,十副老花镜都看不过来)。
这些数据有什么意义(表示什么)?
这个问题问得好,这个问题就好比如:「鸡」为什么叫「鸡」,「鸭」为什么叫「鸭」?(好不搭边的比喻~)
其实是这样的,很久很久以前,第一个发现「鸡」这个物种的中国人,他脑洞不知道为什么就浮现了「鸡」这个字,于是很随机地用「鸡」这个「符号」把它「定义」为「鸡」。如果你能穿越回去,完全可以让他用「鸭」这个「符号」的,如果真是那样,现在的「鸡」就不是「鸡」,「鸭」就不是「鸭」了,而应该是「鸡」是「鸭」,「鸭」是「鸡」……是不是有点晕?放心,以目前的科技水平,你是没办法穿越回去的,所以,「鸡」还是「鸡」,「鸭」还是「鸭」。
言归正传,所以这8个十六进制数据表示什么,完全取决于我们自己的「定义」,程序猿们会把这种「定义」叫做「协议」,也有叫「指令」的。请看下图,这就是其中一个聪明的猿类「定义」的一条指令:
- 第1个字节表示起始位;
- 第2个字节是指令号,用于识别是哪一条指令;
- 第3-4个字节,表示的是颜色值(分别代表RGB三原色其中一色);
- 第6个字节表示亮度值;
- 第7个字节是保留位,作用是如果突然要增加内容,有位置可加;
- 第8个字节是校验位,用于确保整条指令的完整性(可以是固定值,也可以通过一定的算法算出,这里是使用固定值),大概意思就是:见到0x99,就表示这是一条完整的指令了。
备注:这里的「MCU to Phone」,表示这条数据是从硬件(单片机)发送到手机的。
所以,你从蓝牙接收到的数据,不要问我有什么意义,表示的是什么。应该问写固件、作定义的同事,或者是写APP的和写固件的同事一起定义——往往固件的同事单独定义,对写APP的同事来说,会有很多坑,因为他们很难考虑得到APP这边的情况(深受其害状)。
如何更好地收发数据
好了,上面讲了一大堆,终于要和标题扯上点关系了。
拿上面的收到的这条指令举例,或许你已经发现,对我们有意义的数据,其实就是byte3~byte6这4个字节,前3个是颜色值,最后1个是亮度值(其实这是一个利用蓝牙,用手机APP控制灯具颜色、亮度的产品。这条指令是从硬件(Device to Mobile)获取颜色、亮度值)。
我们当然可以简单粗暴直接地声明一个可以容纳若干个元素的C语言数组(buffer),来接收这8bytes数据(我所在公司的前同事也的确是这样做的),类似如下流程:
// 会声明一个可以容纳若干个元素的C数组(类型一般是无符号的char类型)
// 在OC中,UInt8、uint8_t都是unsigned char
UInt8 tmpBuffer[128] = {0};
// 然后用NSData的getBytes:方法拿到数据
[characteristic.value getBytes:tmpBuffer];
// 再从中取用数据
unsigned char startBit = tmpBuffer[0];
light.brightness = tmpBuffer[5];
light.colorR = tmpBuffer[2];
light.colorG = tmpBuffer[3];
light.colorB = tmpBuffer[4];
……
// 有时候还要对tmpBuffer操作,用一堆如memset()、memcpy()等C语言函数,让对C语言不是特别熟的童鞋直接吐血
上面出现了很多「魔术数字」,让后面看代码、维护代码的人看得云里雾里,如果复杂度再高一点,直接吐血。
有没有更好的办法?我们是这样做的:
// 专门有一个类用结构体定义好这些指令
#pragma mark - Device 2 Mobile
#pragma mark Response: 0x13 蓝牙模块返回数据
// 其实这里有个坑,当单个数据的大小为2字节或以上时,我们用UInt16或UInt32去定义,会有「自动对齐」的问题,就是接到的数据,没有按指令定义的顺序对齐,导致数据不正确,这时候可以在struct后面加关键字:「__attribute__((packed))」。(我掉这个坑好久,最后上StackOverflow提问解决)
typedef struct {
UInt8 startBit;
UInt8 cmd;
UInt8 colourR;// 取值范围:0-255
UInt8 colourG;
UInt8 colourB;
UInt8 brightnessValue;// 取值范围:0-255, 0为灭,255为最亮
UInt8 reserved;
UInt8 checksum;
} D2MDeviceParamResponse;
// 然后在接收到数据的地方,定义并用这个结构体接收数据
const void *raw = characteristics.value.bytes;
D2MDeviceParamResponse *responseData = (D2MDeviceParamResponse *)raw;
// 取用数据则这样
light.brightness = responseData->brightnessValue;
light.colorR = responseData->colourR;
light.colorG = responseData->colourG;
light.colorB = responseData->colourB;
//不会出现一个「魔术数字」,直接看代码,就知道是什么东西了。
下面是Swift版本:
// 定义指令
// MARK:- Device 2 Mobile
// MARK:Response: 0x13 蓝牙模块返回数据
struct D2MDeviceParamResponse {
var startBit: UInt8
var cmd: UInt8
var colourR: UInt8
var colourG: UInt8
var colourB: UInt8
var brightnessValue: UInt8
var reserved: UInt8
var checksum: UInt8
}
// 取用数据
// 对Swift还不是十分熟悉,不知道还有没有其他更好的初始化方法(哭)
var cmd = D2MDeviceParamResponse(startBit: 0,
cmd: 0,
colourR: 0,
colourG: 0,
colourB: 0,
brightnessValue: 0,
reserved: 0,
checksum: 0)
characteristic.value!.getBytes(&cmd, length:sizeof(D2MDeviceParamResponse))
light.brightness = cmd.brightnessValue
light.colorR = cmd.colourR
light.colorG = cmd.colourG
light.colorB = cmd.colourB
当然,发送指令也是类似的,先定义好容器(struct),再进行赋值封装发送,不再赘述。
这样是不是会比写一堆中括号加下标索引直观很多?
大神们说最好的说明文档就是代码,代码尽量写得让人能意会到你的目的、意图,也算是对代码的后来维护者的一大功德~~
好困,睡觉。