看完你就懂了-移动端蓝牙开发

avatar
@古茗科技

作者:张义飞

背景

蓝牙从1998 年被发布,真正应用到物联网时代是在 2010 年第四代蓝牙标准出来。蓝牙 4.0+ BLE 标准。 iPhone 4S是收款支持蓝牙 4.0 的智能手机。Android 4.3 (API Level 18) 也为开发者提供了一套基于低功耗蓝牙开发的 API。 蓝牙在物联网领域有着很广泛的应用,比如:手表,手环,体重秤,智能门锁等等。我们的手机也有蓝牙模块,而且是“双模”。像我们使用的蓝牙耳机听歌,这就是使用的是经典蓝牙协议,像我们使用手机去解锁门锁,和连接手环等这些则使用的是低功耗蓝牙。我们公司也有很多的低功耗蓝牙设备,所以我们需要在手机/平板端上需要与蓝牙设备进行通信,本篇文章会介绍低功耗蓝牙的一些特点和在移动端开发的一些注意事项。

什么是低功耗蓝牙

低功耗蓝牙相较于经典蓝牙来说,就是成本低,耗能低。所以被广泛应用于物联网设备。经典蓝牙传输数据量比较大,耗能相对较高主要用于文件传输,耳机等。经典蓝牙在使用时需要进行配对,双方配对成功后才能进行通信。低功耗蓝牙虽然也能进行配对,但这不是必须项。

蓝牙功耗数据传输速率连接方式应用场景协议层
低功耗蓝牙低功耗传输速率较低,通常在1 Mbps左右,适合间歇性的数据传输通常采用“广播”模式,与设备的连接更为灵活,支持多点连接适合物联网设备、可穿戴设备等采用不同的协议栈,优化了数据传输和功耗管理
经典蓝牙功耗较高传输速率较快,能达到3 Mbps或更高,适合持续的数据流通常采用点对点的连接方式,适合需要稳定连接的应用主要应用于音频传输(如耳机、音箱)、文件传输等使用经典的蓝牙协议,适合大数据量的传输

蓝牙微微网

微微网是通过蓝牙技术以特定方式连接起来的一种微型网络,可以由一个主设备和最多七个从设备组成。其中任何一台设备都可以是主设备或从设备。主设备一般负责同步时钟信号和发起连接。从设备一般接收主设备控制。一般都是我们的手机,平板或者电脑作为主设备,其他蓝牙设备作为从设备。一个设备不能既是主设备又是从设备,如果一个设备是一个主设备的从设备,那么它就不能在被另外一个设备连接。

主设备和从设备一旦建立连接,便可以进行通信。如果主设备想获取从设备数据由两种方式,

  1. 主设备轮询从设备
  2. 从设备通知主设备

上面的通信方式和经典的 C/S 架构一样。

当然蓝牙服务也会有些像后端定义的接口的概念。比如我们想要读取某个信息,在一般的 C/S 架构中,后端会给我们一个 url, 然后告诉我们是get还是 post请求。然后进行请求,服务端返回相应的数据。蓝牙服务中也是类似,我们想要访问蓝牙服务上的某个资源,蓝牙服务同样也是暴漏一个服务(service)和这个服务下有哪些特征值(Characteristic),这些特征值有哪些属性(可读,可写)等。

举个列子

我们可以使用 bleno 这个库来模拟外设备设备,该库可在 windows,linux, mac下模拟一个低功耗蓝牙的外设设备,然后我们在手机上下载“蓝牙调试助手”相关的 app,来看下上面的属性到底都是什么意思。

const name = 'bill'; // 广播设备名称
const serviceUuids = ['fffffffffffffffffffffffffffffff0']; // 广播服务UUID

// 监听蓝牙状态
bleno.on('stateChange', (state) => {
  if(state === 'poweredOn') { // 蓝牙打开事件
    // 开始广播
    bleno.startAdvertising(name, serviceUuids);
    // 创建特征
    const characteristic = new Characteristic({uuid: 'fffffffffffffffffffffffffffffff2', properties: ['read', 'write'], descriptors: [
      // 描述
      new Descriptor({uuid: '2901', value: 'This is a test characteristic'})
    ]});
    // 创建服务
    const service = new PrimaryService({uuid: 'fffffffffffffffffffffffffffffff1',characteristics: [characteristic]});
    bleno.setServices([service]);
  }});

