微信小程序蓝牙通信开发之分包传输通信协议开发

蓝牙简介

引用微信开发者文档:蓝牙介绍,可以知道有两种类型的蓝牙:

  • 经典蓝牙
  • 低功耗蓝牙(BLE):从蓝牙4.0起支持的协议

目前,微信小程序只支持低功耗蓝牙协议。对于经典蓝牙,iOS因系统限制暂无法提供,安卓目前已在规划中。也就是微信小程序目前暂不支持经典蓝牙通信协议。

那微信小程序如果需要通过蓝牙控制硬件,只能使用低功耗蓝牙协议

关于低功耗蓝牙通信协议的介绍可以看微信文档:蓝牙低功耗。简单说,就是微信小程序需要和硬件里面的app约定服务uuid,特征值uuid等,从而实现读写能力。

微信小程序BLE传输相关API

写数据

根据微信文档:writeBLECharacteristicValue

可以得知:

  • 并行调用多次会存在写失败的可能
  • 单词传输的数据大小有限制。目前经过实验,如果我们写入的数据大于20字节,微信客户端不会限制我们写入的数据大小,相反,它会自动帮我们将数据拆分成若干个20字节的包。比如我们写入55个字节,那么微信客户端会自动帮我们拆成2个20个字节的包以及1个15字节的包,分三次写入硬件里面的app,硬件里面的app会分三次接收到数据。

我们需要自行设计一套可靠的传输机制,而不能采用微信客户端自动分包的策略,因为我们没法控制传输的可靠性,比如丢包了怎么办?我们没法感知。同时,我们需要控制在数据写入时,要异步串行执行,即成功传完一个分包,再传下一个分包,提供写数据的可靠性

读数据

微信提供了读数据的接口:readBLECharacteristicValue,但接收到的数据并不是通过这个接口的回调接收,而是要通过onBLECharacteristicValueChange注册监听,读的数据会在这个监听的回调里面拿到。

同时,并行读也会存在失败的可能性,因此在读数据时,我们也需要采用异步串行的方式读取。

分包传输协议设计

基于以上几点,我们决定采用类似TCP的分包传输协议,和TCP不同,这里我们的读写都采用异步串行执行的方式。

我们以微信小程序BLE每次只能传输20个字节的包来分包。每个分包20个字节,其中前面3个字节作为包头,包含reqId(当前的请求,用于解包),blockId(当前分包的索引,用于在接收端重组),data size(当前包data部分的实际大小,即有多少个字节是存储data数据的)。每个分包后面17个字节用来存储传输的分块数据,注意,最后一个分包有可能不满17个字节。所以需要data size部分来指示实际有多少个字节是data的,方便接收端解包。

这样设计的好处在于:

  • 1.每个分包都可以独立发送和接收,并且一个分包丢失了,不会影响另一个分包的解析。
  • 2.如果某个分包丢失了,只需要重传这个分包即可,提高可靠性。

具体的分包协议可以根据自身的实际业务进行,上面只是一个例子,比如有些业务传输的分块比较多,可能12bit的blockId不够用,那就需要相应地调整。

如何优雅地设计并实现

接下来,我们一步一步实现我们的类TCP分包协议。

可扩展性设计

我们需要确保分包传输协议的高可扩展性。

Promise with resolve

首先新建一个resolablePromise.ts文件,实现一个Promise with resolve,增强Promise的能力

js 复制代码
export const resolvablePromise = () => {
  let resolve;
  let reject;
  const promise: any = new Promise((_resolve, _reject) => {
    resolve = (...args: any) => {
      promise.fullfilled = true;
      promise.pending = false;
      _resolve(...args);
    };
    reject = (...args: any) => {
      promise.rejected = true;
      _reject(...args);
    };
  });
  promise.resolve = resolve;
  promise.reject = reject;
  promise.pending = true;
  return promise;
};

resolvablePromise增强了普通Promise的能力,使得我们可以灵活的调用Promise.resolve等方法,还能判断Promise的状态。如果不理解这样设计的目的,可以先记住这个,后面的使用就懂了。

通用的异步串行执行任务队列

由于微信并行读写会大概率失败,因此我们需要确保我们的读写是异步串行执行的。因此可以封装一个通用的异步串行执行任务队列。

js 复制代码
type AsycnTaskType = () => Promise<any>
// 通用异步串行执行任务队列
const createAsyncSeriesTaskQueue = () => {
  const queue: AsycnTaskType[] = [];
  const executeTask = async () => {
    const task = queue[0];
    if (task) {
      task().finally(() => {
        queue.shift();
        executeTask();
      });
    }
  };
  return (task: AsycnTaskType) => {
    queue.push(task)
    if (queue.length === 1) {
      executeTask();
    }
  }
}

