iOS蓝牙开发如何更好地收发数据

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啊,罩杯么?

莫生气,只是用十六进制呈现给我们而已,也就是0xda0x130xff0xff0xff0x640x000x99,蓝牙传了这8个十六进制的数(8个byte)给我们。

为什么不直接用二进制?好,我知道你不死心的,二进制是这样的:<11011010 00010011 11111111 11111111 11111111 01100100 00000000 10011001>晕没有?你要继续坚持用二进制吗?「阿尔法狗」倒应该是很乐意的。

正因为二进制与十六进制之间的转换比较简单,所以在计算机领域,16进制比较通用。这就解释了为什么我们打印出来的NSData对象最终以十六进制方式呈现(上面才仅仅是8个byte的0和1。1KB=1024Bytes,给你0.5KB的0和1,十副老花镜都看不过来)。

这些数据有什么意义(表示什么)?

这个问题问得好,这个问题就好比如:「鸡」为什么叫「鸡」,「鸭」为什么叫「鸭」?(好不搭边的比喻~)

其实是这样的,很久很久以前,第一个发现「鸡」这个物种的中国人,他脑洞不知道为什么就浮现了「鸡」这个字,于是很随机地用「鸡」这个「符号」把它「定义」为「鸡」。如果你能穿越回去,完全可以让他用「鸭」这个「符号」的,如果真是那样,现在的「鸡」就不是「鸡」,「鸭」就不是「鸭」了,而应该是「鸡」是「鸭」,「鸭」是「鸡」……是不是有点晕?放心,以目前的科技水平,你是没办法穿越回去的,所以,「鸡」还是「鸡」,「鸭」还是「鸭」。

言归正传,所以这8个十六进制数据表示什么,完全取决于我们自己的「定义」,程序猿们会把这种「定义」叫做「协议」,也有叫「指令」的。请看下图,这就是其中一个聪明的猿类「定义」的一条指令:

我们将这8个byte所表示的内容定义清楚
  • 第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),再进行赋值封装发送,不再赘述。

这样是不是会比写一堆中括号加下标索引直观很多?

大神们说最好的说明文档就是代码,代码尽量写得让人能意会到你的目的、意图,也算是对代码的后来维护者的一大功德~~

好困,睡觉。

你可能感兴趣的:(iOS蓝牙开发如何更好地收发数据)