上面我们创建了一个广播名为 **bill **的蓝牙,广播服务UUID 为 **fffffffffffffffffffffffffffffff0,**当蓝牙打开后,开时广播,并暴漏一个服务 **fffffffffffffffffffffffffffffff1,**该服务特征值是 **fffffffffffffffffffffffffffffff2,**支持读和写。

好了,我们看到这个特征值支持读和写,那么我们现在在加些代码,读取和写入数据

// 监听蓝牙状态
bleno.on('stateChange', (state) => {
  if(state === 'poweredOn') { // 蓝牙打开事件
    // 开始广播
    bleno.startAdvertising(name, serviceUuids);
    // 创建特征
    const characteristic = new Characteristic({uuid: 'fffffffffffffffffffffffffffffff2', properties: ['read', 'write'], descriptors: [
      // 描述
      new Descriptor({uuid: '2901', value: 'This is a test characteristic'})
    ], onWriteRequest: (data, offset, withoutResponse, callback) => {
      // 收到主设备发来的数据,告诉主设备写入成功
      console.log('onWriteRequest: ', data.toString('hex'));
      callback(Characteristic.RESULT_SUCCESS);
    }, onReadRequest: (offset, callback) => {
      // 主设备读取特征值
      callback(Characteristic.RESULT_SUCCESS, Buffer.from('Hello, bill!', 'utf8'));
    }});
    // 创建服务
    const service = new PrimaryService({uuid: 'fffffffffffffffffffffffffffffff1',characteristics: [characteristic]});
    bleno.setServices([service]);
}});

主设备写入数据

从设备收到主设备的数据。68656c6c6f20776f726c6421 将收到的 hex string解码后就是我们发送的Hello, world!

那我们在看下主设备读取从设备的特征

蓝牙连接

广播

主设备要想连接到从设备,从设备需要处于被发现状态,也就是从设备处于广播状态。从设备会每隔一段时间就会发送一次广播包,主设备扫描到广播包就可以发起连接了。每个广播包会包含一些设备的特征,比如设备名称。用来区分设备。每个广播包的大小不能超过31个字节。从设备会在三个信道上按照一定时间进行广播包的广播。

扫描

外围设备只负责广播包的发送,但是它并不只知道有扫描者的存在,所以扫描者和广播者只有在同一信道和时间上重合才能发现广播者。所以你可能会很快扫描到设备,还有可能很长时间在扫描到。android 手机需要打开定位服务才能扫描到设备。 主设备可以对已扫描到的从设备发起连接请求,从而连接从设备并通信。

连接流程

主要的通信流程便是从设备处于广播状态,手机扫描到设备并,建立连接。连接成功后双方协商 mtu,获取从设备的服务和特征值,主设备监听特征值变化。从设备连接成功后,就会停止广播,这个时候其他主设备就不能在发现它了。

通信数据包

主设备和从设备建立连接后,就会协商 mtu,来确定双方的通信数据的最大值。这个地方对于不同的协议设计和手机存在一些兼容性问题。因为上面我们说到不同手机上的蓝牙模块可能不同,不同蓝牙模块的最大 mtu 不同。

蓝牙4.2 以下

蓝牙 4.2(不包含 4.2) 以下 mtu 最大为 23,去除 3 个字节后,留给我们传输的最大是 20 个字节。所以我们的协议需要支持 20 个字节的分包传输。

蓝牙 4.2 以上

蓝牙 4.2 和 5.x 的一帧数据,最大支持 244 个字节,并且支持 mtu 的协商 ,协商后 最大的传输数据在 0-244 区间,这里对我们的通信协议的制定就会有很大操作性。

mtu协商