我们可以通过上面的工厂方法创建一个写任务队列以及一个读任务队列:

js 复制代码
export const addWriteBLECharacteristicValueAsyncSeriesTask = createAsyncSeriesTaskQueue();

export const addReadBLECharacteristicValueAsyncSeriesTask = createAsyncSeriesTaskQueue();

然后我们可以这样使用:

js 复制代码
const writeTask = () => {
  return new Promise((resolve, reject) => {
    console.log('writeTask1', Date.now());
    setTimeout(() => {
      resolve('writeTask1');
    }, 1000);
  });
};

const writeTask2 = () => {
  return new Promise((resolve, reject) => {
    console.log('writeTask2', Date.now());
    setTimeout(() => {
      resolve('writeTask2');
    }, 2000);
  });
};
const writeTask3 = () => {
  return new Promise((resolve, reject) => {
    console.log('writeTask3', Date.now());
    setTimeout(() => {
      resolve('writeTask3');
    }, 3000);
  });
};

addWriteBLECharacteristicValueAsyncSeriesTask(writeTask);
addWriteBLECharacteristicValueAsyncSeriesTask(writeTask2);
addWriteBLECharacteristicValueAsyncSeriesTask(writeTask3);

通用包头设计及实现

包头部分的设计需要充分考虑可扩展性,不同的业务包头协议的设计可能差异很大,因此我们不能写死包头只能包含reqId,blockId,以及data size,又或者限制包头中的reqId只能为7 bit,这样的设计不利于后面业务迭代的扩展,万一需要改协议,这部分逻辑的改动就比较大。毕竟涉及到发送端包头协议部分的组装,以及接收端包头协议部分的解析。因此我们需要设计一种比较通用的包头协议实现方式。我们可以实现一个工厂类用于组装或者解析包头部分的协议。我们创建一个ChunkHeaderHelper类:

js 复制代码
// 通用的chunk header解析协议
export class ChunkHeaderHelper {
  private headerDataBitParts: number[] = []; // 每部分数据的位数,比如reqId 4bit
  private headerTotalBits = 0; // header部分占的字节总数
  constructor(headerDataBitParts: number[]) {
    // headerDataParts 比如如果header包含三部分数据,reqId 4bit,blockId 10Bit,dataBufferLen: 10bit,那么headerDataParts就传[4, 10, 10]
    this.headerDataBitParts = headerDataBitParts;
    this.headerTotalBits = this.headerDataBitParts.reduce((cur, res) => res + cur, 0);
  }
  putBTHHeaders(headerData: number[]): Uint8Array | string {
    if (headerData.length !== this.headerDataBitParts.length) {
      return 'header数据不对称';
    }
    if (this.headerTotalBits % 8) {
      return 'header部分总位数不对,需要为8的整数倍';
    }

    for (let i = 0; i < this.headerDataBitParts.length; i++) {
      const bitPart = this.headerDataBitParts[i];
      const data = headerData[i];
      const maxValue = Math.pow(2, bitPart) - 1;
      if (data > Math.pow(2, bitPart)) {
        return `数据超过最大值,预期:0-${maxValue},实收到:${data}`;
      }
    }

    const bits = new Array(this.headerTotalBits).fill(false);
    let pos = 0;
    for (let i = 0; i < this.headerDataBitParts.length; i++) {
      const bitNum = this.headerDataBitParts[i];
      const data = headerData[i];
      for (let idx = bitNum - 1; idx >= 0; idx--) {
        bits[pos++] = (data >> idx) & 1;
      }
    }
    const totalBytes = this.headerTotalBits / 8;
    const bytes = new Uint8Array(totalBytes);
    for (let bytePos = 0; bytePos < totalBytes; bytePos++) {
      let val = 0;
      for (let i = 0; i < 8; i++) {
        val = (val << 1) | (bits[bytePos * 8 + i] ? 1 : 0);
      }
      bytes[bytePos] = val;
    }
    return bytes;
  }

