UniApp BLE蓝牙开发实战:ArrayBuffer与16进制转换全攻略
别再为蓝牙数据格式发愁了UniApp连接BLE设备手把手教你搞定ArrayBuffer与16进制转换搞蓝牙硬件开发的朋友应该都有体会设备端工程师给你一份 PDF 协议文档上面密密麻麻写着“设备回复AA 55 AA 55 01 02 00 03 0D 0A”然后让你在小程序或者 App 里把这些字节解析成温度、电量、开关状态……第一次接触 BLE 数据的人十有八九会卡在“ArrayBuffer 到底是个什么玩意”这一步。我最早做 UniApp 对接 BLE 心率设备的时候也是被这套数据格式折磨得够呛一端是设备发来的原始字节流一端是业务逻辑需要的十进制数值中间那层转换代码怎么写才稳妥为什么别人写的ab2hex能跑我复制过来就乱码为什么安卓上报的数据类型和 iOS 不一样这些坑我踩了一遍之后才逐步整理出一套可复用的处理流程。这篇文章就把我实际项目中沉淀下来的 BLE 数据收发思路、ArrayBuffer 和 16 进制字符串互转的完整代码、以及 UniApp 蓝牙 API 的调用时序都摊开讲清楚。无论你是刚接手蓝牙项目的新手还是被“分包粘包”折磨的老手这篇都能当一份速查手册来用。1. 整体设计先搞清楚 BLE 的数据通路和格式1.1 从设备到手机一条完整的数据链路是怎么走的BLEBluetooth Low Energy低功耗蓝牙和经典蓝牙最大的不同在于它不是为了传大文件设计的而是为了“低功耗、小数据量、低延迟”的物联网场景。一个典型的心率计、温湿度传感器、智能锁、体脂秤本质上都是通过 BLE 的 GATTGeneric Attribute Profile通用属性协议对外暴露若干“服务Service”和“特征值Characteristic”。当你在 UniApp 里写这样一串调用时背后其实有一整套动作uni.openBluetoothAdapter() uni.startBluetoothDevicesDiscovery() uni.createBLEConnection() uni.getBLEDeviceServices() uni.getBLEDeviceCharacteristics()整个过程可以理解成手机先打开蓝牙“大门”openBluetoothAdapter然后“竖起耳朵听谁在广播”startBluetoothDevicesDiscovery找到目标设备后用 MAC 地址或 deviceId 建立连接createBLEConnection。连接成功不等于能直接收发数据你还要问设备“你有哪几扇门可以让我进去”getBLEDeviceServices每一扇门Service里面又有几把锁Characteristic你需要找到那把可以“读”或者“写”的锁才能传输数据。这里有一个新手最容易搞混的概念Service 和 Characteristic 的 UUID 不是随便写的。规范蓝牙 SIG 定义了很多标准服务比如心率服务是0x180D、电池服务是0x180F、设备信息服务是0x180A。但很多厂商硬件会使用自定义的 128 位 UUID比如0000ffe1-0000-1000-8000-00805f9b34fb看起来特别像乱码实际上它就是设备端工程师自定义的串口透传服务。遇到这种设备别慌先找协议文档确认读写特征值再在代码里做映射即可。1.2 为什么 BLE 传数据偏偏要用 ArrayBuffer市面上很多文章会告诉你“BLE 返回的数据是 ArrayBuffer”但没说为什么。我个人的理解是BLE 底层物理层传输的是字节流字节是最小的操作单位。而 JS 语言里最贴近“原始字节”的表示方式恰好就是 ArrayBuffer。ArrayBuffer 本质上是一段固定长度的连续内存空间它本身不能直接读写你只能通过 DataView 或者 TypedArrayUint8Array、Int16Array 等来读写这块内存。也就是说ArrayBuffer是“仓库”Uint8Array是“按字节读取仓库的手”DataView是“按任意类型读取仓库的手”很多从后端转过来的同学喜欢用字符串来传递数据比如把数据拼成AA550104发给设备。倒不是说一定不行但在 BLE 场景下标准 API比如 UniApp 的writeBLECharacteristicValue从底层就把值限定成了 ArrayBuffer你传普通字符串往往会直接报参数类型错误。就算有的平台帮你内部转了一下也会在遇到二进制不可见字符比如 0x00、0xFF时出问题字符串被截断或者被编码器污染收到的数据就完全错乱了。所以在 BLE 开发里统一以字节为思考单位以 ArrayBuffer 为交换格式是减少返工的最重要原则。2. 核心细节ArrayBuffer 与 16 进制字符串互转的完整实现2.1 字节序和数值范围从 DataView 到 Uint8Array选哪个在动手写转换函数前有一个必须理解的基础概念——字节序Endianness。设备端单片机比如 STM32在内存里存多字节整数时有两种排法小端序Little-Endian把低字节放在前面大端序Big-Endian把高字节放在前面。常见的 BLE 协议默认使用小端序比如一个 16 位的数值0x1234在小端序下传输的数据是0x34 0x12。你在用DataView.getUint16(offset, true)的时候第二个参数写true表示小端写false或者不写则默认是大端。举个例子假设设备电量百分比是96十进制也就是0x60。它用一个字节传输你用DataView.getUint8(0)拿到的就是96没有字节序问题。但如果设备给你传一个两字节的温度值比如35.6℃有的协议会把数值放大 10 倍变成356也就是0x0164。在小端序模式下设备发送的是0x64 0x01你必须用getUint16(offset, true)才能还原成356。我做项目时一般这样选型场景推荐方案原因只需逐字节读取Uint8Array代码简单逻辑直观需要读取 16/32 位整数、浮点数DataView支持指定字节序可读性最强需要写入数据构造命令帧DataView 或 Uint8Array按协议一名一名填充清晰不易错对于大多数 BLE 透传数据自定义协议帧我更喜欢用 DataView 阅读用Uint8Array构造帧。两个混用也不冲突数据底层都是同一个 ArrayBuffer只是视角不同。2.2 16 进制字符串转 ArrayBuffer给设备下发指令前的第一道工序设备协议里最常见的指令格式是AA 55 01 02 0D 0A这样的 16 进制字符串。我们要把它转成 ArrayBuffer 再写入 BLE 特征值。这一步若写错轻则设备无响应重则误触发别的功能所以实现不能出错。我封装好的“字符串转 ArrayBuffer”代码如下function hexStringToArrayBuffer(hexStr) { // 去掉所有空格和 0x 前缀统一成干净的纯 16 进制字符串 let cleanHex hexStr.replace(/\s/g, ).replace(/0x/g, ).toUpperCase(); // 如果长度是奇数补个前缀 0防止 parse 乱掉 if (cleanHex.length % 2 ! 0) { cleanHex 0 cleanHex; } const buffer new ArrayBuffer(cleanHex.length / 2); const dataView new DataView(buffer); for (let i 0; i cleanHex.length; i 2) { dataView.setUint8(i / 2, parseInt(cleanHex.substr(i, 2), 16)); } return buffer; }有几个细节我想特别说明为什么先去掉空格和0x因为协议文档里写过AA 55 01也会有人写成0xAA 0x55 0x01如果你不统一清洗parseInt( , 16)会解析出 NaN导致数据错乱。为什么要补前缀 0比如协议里写的是A 55 01这种奇数长度实属少见但一旦遇到不补 0 会让整个数组错位。为什么用 DataView 而不用 Uint8ArrayUint8Array的set方法也没问题但要写new Uint8Array(buffer)[i] ...写法上多一层。DataView 的setUint8在语义上更明确代码更统一。2.3 ArrayBuffer 转 16 进制字符串调试输出和协议解析的必备函数设备回复的数据调试时最直观的就是打印成形如AA 55 01 02的 16 进制字符串。这个转换函数几乎是每个 BLE 项目的地基没有它你连日志都看不了。function arrayBufferToHexString(buffer) { const bytes new Uint8Array(buffer); let hexStr ; for (let i 0; i bytes.length; i) { let hex bytes[i].toString(16); // 确保每个字节都补齐两位比如 0x0A 才显示成 0A而不是 A hex hex.length 1 ? 0 hex : hex; hexStr hex.toUpperCase(); if (i bytes.length - 1) { hexStr ; } } return hexStr; }这个函数我调试时几乎天天用。比如onBLECharacteristicValueChange里拿到ArrayBuffer之后我会立刻调用它打印uni.onBLECharacteristicValueChange((res) { const hexStr arrayBufferToHexString(res.value); console.log([BLE] 收到数据: ${hexStr}); });这样不管是自己写协议解析还是发报文给硬件同事排查问题都方便很多。2.4 完整案例解析从一帧温湿度数据里抠出温度和湿度以下是我实际做过的温湿度计项目里的一帧数据协议格式是字节位置内容说明00xAA帧头10x55帧头校验20x01数据长度后面的有效数据字节数30x02命令字表示温度湿度上报40x34温度整数部分十六进制 0x34 5250xCE温度小数部分编码60x1B湿度整数部分 2770x0D校验位80x0A帧尾真实场景中硬件同事告诉我“数据里是 16 进制温度是反码补码湿度除以 2 就是真实值”。我在回调里写的解析逻辑大致是function parseTemHum(buffer) { const dataView new DataView(buffer); if (dataView.byteLength 9) return null; if (dataView.getUint8(0) ! 0xAA || dataView.getUint8(1) ! 0x55) { console.warn([BLE] 帧头校验失败); return null; } const tempInt dataView.getUint8(4); const tempDec dataView.getUint8(5); // 表示 0.1 度的个数 const humVal dataView.getUint8(6); const temperature (tempInt tempDec / 100).toFixed(2); // 按实际协议计算 const humidity (humVal / 2).toFixed(1); return { temperature: parseFloat(temperature), humidity: parseFloat(humidity), }; }在实际操作里建议把“帧头校验”“长度判断”“CRC 校验”三层判断都写全别觉得浪费时间。我曾经偷懒省略了长度判断结果有一次设备异常多发了一字节导致整帧解析错位UI 上温度直接显示成 -40 度排查了很久才发现是越界读取的问题。3. 实操过程UniApp 连接 BLE 设备的完整流程3.1 初始化蓝牙适配器和扫描设备的正确时机在 UniApp 里蓝牙 API 大多集中在uni对象上。第一步是初始化蓝牙适配器function initBLE() { return new Promise((resolve, reject) { uni.openBluetoothAdapter({ success: (res) { console.log([BLE] 适配器初始化成功, res); resolve(res); }, fail: (err) { console.error([BLE] 适配器初始化失败, err.errCode, err.errMsg); reject(err); }, }); }); }这里有个经验在用户点击“连接设备”按钮之前先确认手机蓝牙是否已打开、是否需要请求系统权限。在安卓上如果没有定位权限或蓝牙权限openBluetoothAdapter会直接失败在 iOS 上缺少NSBluetoothAlwaysUsageDescription配置会导致点击无反应。所以上线前一定要检查 App 打包时的权限配置。扫描设备时我会加上allowDuplicatesKey: false避免同一个设备重复上报多次导致列表闪烁uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: () { uni.onBluetoothDeviceFound((res) { const devices res.devices; devices.forEach((device) { if (device.name device.name.includes(TempStick)) { // 收集目标设备 } }); }); }, });很多设备的广播信号不稳定第一次扫描可能 1 秒内没出现所以建议扫描事件保持 10 秒以上或者做成下拉刷新重新扫描。另外device.name在不同系统上会有差异有时候安卓上报的localName才是设备名device.name可能为空这个细节要留意。3.2 建立连接并获取 Service/Characteristic 的完整时序扫描到设备后下一步是建立连接。UniApp 的createBLEConnection需要传入设备的deviceIdfunction connectDevice(deviceId) { return new Promise((resolve, reject) { uni.createBLEConnection({ deviceId, timeout: 10000, success: () resolve(deviceId), fail: (err) reject(err), }); }); }连接成功之后直接去获取服务列表function getServices(deviceId) { return new Promise((resolve, reject) { uni.getBLEDeviceServices({ deviceId, success: (res) { console.log([BLE] 服务列表, res.services); resolve(res.services); }, fail: reject, }); }); }拿到服务列表后我们需要在其中找到目标 Service 的 UUID再调用getBLEDeviceCharacteristics获取这个 Service 下所有特征值的属性function getCharacteristics(deviceId, serviceId) { return new Promise((resolve, reject) { uni.getBLEDeviceCharacteristics({ deviceId, serviceId, success: (res) { console.log([BLE] 特征值列表, res.characteristics); resolve(res.characteristics); }, fail: reject, }); }); }在res.characteristics列表里每一项都带有uuid、properties包含read、write、notify等布尔值。务必判断好特征值是否支持 notify再决定是否调用notifyBLECharacteristicValueChange。有一次我忘了判断直接对只读特征值开启 notify导致 iOS 直接报 10005 找不到指定特征值。完整连接时序用伪代码串起来是这样的async function connectAndDiscover(deviceId) { await connectDevice(deviceId); const services await getServices(deviceId); const targetService services.find((s) s.uuid.toUpperCase() SERVICE_UUID); if (!targetService) throw new Error(未找到目标服务); const characteristics await getCharacteristics(deviceId, targetService.uuid); const writeChar characteristics.find((c) c.properties.write c.uuid.toUpperCase() WRITE_UUID); const notifyChar characteristics.find((c) c.properties.notify c.uuid.toUpperCase() NOTIFY_UUID); return { deviceId, serviceId: targetService.uuid, writeChar, notifyChar }; }3.3 写入数据、开启 notify 订阅和解析返回值写入数据时必须把字符串指令转成 ArrayBuffer再调用写入接口。很多同学会忘记这一步直接用字符串文档里又没说清楚结果一直报“参数错误”。正确的写法是function writeCommand(deviceId, serviceId, charId, hexStr) { const buffer hexStringToArrayBuffer(hexStr); return new Promise((resolve, reject) { uni.writeBLECharacteristicValue({ deviceId, serviceId, characteristicId: charId, value: buffer, success: resolve, fail: (err) { console.error([BLE] 写入失败, err.errCode, err.errMsg); reject(err); }, }); }); }开启 notify 订阅的代码如下function startNotify(deviceId, serviceId, charId) { return new Promise((resolve, reject) { uni.notifyBLECharacteristicValueChange({ deviceId, serviceId, characteristicId: charId, state: true, success: resolve, fail: reject, }); }); }这里有个非常关键的细节监听回调uni.onBLECharacteristicValueChange需要在调用notifyBLECharacteristicValueChange之前就注册好否则设备数据一旦上报你还没来得及监听就丢了。我遇到过不止一次此类问题设备明明返回了数据控制台却啥都没有检查半天才发现是事件监听注册太晚。4. 常见问题与排查技巧实录4.1 UniApp 蓝牙错误码速查表下面这份错误码速查表是我在历次项目里反复踩坑后整理出来的基本涵盖了常见问题错误码含义常见场景处理建议10001蓝牙未打开或不支持手机蓝牙关闭、模拟器引导用户打开蓝牙做好开关联动10002没有找到设备扫描列表为空、设备不在广播确认设备是否进入配对模式、是否超出信号范围10003连接失败信号差、设备已被其他手机连接重试一次或者重置设备后再试10004没有找到指定服务Service UUID 写错用 getBLEDeviceServices 打印真实 UUID 对比10005没有找到指定特征值Characteristic UUID 写错用 getBLEDeviceCharacteristics 打印对比10006当前连接断开设备断电、信号干扰、系统回收监听 onBLEConnectionStateChange 做自动重连10007当前特征值不支持该操作对只读特征值调用写入、对非 notify 特征值订阅判断 properties 后调用10008系统内部异常并发调用或内存问题重启蓝牙适配器或提示用户重启蓝牙10009安卓版本过低安卓 4.3 以下提示用户更换设备或升级系统提示这组错误码在不同 UniApp 版本里可能有微妙差异建议线上打印完整err对象而不是只信任errCode。有时候errMsg里附带的信息比错误码还有用。4.2 Android 和 iOS 的行为差异与兼容处理同样的代码在安卓和 iOS 上表现可能完全不同这是 BLE 开发躲不开的痛。小端字节序差异Android 从小端设备取回数据时如果你直接按顺序转 16 进制会看到数据“倒过来”。这个问题本质上是设备端字节序决定的不是手机端的问题但排查时容易误判成自己代码写错。写入 20 字节限制老版本 Android 系统 BLE 底层默认 MTU最大传输单元是 23 字节扣除协议头 3 字节单包最多能写 20 字节。iOS 会尝试自动协商 MTU但如果硬件端不支持也是白搭。所以最稳妥的做法是把长数据拆成 20 字节一包逐包发送。回调频率差异同一设备在安卓上回调速度可能比 iOS 更快且回调里的res.value在 Android 上可能是ArrayBuffer的兼容对象需要统一用ArrayBuffer.isView判断一下必要时做一次类型转换。扫描过滤差异iOS 对蓝牙广播数据里的 1 字节 manufacturer data 解析更严格而安卓经常上报空 name。所以在筛选设备时不要单纯依赖name可以把localName和serviceId一起判断。我自己写了一套“跨端安全读取”工具函数核心逻辑是function normalizeArrayBuffer(value) { // 某些平台返回 Uint8Array某些返回 ArrayBuffer统一归一 if (ArrayBuffer.isView(value)) { return value.buffer.slice(value.byteOffset, value.byteOffset value.byteLength); } return value; }实测下来加上这个兜底逻辑以后跨端兼容问题少了一大半。4.3 分包发送、粘包处理和数据缓冲区设计蓝牙协议包里经常会出现一大串连续数据比如从体脂秤读取的历史测量记录可能有几百字节远超一包 20 字节的限制。设备端会分批发送而手机端在onBLECharacteristicValueChange里会收到多次回调。如何在接收端把这些碎片拼接回完整指令是我踩过最深的一个坑。我的做法是维护一个“接收缓冲区”let receiveBuffer new Uint8Array(0); function handleNotify(res) { const rawBuffer normalizeArrayBuffer(res.value); const currentBytes new Uint8Array(rawBuffer); // 拼接到总缓冲区 const newBuffer new Uint8Array(receiveBuffer.length currentBytes.length); newBuffer.set(receiveBuffer, 0); newBuffer.set(currentBytes, receiveBuffer.length); receiveBuffer newBuffer; // 尝试从缓冲区里截取完整帧根据帧头帧尾 const frame extractFrame(receiveBuffer); if (frame) { parseFrame(frame); // 清掉已处理的字节 receiveBuffer receiveBuffer.slice(frame.length); } }extractFrame的实现依赖于具体协议但逻辑模式比较固定找帧头如0xAA 0x55根据协议里的长度字段计算整帧长度如果缓冲区的长度小于整帧长度继续等下一包如果长度满足则截取这一帧并返回同时把缓冲区起点后移这种“缓冲区 轮询截帧”的做法在串口、BLE、WebSocket 二进制传输里都通用。我建议大家在项目一启动就把这层封装做好哪怕初期只处理单包数据也不要省因为设备固件升级、批量同步历史数据这些需求迟早会找上门。5. 更多工程化建议从“能用”到“好用”5.1 封装一个 BLE 管理器类避免全局变量满天飞如果项目只在一两个页面用蓝牙那直接写全局方法问题不大。但一旦涉及多页面共享设备连接状态、数据解析结果全局变量满天飞会让维护成本直线上升。我的做法是封装一个BleManager单例类内部维护_deviceId、_serviceId、_writeCharId、_notifyCharId、_connected等状态并提供init()scan()connect(deviceId)disconnect()send(hexStr)setOnDataListener(callback)这样页面里只需要调用BleManager.send(AA55...)然后监听onData回调更新 UI不需要关心底层 state 怎么流转。5.2 合理处理页面生命周期和蓝牙资源释放在onUnload或onHide里释放蓝牙资源是一个老生常谈但又容易忘记的动作。尤其是在 App 里页面随便滑动返回如果不主动关闭连接蓝牙会被系统挂住下次再打开页面时初始化就可能失败。建议在页面卸载时执行uni.closeBLEConnection({ deviceId: this.deviceId }); uni.closeBluetoothAdapter();但要注意如果多个页面共用同一个连接别在第一个页面退出时就把适配器关了。这需要结合业务实际来权衡——如果整个 App 只有一个蓝牙操作中心那就在全局入口初始化、全局出口释放否则就按页面维度精细管理。5.3 开启蓝牙状态监听实现断线自动重连很多智能硬件项目都有“掉线后自动重连”的需求。在 UniApp 里可以通过监听onBLEConnectionStateChange来实现uni.onBLEConnectionStateChange((res) { if (!res.connected) { console.warn([BLE] 连接已断开); // 根据业务需求决定是否自动重连 if (needReconnect) { autoReconnect(); } } });这里要防止进入无限重连的死循环。我一般会在重连逻辑里加一个计数器和时间间隔最多重连 3 次每次间隔 2 秒如果还连不上就提示用户“设备连接异常请检查设备是否处于工作状态”。避免在用户已经手动取消时还疯狂重连浪费电也影响体验。5.4 才开始接触时最容易犯的几个认知错误最后说几个项目新人在学习和实践中容易存在的心智误区。以为 ArrayBuffer 和 16 进制字符串完全等价。它们能互转但中间存在编码和数据表示差异。字符串里存的是人类可读的“十六进制字形”ArrayBuffer 里存的是原始字节值。如果只靠字符串拼接而丢失了字节对齐数据就毁了。以为拿到了 ArrayBuffer 第一件事就是转字符串。其实更高效的做法是在DataView层面直接按协议字段读取只有调试和日志才转字符串。遇到大量高频率数据时频繁转字符串会影响性能。以为 BLE 连接和普通 HTTP 请求一样可以随时发。BLE 是半双工、小包、慢速通道微信小程序里一次writeBLECharacteristicValue之后通常要等设备回包不要连续密集发送。否则极容易出现写入失败甚至把设备端协议栈搞崩溃设备直接重启。我从踩坑中得出的最终经验是做 BLE 开发尤其是 UniApp 这种跨端平台最重要的不是背 API而是建立一种“我发出去的是一个一个字节收回来的也是一个一个字节”的直觉。只要把字节流这个概念吃透了ArrayBuffer、DataView、16 进制互转、分包粘包都是表象问题什么平台都不会再难住你。如果再让我重新做一遍最初的心率项目我会在第一天就把那套hexStringToArrayBuffer和arrayBufferToHexString封装好然后把连接状态机写好再去做页面 UI。这比先画界面再补逻辑要省太多时间。希望这篇经验分享能让你在 BLE 这条路上少走点弯路早点把跟硬件“对话”的乐趣找回来。