mtu 的协商需要在每次建立连接之后,由主设备发起协商,双方最终会以最小的 mtu 作为数据传输标准。iPhone 6 之后用的都是蓝牙 4.2 以上的蓝牙模块了,所以在 iOS 上是没有协商 mtu 的 api 的。andorid 有很多机型,默认的 mtu 为 23 个字节,去除 ATT Header 的三个字节,其中只有 20 个字节了。钉钉小程序的 api 也未提供协商 mtu 的 api,微信小程序有协商 mtu 的 api。所以在协商 mtu 这个问题上,受主设备和从设备的蓝牙协议影响。为了更好的兼容性,通信协议尽量需要有分包处理,以 20 个字节分包处理,兼容性和传输速率是最好的。

蓝牙底层的分包机制

上面我们说到我们一般会基于协商的 mtu 来进行分包和数据传输,但是这种情况在不同平台上如果没有协商 mtu 的 api 以及手机上蓝牙模块协议比较低。默认传输数据为 20 个字节。如果外围设备自定义的通信协议一帧数据大于 20 个字节。比如下方的数据包,一帧数据就超过了 20 个字节。我们就无法自行分包。那么这里我们就需要使用蓝牙底层的分包机制了。

我们是用上面的列子,找个 android 手机来看下。我们发送数据为 123456789126456789123456789,是 27 个字节超过了android 默认的 20 个字节。蓝牙发送时内部会进行分包。

从设备收到的数据,mtu 为 20

2024-09-03 14:52:55.774 收到第一包: 3132333435363738393132363435363738393132

2024-09-03 14:52:55.819 收到第二包:333435363738390a

总数据为: 3132333435363738393132363435363738393132333435363738390a

两次收到的数据包时间间隔为:819 - 744 = 75ms, 这个时间间隔不是固定的。如果你要传输的数据为 68 个字节那么底层的蓝牙会分为四包发送,那这四包的延时可能会达到 200ms 左右。 解析数据的代码:

function hexToUtf8(hex) {
    const bytes = [];
    for (let i = 0; i < hex.length; i += 2) {
        bytes.push(parseInt(hex.substr(i, 2), 16));
    }
    return String.fromCharCode(...bytes);
}

const hexString = "3132333435363738393132363435363738393132333435363738390a";
const utf8String = hexToUtf8(hexString);
console.log(utf8String); // 123456789126456789123456789

那从设备会相差一定时间收到各个数据包的大小,所以设备为了收到完整的数据就要增加一个延时和缓存。这就导致会发生黏包问题,也就是两帧数据发生黏包。如果设备设置的延时比较大,那么黏包问题会严重,如果设置的延时比较小那么就可能收不到完整的数据包,需要在下个延时里才能获取到。所以设备需要处理黏包以及组包的逻辑才能保证数据的完整性问题。

修改一下 android 手机的 mtu 设置成 140,这样也能一包收到这个数据了。最后一个 0a 为换行符,是发送方增加的。可以忽略

我们在来看下 iOS 手机上发送相同数据,外围设备收到的数据是怎样的。

发送的数据

收到的数据

这里一次就收到的完整的数据包,而且自动协商的 mtu 为 512。怎么比上面说的 mtu 值大那,因为上面说的是蓝牙 4.2 和蓝牙 5.0。我的手机是 iPhone14, 在这里你能查到你的手机信息。理论上你的蓝牙模块越好支持的最大值越大,但是有什么用类,android 手机兼容怎么办!!!

处理黏包

一般处理黏包问题有几个方法(根据自己的通信协议来)

  1. 按照固定的头部或者尾部进行截取
  2. 固定消息长度
  3. 根据协议中固定长度进行读取

总结

在搜索阶段 android 和 iOS 的不同点是android 需要地理位置的权限。在数据通信阶段 android 如果未设置 mtu 的话默认是 20 个字节的数据。有些同学可能在某些机型上设置 mtu 时时无效的,可能是设备或者手机的蓝牙模块低于 4.2,导致设置失败。所以尽量通信协议支持的最小是 20 个字节,这样兼容性时最高的。 iOS 则会自动进行协商 mtu,来进行发送和接收数据。如果在小程序上进行蓝牙开发,有些小程序的 api 协议不支持设置 mtu,比如钉钉小程序。如果在钉钉小程序上进行开发的话,你们的通信协议超过 20 个字节就会触发蓝牙底层的分包机制。这个时候需要设备进行一些兜底操作,比如处理黏包和组包的操作。