  getBTHHeadersAndDataChunk(bytes: Uint8Array) {
    const headerData: number[] = [];
    let lastTotalBitNum = 0;
    for (let i = 0; i < this.headerDataBitParts.length; i++) {
      const bitNum = this.headerDataBitParts[i];
      headerData[i] = 0;
      const startByte = Math.floor(lastTotalBitNum / 8);
      const endByte = Math.ceil((lastTotalBitNum + bitNum) / 8);

      const startByteHandeBitNum = lastTotalBitNum % 8;
      let handleBitNum = 0;
      for (let byteIdx = startByte; byteIdx < endByte; byteIdx++) {
        const bitInt = bytes[byteIdx];
        let startIdx = 7;
        if (byteIdx === startByte) {
          startIdx = 7 - startByteHandeBitNum;
        }
        let needHanle = 0;
        if (byteIdx === endByte - 1 && byteIdx !== startByte) {
          needHanle = 8 - (bitNum - handleBitNum);
        }
        if (byteIdx === endByte - 1 && byteIdx === startByte) {
          needHanle = 8 - (bitNum + startByteHandeBitNum);
        }
        for (let bitIdx = startIdx; bitIdx >= needHanle; bitIdx--) {
          handleBitNum++;
          headerData[i] = (headerData[i] << 1) | (bitInt & (1 << bitIdx) ? 1 : 0);
        }
      }
      lastTotalBitNum = lastTotalBitNum + bitNum;
    }
    const headerTotalBytes = this.headerTotalBits / 8;
    return {
      headerData,
      dataBuffer: bytes.slice(headerTotalBytes),
    };
  }
}

可以不用理解上面的代码,只需要知道是个通用的包头数据组装以及解析工具类。可以灵活的设计包头部分的数据。比如如果你的业务中,包头包含3部分数据:reqId(7bit),blockId(12bit),data size(5 bit),那么你可以这样使用: 在发送端和接收端需要初始化headerhelper:

js 复制代码
const reqIdBitPart = 7;
const blockIdBitPart = 12;
const dataBufferLenBitPart = 5;
const businessAHeaderHelper = new ChunkHeaderHelper([reqIdBitPart, blockIdBitPart, dataBufferLenBitPart]);

在发送端,当前写入数据需要分成3个包,那么可以像下面这样为每个分包组装它的header部分的数据。

js 复制代码
const currentReqId = 0;
// 发送端,组装每个chunk
const chunk0Header = businessAHeaderHelper.putBTHHeaders([currentReqId, 0, 4]); // 分块0,后面数据部分为4字节
const chunk1Header = businessAHeaderHelper.putBTHHeaders([currentReqId, 1, 17]); // 分块1,后面数据部分为17字节
const chunk2Header = businessAHeaderHelper.putBTHHeaders([currentReqId, 2, 17]); // 分块2,后面数据部分为117字节

console.log(chunk0Header);
console.log(chunk1Header);
console.log(chunk2Header);

结果如下:

而在接收端,我们对接收到的每个chunk,可以这样使用:

js 复制代码
const chunk0Data = new Uint8Array([chunk0Header[0], chunk0Header[1], chunk0Header[2], 0, 0, 0, 65]);
const chunk1Data = new Uint8Array([chunk1Header[0],chunk1Header[1],chunk1Header[2],123,34,114,101,113,73,100,34,58,48,44,34,109,101,116,104,111,
]);
const chunk2Data = new Uint8Array([chunk2Header[0],chunk2Header[1],chunk2Header[2],100,34,58,49,44,34,114,101, 113, 34,58,123,34,115,83,83,73,
]);

console.log('chunk0Header', businessAHeaderHelper.getBTHHeadersAndDataChunk(chunk0Data).headerData);
console.log('chunk1Header', businessAHeaderHelper.getBTHHeadersAndDataChunk(chunk1Data).headerData);
console.log('chunk2Header', businessAHeaderHelper.getBTHHeadersAndDataChunk(chunk2Data).headerData);

假设后面业务迭代变了,reqId不需要7bit,同时blockId要相应扩大,那只需要简单修改下配置即可:

js 复制代码
const reqIdBitPart = 4;
const blockIdBitPart = 15;
const dataBufferLenBitPart = 5;
const businessAHeaderHelper = new ChunkHeaderHelper([reqIdBitPart, blockIdBitPart, dataBufferLenBitPart]);

又或者,包头需要新增一个字段,比如新增一个res标记位告诉接收端是否需要回包,那么只需要简单改下配置即可:

js 复制代码
const reqIdBitPart = 4;
const blockIdBitPart = 14;
const dataBufferLenBitPart = 5;
const resBitPart = 1; // 是否需要接收端回包
const businessAHeaderHelper = new ChunkHeaderHelper([reqIdBitPart, blockIdBitPart, resBitPart, dataBufferLenBitPart]);

分包写实现

我们先来基于微信的wx.writeBLECharacteristicValue方法封装一个具备失败重试能力的方法:

