☰
HBuilderX HTML5+ BLE蓝牙通信Demo真机实战
2026/10/27 15:10:43 网站建设 项目流程

简介:围绕HBuilderX与HTML5 Web Bluetooth打造的蓝牙通讯示例包,面向前端开发者、物联网应用初学者及需要联调BLE设备的软硬件人员,重点解决H5页面连接蓝牙、监控连接状态并完成数据收发的问题。压缩包共258个文件、约11.45MB,包含Java、class、xml、js、png、apk等类型,既有Android原生工程代码与演示安装包,也有H5示例页面、界面截图和MLT-BT05模块资料,便于对照Web端与原生端实现。已有5167人学习,说明其在蓝牙通信入门与方案验证中具有一定参考价值。读者可从中了解navigator.bluetooth.requestDevice、GATT服务读写、连接异常处理等关键逻辑,并借助Android BluetoothAdapter、BluetoothGatt源码理解底层备选方案;配套模块资料和串口调试工具还能帮助排查硬件通信问题,适合快速搭建跨平台蓝牙应用原型或课程实验。

1. 蓝牙通讯这块硬骨头,这个 demo 把链路直接通到了能发数据

前年接一个手持终端的活,厂商给过来的资料只有一张 UUID 表和一句「按 BLE 标准来」,App 端一行代码都没有。真正把链路跑通的,是一份 HBuilderX 打包的 HTML5 蓝牙 demo——打开适配器、扫到设备、连上、翻出服务和特征值、订阅通知、写十六进制指令,整条链路在一台安卓真机上从头走到尾,没有一步是空的。这份 BlueTooth 蓝牙传输源码的价值不在界面多好看,而在于它用 HTML5+ 规范里的 plus.bluetooth 把 BLE 那一串回调串成了能照着改的流程。适合的人挺明确:做物联网设备对接的前端、手里有 H5 页面但需要让它真的能连硬件的开发者,以及想要一份能跑通的 H5+Bluetooth 参考实现的人。先把丑话说前头,它在浏览器里一定跑不起来,原因后面单独讲。

2. 工程骨架与真机运行:manifest 里的模块配置决定它能不能起来

拿到一份带硬件交互的 HBuilderX 工程,第一件事不是打开 js 文件读逻辑,而是先看 manifest.json。这个文件决定了 App 打包出来有没有蓝牙模块、有没有扫描权限,逻辑写得再对,模块没勾上一样是白屏加静默失败。

2.1 目录结构:几个文件撑起一条完整链路

这类 demo 的工程通常长这样,结构很轻,重点全在 js 目录里:

html5-bluetooth-demo/ ├── manifest.json # App 配置:模块、权限、启动页、包名 ├── index.html # 入口页,承载设备列表和日志面板 ├── css/ │ └── style.css # 列表、按钮、日志区的样式 ├── js/ │ ├── bluetooth.js # BLE 主逻辑:扫描、连接、订阅、收发 │ ├── convert.js # ArrayBuffer 与 hex/字符串互转 │ └── main.js # 页面事件绑定、列表渲染 └── unpackage/ # 打包产物目录,代码不用管

如果是 uni-app 版本,index.html会换成pages/index/index.vue加pages.json,但bluetooth.js这一层基本原样保留,因为它只依赖plus.bluetooth,跟视图框架无关。这个分层其实是这份源码最值得学的地方:通讯逻辑和 UI 完全解耦,换成任何页面都能直接搬。

我一般在动手改之前,会先把bluetooth.js从头到尾读一遍,把里面所有硬编码的 UUID 抄到一张纸上。设备厂商给的 UUID 表通常和 demo 里的对不上,这一步不做,后面连上了也收不到数据。

2.2 manifest.json:模块、权限、targetSdkVersion 三处最容易被忽略

蓝牙相关配置集中在这一个文件里,缺一项就是真机上的一次失败。下面是我按这份 demo 的配置思路整理出来的关键片段:

{ "name": "html5-bluetooth-demo", "appid": "__UNI__DEMOXXXX", "versionName": "1.0.0", "app-plus": { "modules": { "Bluetooth": {} }, "distribute": { "android": { "minSdkVersion": 21, "permissions": [ "<uses-permission android:name=\"android.permission.BLUETOOTH\"/>", "<uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\"/>", "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>", "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>", "<uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\"/>", "<uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\"/>" ] }, "ios": { "privacyDescription": { "NSBluetoothPeripheralUsageDescription": "需要蓝牙与设备通讯", "NSBluetoothAlwaysUsageDescription": "需要蓝牙与设备通讯" } } } } }

app-plus.modules里那个Bluetooth开关是总闸,HBuilderX 界面上对应「App 模块配置 → Bluetooth」那个勾。没勾上的包,运行时plus.bluetooth这个对象根本不存在,控制台报的是Cannot read property 'openBluetoothAdapter' of undefined,很多人会误以为是代码问题,其实是模块没打进去。

permissions数组里那几条要分开看。前两条是老版本安卓就有的基础蓝牙权限;ACCESS_FINE_LOCATION是从安卓 6.0 开始扫描 BLE 设备的硬性要求,系统认为扫描周边信标可以推断位置;BLUETOOTH_SCAN和BLUETOOTH_CONNECT是安卓 12 之后新增的运行时权限,旧写法在新系统上会直接扫不到东西。iOS 那边则是两个隐私描述字段,缺了会在连接时被系统静默拒绝,日志里什么都不给你留,属于最容易卡住的一类问题。

minSdkVersion: 21对应安卓 5.0,是 BLE 中央设备模式比较稳妥的下限。低于这个值的机器只能做经典蓝牙,而plus.bluetooth这一套 API 走的是低功耗蓝牙协议栈,两码事。

2.3 三种运行方式:标准基座、自定义基座、云打包,选错了就是白忙

HBuilderX 里跑这个 demo 有三种路子,很多人卡在第一步就是因为选错了运行方式。

标准基座是最快的验证手段。手机开着 USB 调试连上电脑,菜单里选「运行 → 运行到手机或模拟器 → 运行到 Android App 基座」,等它装完一个通用壳子,页面就跑起来了。标准基座里预置了蓝牙模块,所以plus.bluetooth能直接用。缺点是这个壳子的权限清单是固定的,你改的permissions不会生效,适合验证「API 调用顺序对不对」这一层。

自定义基座是必须跨过的一道坎。菜单「发行 → 制作自定义调试基座」,它会把当前 manifest 里的模块和权限真的打进包里。安卓 12 以上的机型如果只跑标准基座,BLUETOOTH_SCAN这类运行时权限根本没声明,扫描永远返回空列表,会让人怀疑人生。改过 manifest 之后一定要重新做基座,然后用「运行 → 运行到手机 → 运行到自定义基座」跑。

云打包就是出正式包,走「发行 → 云打包」,主要给测试和交付用。这一步跟调试的区别在于签名变了,如果设备端做了白名单绑定,换签名后要重新配。

调试期间看日志别只盯着 HBuilderX 的控制台,它在真机上经常吞输出。接上 adb 直接看更实在:

# 只过滤 console 和运行时相关标签,避免被系统日志刷屏 adb logcat -s console:V DCloud:V

-s是 silent 模式加白名单,后面每项是tag:level,V表示 verbose 全级别。用这个能看到console.log打出来的原始行,包括plus.bluetooth各回调里errMsg和errCode的完整内容——排查蓝牙问题,这两个字段比任何猜测都值钱。

3. 通信主链路:从 openBluetoothAdapter 到 writeBLECharacteristicValue 的六个调用

BLE 这套协议栈的调用顺序是强约束的,顺序错了不会有明确报错,只会得到一堆空回调。这份 demo 把六个调用按顺序摆得比较规整,我按阶段拆开讲。

3.1 打开适配器与扫描:先挂 onBluetoothDeviceFound,再 startDiscovery

第一段是初始化和扫描,核心是「回调先注册,动作后触发」这个顺序:

// js/bluetooth.js —— 扫描阶段 const state = { deviceId: '', serviceId: '', writeCharId: '', readCharId: '', notifyCharId: '', connected: false }; // 打开蓝牙适配器:整个 BLE 会话的总开关 plus.bluetooth.openBluetoothAdapter({ success() { console.log('adapter ready'); startDiscovery(); }, fail(err) { // 常见 errCode:10000 未初始化,10001 蓝牙未开启 console.error('openBluetoothAdapter fail', JSON.stringify(err)); } }); let foundBound = false; function startDiscovery() { // 设备发现回调全局只注册一次,重复注册会覆盖上一个监听器 if (!foundBound) { plus.bluetooth.onBluetoothDeviceFound((res) => { res.devices.forEach((d) => { // d 里有 deviceId / name / localName / RSSI / advertisData renderDevice(d); }); }); foundBound = true; } plus.bluetooth.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, // 关掉重复上报,列表不会疯狂刷新 interval: 0, success() { console.log('discovery started'); }, fail(err) { console.error('startDiscovery fail', JSON.stringify(err)); } }); }

openBluetoothAdapter是后面所有调用的前提,它不成功,createBLEConnection之类的接口会直接抛错。allowDuplicatesKey建议设成false,同一个设备只上报一次,列表不会因为 RSSI 波动不停重排;如果要做信号强度实时显示,再改成true并把interval设成 1000 左右控制频率。

deviceId这个字段要特别当心。安卓上它通常就是设备的 MAC 地址,形如AA:BB:CC:DD:EE:FF;iOS 上是系统分配的一个 UUID,同一个设备在不同手机上还不一样。所以这个值只能当次会话用,不要写进本地存储当设备唯一标识,换了手机就对不上——这是我在一个跨平台项目里踩过的坑,后来统一改成用广播数据里的厂商自定义字段做识别。

renderDevice就是渲染列表,不涉及协议,随便写。列表项上一般会显示名称、deviceId和 RSSI 三个字段,点一下就进连接。

3.2 连接与枚举:createBLEConnection 之后要连做两次 get

连上设备只是建立了链路,服务和特征值还得自己去翻。这一段是整条链路里最容易被写错的地方:

function connect(deviceId) { plus.bluetooth.createBLEConnection({ deviceId, timeout: 10000, // 单位毫秒,不设的话某些机型会卡很久 success() { state.deviceId = deviceId; state.connected = true; // 连上就停扫描,省电,也避免干扰当前连接 plus.bluetooth.stopBluetoothDevicesDiscovery(); discoverServices(deviceId); }, fail(err) { console.error('createBLEConnection fail', JSON.stringify(err)); } }); } function discoverServices(deviceId) { plus.bluetooth.getBLEDeviceServices({ deviceId, success(res) { // 优先从主服务里找,次服务通常是系统信息 const primary = res.services.filter((s) => s.isPrimary); const target = primary.find((s) => isTargetService(s.uuid)); if (!target) { console.warn('no target service', res.services); return; } state.serviceId = target.uuid; discoverCharacteristics(deviceId, target.uuid); }, fail(err) { console.error('getBLEDeviceServices fail', JSON.stringify(err)); } }); } function discoverCharacteristics(deviceId, serviceId) { plus.bluetooth.getBLEDeviceCharacteristics({ deviceId, serviceId, success(res) { res.characteristics.forEach((c) => { const p = c.properties || {}; if (p.notify || p.indicate) state.notifyCharId = c.uuid; if (p.write || p.writeNoResponse) state.writeCharId = c.uuid; if (p.read) state.readCharId = c.uuid; }); console.log('chars ready', JSON.stringify(state)); // 拿到特征值立刻订阅,别等用户再点一次按钮 enableNotify(); }, fail(err) { console.error('getBLEDeviceCharacteristics fail', JSON.stringify(err)); } }); }

getBLEDeviceServices返回的services是个数组,每项有uuid和isPrimary。一个 BLE 设备一般有好几个服务,标准服务如0000180A(设备信息)是系统用的,真正的业务服务是厂商自定义的那串 128 位 UUID。isTargetService就是拿 UUID 做前缀匹配,这里必须统一大小写再比,因为安卓返回大写、iOS 返回小写,直接===永远不成立——这个坑我在两个平台上各翻过一次。

properties里的字段决定这个特征值能干什么。notify表示设备会主动推送,write表示可以带响应地写,writeNoResponse表示写完就走不等确认,read表示可以主动读。通常一个自定义服务里会有两个特征值:一个负责收(notify),一个负责发(write),state里分三个字段存就是为这个。

timeout这个参数值得单独说一句。不传的时候底层用的是默认值,有的机型能卡到几十秒,用户以为死机了。显式给 10 秒,超时走fail回调,界面上给个「重试」按钮,体验上差很多。

3.3 订阅与写入:notify 必须先开,写值必须给 ArrayBuffer

链路建好、UUID 拿到,剩下的就是让数据流起来:

function enableNotify() { plus.bluetooth.notifyBLECharacteristicValueChange({ deviceId: state.deviceId, serviceId: state.serviceId, characteristicId: state.notifyCharId, state: true, // true 开启订阅,false 关闭 success() { console.log('notify enabled'); // 接收回调全局挂一次,不要写在 success 里面反复注册 plus.bluetooth.onBLECharacteristicValueChange((res) => { // res.value 是 ArrayBuffer,直接 console.log 会打出一堆数字 const hex = bufferToHex(res.value); console.log('rx <-', hex); onFrame(hex); }); // 订阅成功后发一帧握手,很多设备要收到指令才开始推数据 writeHex('AA55010000'); }, fail(err) { console.error('notify fail', JSON.stringify(err)); } }); } function writeHex(hex) { const buffer = hexToBuffer(hex); plus.bluetooth.writeBLECharacteristicValue({ deviceId: state.deviceId, serviceId: state.serviceId, characteristicId: state.writeCharId, value: buffer, // 必须是 ArrayBuffer,传字符串会静默失败 success() { console.log('tx ->', hex); }, fail(err) { console.error('write fail', JSON.stringify(err), hex); } }); }

notifyBLECharacteristicValueChange里的state: true才是订阅动作本身,properties里有notify只代表这个特征值「支持」订阅,不代表「已经」订阅。不少新手看到属性里有 notify 就以为能收数据了,结果一直等不到回调,这是最常见的一类误判。

onBLECharacteristicValueChange我强烈建议全局只注册一次,放在页面初始化阶段。写在 notify 的success里,每次重连都会再注册一个,多个监听器叠加会导致同一帧数据被处理好几遍,业务层表现为「指令重复执行」,排查起来相当玄学。

writeBLECharacteristicValue的value参数必须是ArrayBuffer。传十六进制字符串在部分版本上不报错但设备收不到,属于那种不给你任何反馈的沉默失败。所以我习惯在封装的writeHex里先转换再传,日志里同时打印源 hex 和目标 byteLength,出问题时一眼能看出转换有没有问题。写之前建议先读一下writeCharId是否为空,如果设备那边write属性标在别的特征值上,写错地方也是返回 success 但没反应。

4. 编解码与分包:ArrayBuffer、十六进制和 20 字节那道坎

BLE 传的是字节流,业务层习惯用字符串和十六进制,中间这层转换绕不开。这份源码里convert.js短小但关键,我把它和分包逻辑放在一起说,因为这两个问题通常同时出现。

4.1 三种数据形态:ArrayBuffer、Uint8Array、hex 字符串

JS 里处理二进制有三层皮:ArrayBuffer是内存块本身,不能直接下标访问;Uint8Array和DataView是视图,负责按字节读写;hex 字符串是给人看的,日志和协议文档都用它。三者的互转函数如下:

// js/convert.js // ArrayBuffer -> 大写 hex 字符串,日志和调试都用它 function bufferToHex(buffer) { return Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, '0')) .join('') .toUpperCase(); } // hex 字符串 -> ArrayBuffer,写指令时用 function hexToBuffer(hex) { const clean = hex.replace(/[^0-9a-fA-F]/g, ''); if (clean.length % 2 !== 0) throw new Error('hex length must be even'); const buffer = new ArrayBuffer(clean.length / 2); const view = new DataView(buffer); for (let i = 0; i < clean.length / 2; i++) { view.setUint8(i, parseInt(clean.substr(i * 2, 2), 16)); } return buffer; } // 字符串 -> ArrayBuffer,手写 UTF-8 编码,避开老 WebView 缺 TextEncoder function strToBuffer(str) { const bytes = []; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code < 0x80) { bytes.push(code); } else if (code < 0x800) { bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); } else { bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); } } return new Uint8Array(bytes).buffer; }

padStart(2, '0')这一步不能省。字节值小于 16 的时候toString(16)只给一位,拼出来整条 hex 就错位了,接收端按两位切分全乱套。hexToBuffer里正则清洗是为了容错,用户从串口工具里复制过来的 hex 常常带空格和0x前缀,直接parseInt会得到NaN,写进去就是一串 0。

strToBuffer手写 UTF-8 而不是用TextEncoder,是因为部分老安卓 WebView 里这个全局对象不存在。业务上如果只发 ASCII 指令,用charCodeAt就够了,但一旦有中文(比如透传设备名),不按 UTF-8 编码过去就是乱码。

4.2 20 字节上限与分片写入

默认 MTU 是 23 字节,其中 3 字节被 ATT 协议头占掉,留给应用层的有效载荷只有 20 字节。超过这个长度,要么被底层截断,要么直接返回失败,具体表现跟机型有关。

const PAYLOAD = 20; // 23 字节 MTU 减去 3 字节 ATT 头 const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function writeLong(buffer) { const total = buffer.byteLength; let sent = 0; while (sent < total) { const end = Math.min(sent + PAYLOAD, total); const chunk = buffer.slice(sent, end); // slice 对 ArrayBuffer 是浅拷贝,安全 await new Promise((resolve, reject) => { plus.bluetooth.writeBLECharacteristicValue({ deviceId: state.deviceId, serviceId: state.serviceId, characteristicId: state.writeCharId, value: chunk, success: resolve, fail: reject }); }); sent = end; console.log(`chunk ${sent}/${total}`); await sleep(20); // 给设备端留处理时间,太密会丢包 } }

buffer.slice对ArrayBuffer返回的是新的内存副本,不是视图,所以传进去不会被后续修改影响。每片之间那 20 毫秒的间隔不是玄学,很多低功耗设备的蓝牙模组处理能力有限,连续无间隔写入会触发丢包,日志上一切正常,设备那边只收到一部分。这个值可以按设备调,20 到 50 毫秒是比较稳的区间。

需要提醒的是,有些安卓机型支持协商更大的 MTU(比如 247),但plus.bluetooth这套 API 没有直接暴露 MTU 协商接口,所以最稳妥的做法还是按 20 字节切。别指望靠改协议来绕,跨平台一致性比省那点时间重要得多。

4.3 加一层帧头:粘包和半包才有解

BLE 的通知回调不保证「一次回调对应一帧完整数据」。设备连续推两帧,底层可能合并成一次回调;一帧太长,也可能拆成两次回调。不加应用层协议,收到的就是一堆碎片。

// 帧格式:AA 55 | len(1) | cmd(1) | payload(n) | crc8(1) let rxBuffer = ''; function onFrame(hex) { rxBuffer += hex; while (rxBuffer.length >= 10) { // 帧头对不上就往前挪一个字节重新找 if (rxBuffer.substr(0, 4) !== 'AA55') { rxBuffer = rxBuffer.substr(2); continue; } const len = parseInt(rxBuffer.substr(4, 2), 16); const frameLen = (5 + len) * 2; // 总字节数 5+len,换算成 hex 字符数 if (rxBuffer.length < frameLen) break; // 半包,等下一块拼上来 const frame = rxBuffer.substr(0, frameLen); rxBuffer = rxBuffer.substr(frameLen); dispatch(frame); } } function dispatch(frameHex) { const cmd = frameHex.substr(6, 2); const payload = frameHex.substr(8, frameHex.length - 10); console.log('frame cmd=', cmd, 'payload=', payload); }

rxBuffer是跨回调的累积缓冲,这是解粘包的核心。while里那个break对应半包场景:长度字段说这帧有 12 字节,现在缓冲里只有 8 字节,就退出循环等下一次回调补齐。帧头检查失败的substr(2)是逐字节滑动找同步头,虽然效率不高,但容错性好,实际跑起来够用。

这套帧格式是参照常见设备协议定的,AA55是同步头,len是负载长度,cmd是指令码,最后一位是 CRC8 校验。真实设备的协议不一定长这样,但结构基本类似,把字段位置换掉就行,rxBuffer这套累积加滑窗的骨架可以直接搬。

5. 真机排查:五个最容易翻车的现象和它们的成因

蓝牙这块的特点是错误不透明,失败了经常什么都不给你。下面五条是我在这类项目里反复遇到的,按「现象 → 原因 → 解决」列出来。

5.1 扫不到任何设备,但接口全部返回 success

现象很迷惑人:openBluetoothAdapter成功,startBluetoothDevicesDiscovery也成功,但onBluetoothDeviceFound一次都不触发,列表永远空的。

原因基本锁定在权限链上。从安卓 6.0 开始,扫描 BLE 设备被系统归类为可能推断位置的行为,必须同时满足三个条件:应用拿到定位权限、系统定位总开关打开、并且是精确定位。少任何一个,扫描静默返回空。另外还有一种情况是目标设备已经被另一台手机或另一个 App 连着,低功耗从机大多只接受一路连接,此时它干脆不广播可连接信令。

解决顺序是先看应用权限设置里的定位授权有没有给,再看系统下拉菜单里的定位有没有开,两个都确认了还扫不到,就换一台没连过该设备的手机交叉验证。还有一个隐蔽点:startBluetoothDevicesDiscovery调用后马上stop再start,部分机型需要中间隔一秒以上才能重新生效,间隔太短会被底层忽略。

5.2 设备在列表里,createBLEConnection 一直失败

现象是点连接之后fail回调很快返回,errCode常见 10003、10006 这类。

原因之一是deviceId传错了。iOS 上这个值不是 MAC 地址而是系统给的 UUID,如果代码里从别处拼了一个 MAC 过来,安卓能用 iOS 必挂。原因之二是上一次连接的句柄没释放,设备端还认为链路存在,新连接被拒。原因之三比较少见但确实存在:设备电量低或者广播包里没有可连接标志,它只是在那儿广播,压根不接受连接。

解决上,先在fail回调里把deviceId和errCode原样打出来核对;然后在每次连接前调一次closeBLEConnection,不管上次成没成功,清理干净再连;超时时间设成 10 秒而不是等默认值。

5.3 写返回 success,设备一点反应都没有

现象是writeBLECharacteristicValue的success回调确实执行了,日志也打了tx ->,但设备端 LED 不亮、不动作。

最常见的原因是写错特征值。一个服务里可能有多个write属性的特征值,负责收指令的通常只有一个,写到别的上面底层照样返回成功,因为链路层面写入是合法的。第二类原因是指令内容对不上,帧头、CRC、字节序有一个不对设备就不认。第三类是要先写使能,有些设备的协议规定在收到业务指令前必须先发一条特定的配置帧。

解决上,把每次写出的hex和byteLength都打到日志,拿厂商的串口调试工具在电脑上对一遍同样的指令,能立刻区分是协议问题还是 App 问题。区分write和writeNoResponse也很关键,带响应的写完会等设备确认,不带响应的写完就结束,后者在设备处理慢的时候更容易丢。

5.4 通知订阅成功,但接收回调一次都不触发

现象是notifyBLECharacteristicValueChange返回 success,设备那边也确实在发数据,但onBLECharacteristicValueChange完全静默。

原因是回调注册的位置。如果把它写在notify的success里,而页面切换、重连时又重新执行了一遍notify,旧的监听器会被新的覆盖,某些情况下新监听器注册的时机比数据到达晚,第一批数据就丢了。另一类原因是订阅了错误的特征值——多个特征值都有notify属性时,要选设备实际用来上报数据的那个。

解决是把onBLECharacteristicValueChange的注册挪到页面初始化阶段,全局挂一次,和notify使能动作彻底拆开;然后在回调里打印res.characteristicId,跟你订阅的那个 UUID 对比一下,是不是同一个。

5.5 发长数据只收到前半截,或者发完就断

现象是发一条 60 字节的指令,设备只认了前 20 字节,或者链路直接断开需要重连。

原因很明确:默认 MTU 23 字节,有效载荷 20 字节,超出的部分被截断。断链则是连续高速写入导致设备端缓冲溢出。

解决就是第四章那套分片方案,按 20 字节切、每片之间留 20 到 50 毫秒间隔、每次写入用 Promise 包住确保上一片完成再发下一片。这里不要图省事用setTimeout堆一轮并发,写乱序比丢包更难查。

6. 二次开发:把 demo 收成一个能复用的 BLE 工具层

demo 的写法是回调套回调,能跑通但不好维护。真正要接到业务里,我一般做三件事:把所有plus.bluetooth调用 Promise 化、给连接流程加状态机和超时、把 UUID 配置抽到独立文件。

先看 Promise 化的封装,这是所有改造的地基:

// 把回调式 API 包成 Promise,统一加超时 function btCall(fn, options, timeout = 10000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('bt timeout')), timeout); fn({ ...options, success: (res) => { clearTimeout(timer); resolve(res); }, fail: (err) => { clearTimeout(timer); reject(err); } }); }); } // 用起来就是线性的 async 流程,中断就直接 throw async function connectFlow(deviceId) { await btCall(plus.bluetooth.createBLEConnection, { deviceId, timeout: 10000 }); const { services } = await btCall(plus.bluetooth.getBLEDeviceServices, { deviceId }); const target = services.find((s) => s.uuid.toLowerCase() === CONFIG.serviceUuid); if (!target) throw new Error('service not found'); const { characteristics } = await btCall(plus.bluetooth.getBLEDeviceCharacteristics, { deviceId, serviceId: target.uuid }); // ...继续订阅和握手 }

btCall的第一个参数直接传函数引用,第二个是原始参数对象,timeout兜底防止某个回调永远不回。这样整条连接流程就能用try/catch统一处理,失败点在哪一步一目了然,比嵌套回调好排查太多。

断线重连靠状态变化回调,这个必须挂:

plus.bluetooth.onBLEConnectionStateChange((res) => { if (!res.connected && res.deviceId === state.deviceId) { console.warn('link lost, retry after 2s'); setTimeout(() => connectFlow(res.deviceId).catch(console.error), 2000); } });

改造前后的差别用一张表看得更清楚:

维度demo 原版生产改造版
调用方式层层嵌套回调Promise + async/await
UUID 来源硬编码在 js 里独立 config 文件,按设备型号切换
超时控制依赖底层默认每步显式设 10 秒
断线处理无状态回调 + 2 秒重连
数据出口直接 console.log事件总线分发给业务模块

UUID 外置这一步收益最大。同一个 App 要对接多种设备时,把serviceUuid、writeCharId、notifyCharId三件套按型号写成配置对象,切换设备只改配置不改逻辑。我一般还会在每个 UUID 后面加一行注释写清楚这个特征值是干什么的、来自厂商文档第几页,过三个月再回来看能省不少时间。

验证改造有没有成功,我的做法是造三个场景跑一遍:正常连接收发、连到一半把设备关机再开机、连续发 500 字节长指令。三个都过了才算稳。

从那以后我每次接新的蓝牙设备,都强制先把「扫到 → 连上 → 订上 → 收到第一帧」这条最小链路在真机上跑通,再去写任何业务界面。这个顺序反过来做,后面全是血泪。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询