js 复制代码
  // 写数据
  writeBLECharacteristicValue({
    deviceId,
    serviceId,
    writeCharacteristicId,
    value,
    retry = 3,
  }): Promise<'success' | 'fail'> {
    const pro = resolvablePromise();
    const write = () => {
      wx.writeBLECharacteristicValue({
        deviceId: deviceId,
        serviceId: serviceId,
        characteristicId: writeCharacteristicId,
        value: value, // ArrayBuffer
        success: (res) => {
          pro.resolve('success');
        },
        fail: (err) => {
          if (retry > 0) {
            // 写失败,则重试
            write();
          } else {
            pro.resolve('fail');
          }
        },
      });
    };
    write();
    return pro;
  }

可以看到这个方法很简单,就是直接调微信的接口,只不过我们加了重试机制,失败了就会递归调用,直到调用成功或者重试次数用完了。

然后我们封装一个分包写的方法

js 复制代码
  //  分包写数据
  writeBLECharacteristicValuePacket({ writeCharacteristicId, value, deviceId, serviceId }: any): Promise<any> {
    const writePromise = resolvablePromise();

    const writeTask = async () => {
      const currentReqId = this.reqId;
      const requstId = this.generateRandomIntDigits(6);
      const data = {
        reqId: requstId,
        ...value,
      };
      this.reqId = (currentReqId + 1) % this.maxReqId;

      const promiseKey = requstId;
      const dataStr = JSON.stringify(data);
      const dataBuffer = stringToArrayBuffer(dataStr);
      const totalLength = dataBuffer.byteLength; // 单位:字节
      const chunkSize = this.chunkSize - this.headerTotalByteSize; // 每个数据包的buffer部分长度,单位:字节
      const packetCount = Math.ceil(totalLength / chunkSize); // 计算需要的包数量
      const totalLengthBuffer = numberToUint8Array(totalLength);
      const headerDataview = this.putBlueToothChunkHeaderHelper({
        reqId: currentReqId,
        blockId: 0,
        dataSize: 4,
      }) as Uint8Array;
      const firstchunk = new Uint8Array(this.headerTotalByteSize + 4); // 4表示4个字节,第一个chunk用来表示传输的数据的总大小,最大Math.pow(2, 32)
      firstchunk.set(headerDataview);
      firstchunk.set(totalLengthBuffer, headerDataview.length);
      // 第一个包,告诉客户端包的data的总大小
      await this.writeBLECharacteristicValue({
        deviceId: deviceId,
        serviceId: serviceId,
        writeCharacteristicId: writeCharacteristicId,
        value: firstchunk.buffer,
      });
      for (let blockId = 1; blockId < packetCount + 1; blockId++) {
        const bufferSize = Math.min(chunkSize, totalLength - (blockId - 1) * chunkSize);
        // 3字节是包头的协议
        console.log(`block${blockId}, ${currentReqId},bufferSize:${bufferSize},packetCount:${packetCount}`);
        const headerDataview = this.putBlueToothChunkHeaderHelper({
          reqId: currentReqId,
          blockId: blockId,
          dataSize: bufferSize,
        }) as Uint8Array;
        const currentPacket = new Uint8Array(bufferSize + headerDataview.byteLength);
        // 填充数据 buffer
        currentPacket.set(headerDataview);
        const dataChunk = dataBuffer.slice((blockId - 1) * chunkSize, (blockId - 1) * chunkSize + bufferSize);
        currentPacket.set(dataChunk, headerDataview.length);
        await this.writeBLECharacteristicValue({
          deviceId: deviceId,
          serviceId: serviceId,
          writeCharacteristicId: writeCharacteristicId,
          value: currentPacket.buffer,
        });
      }
      console.log('分包写数据结束:', requstId, currentReqId, dataBuffer.byteLength, dataStr);

      this.writePromiseMap[promiseKey] = writePromise;
    };

    // 加入异步串行执行队列
    addWriteBLECharacteristicValueAsyncSeriesTask(writeTask);

    return writePromise;
  }

这个方法核心就是分包,然后加入异步串行任务执行,主要逻辑是这样。

js 复制代码
  writeBLECharacteristicValuePacket({ writeCharacteristicId, value, deviceId, serviceId }: any): Promise<any> {
    const writePromise = resolvablePromise();

    const writeTask = async () => {
      const requstId = this.generateRandomIntDigits(6);
      //...
      const promiseKey = requstId;
      //...
      for (let blockId = 1; blockId < packetCount + 1; blockId++) {
        //...
        await this.writeBLECharacteristicValue();
        // ...
      }

      this.writePromiseMap[promiseKey] = writePromise;
      // 注意!!!下面return writePromise和不return writePromise差别很大,意义不一样
      // return writePromise
    };

    // 加入异步串行执行队列
    addWriteBLECharacteristicValueAsyncSeriesTask(writeTask);

    return writePromise;
  }

然后调用方直接这么调用即可,即使多次调用写也无所谓,内部会控制异步串行执行。

js 复制代码
blueTooth.writeBLECharacteristicValuePacket({
    writeCharacteristicId: '124',,
    deviceId: '111',
    serviceId: '222',
    value: {
        productId: 12,
        name: 'test'
      }
    }
  }).then(res1 => console.log('res1', res1))
  
  blueTooth.writeBLECharacteristicValuePacket({
    writeCharacteristicId: '124',,
    deviceId: '111',
    serviceId: '222',
    value: {
        productId: 44,
        name: 'test44'
      }
    }
  }).then(res2 => console.log('res2', res2))
  
  blueTooth.writeBLECharacteristicValuePacket({
    writeCharacteristicId: '124',,
    deviceId: '111',
    serviceId: '222',
    value: {
        productId: 55,
        name: 'test55'
      }
    }
  }).then(res3 => console.log('res3', res3))

需要注意的是,writeTask方法最后return writePromise和不return writePromise意义大不相同:

  • 如果return writePromise,那就意味着当前写数据的请求,需要等待接收端返回,才会执行下一个写数据的请求。
  • 如果不return writePromise,那就意味着写完当前数据,不需要等接收端返回,就可以继续执行下一个写数据的请求。

这两者的使用场景大不相同。

分包读实现

我们需要监听接收端的返回值,接收端也是分包返回数据,因此我们需要基于分包读数据。首先我们需要基于wx.onBLECharacteristicValueChange监听接收端的信息。

js 复制代码
  private onBLECharacteristicValueChange() {
    // 操作之前先监听,保证第一时间获取数据
    wx.onBLECharacteristicValueChange((characteristic) => {
      // console.log('BlueTooth:onBLECharacteristicValueChange接收到消息:', characteristic)
      this.readBLECharacteristicValuePacket(new Uint8Array(characteristic.value));
    });
  }
js 复制代码
  readBLECharacteristicValuePacket(chunk: Uint8Array) {
    // 对接收到的分包进行解析处理,获取包头协议和数据
    const { reqId, blockId, dataSize, dataBuffer } = this.getBlueToothChunkHeaderHelper(chunk);
    const resultMap = this.resultMap[reqId] || {
      totalBytes: 0,
      dataBuffers: [],
      currentReceiveBytesNum: 0,
    };
    if (blockId === 0) {
      // 第一个包,获取数据的总大小
      resultMap.totalBytes = uint8ArrayToDecimalBigEndian(dataBuffer);
      console.log(`开始分包接收数据,包大小:${resultMap.totalBytes}`);
    } else {
      resultMap.dataBuffers[blockId - 1] = dataBuffer;
      resultMap.currentReceiveBytesNum = resultMap.currentReceiveBytesNum + dataSize;
    }
    console.log(`接收分包接收数据:`, reqId, blockId, dataSize, dataBuffer.byteLength);

    this.resultMap[reqId] = resultMap;

    // 判断是否接受完成
    if (resultMap.totalBytes === resultMap.currentReceiveBytesNum) {
      // 接收完成
      const res = extraResult(resultMap.dataBuffers);
      if (!res) {
        console.error('提取结果失败..');
        return;
      }
      console.log('接收完成,,收到的响应:', res);
      this.writePromiseMap[res.reqId]?.resolve(res.rsp);
      this.resultMap[reqId] = null;
    }
  }

总结

目前已经实现了异步串行读写的能力,但还差以下能力没实现:

  • 丢包重传
  • 超时重传

以上能力需要接收端配合实现对每个分包的ack确认机制

相关推荐
jingling55528 分钟前
【Vue3 实战】插槽封装与懒加载
前端·javascript·vue.js
Freedom风间5 小时前
前端优秀编码技巧
前端·javascript·代码规范
萌萌哒草头将军6 小时前
🚀🚀🚀 Openapi:全栈开发神器,0代码写后端!
前端·javascript·next.js
萌萌哒草头将军6 小时前
🚀🚀🚀 Prisma 爱之初体验:一款非常棒的 ORM 工具库
前端·javascript·orm
拉不动的猪6 小时前
SDK与API简单对比
前端·javascript·面试
山海上的风7 小时前
Vue里面elementUi-aside 和el-main不垂直排列
前端·vue.js·elementui
电商api接口开发7 小时前
ASP.NET MVC 入门指南二
前端·c#·html·mvc
亭台烟雨中7 小时前
【前端记事】关于electron的入门使用
前端·javascript·electron