HBuilderX下BLE蓝牙通讯:Web Bluetooth API四步闭环与分包调试指南
简介面向使用HBuilderX进行跨平台蓝牙开发的前端与混合应用开发者这套经实测可用的HTML5蓝牙通信Demo解决了Web端连接BLE设备、实时监控蓝牙状态及数据收发等关键问题。压缩包共258个文件核心包含java/js源码、Android原生蓝牙工程涉及BluetoothAdapter、BluetoothGatt、可安装apk示例及xml配置另附MLT-BT05蓝牙模块资料与串口调试工具便于软硬件联调整体11.45MB结构清晰。已有5166人学习下载。开发者可从中获取Web蓝牙API调用与Android原生蓝牙两种实现思路覆盖设备扫描、GATT服务读写及异常处理既能作为学习蓝牙通信原理的完整范例也能直接移植到智能硬件、健康监测等实际项目中降低从零调试蓝牙模块的难度。1. HBuilder 跑蓝牙通讯一个 Demo 就把 BLE 四步闭环讲透了在 HBuilderX 里做 HTML5 开发最劝退的功能就是蓝牙通讯要么写原生插件要么绕道小程序再包一层。这个 html5-bluetooth-demo 的可贵之处是它证明了一条能走通的路径——用标准 Web Bluetooth API扫描、连接、读写、通知四个环节一次跑通而不是停留在“理论上可行”。它特别适合三类人手里有 BLE 硬件、想快速验证连通性的开发者想把调试页做成 H5、方便随时打开的工程师以及准备把 HTML5 项目包成 App、但要先确认蓝牙链路能不能用的团队。这篇按我实际做过的方案拆成五步先选型再跑通然后调 UUID、写入模式和 MTU再按避坑清单排查最后从 Demo 进化成能持续收数据的工具。十分钟能跑起来但真正决定数据对不对的是后面的参数和习惯。2. 先弄清底层两套蓝牙接口Demo 走的是哪条链路在 HBuilderX 里写蓝牙真正的坑不在代码而在一开始选哪套接口。有人用标准 Web Bluetooth API有人走 HTML5 的原生扩展。Demo 名字里的 html5 已经透露了主路径但你要理解另一条路为什么存在才能在该切换的时候切换。这一章先把两条路的边界画清楚免得跑不通时不知道往哪个方向排查。2.1 Web Bluetooth API 的完整调用链从 requestDevice 到 characteristicBLE 的 GATT 模型可以压缩成四层广播里能看到设备连上之后拿到 GATT ServerServer 下面挂 ServiceService 下面挂 Characteristic真正收发数据的是 Characteristic。Web Bluetooth API 把这四层映射成了四个异步方法requestDevice 选设备、gatt.connect 建立连接、getPrimaryService 拿服务、getCharacteristic 拿特征。后面做读写和通知全是在这个特征对象上操作所以记这条调用链比记任何 API 文档都重要。这条链路有两个硬性前置条件。第一requestDevice 必须发生在用户手势里也就是按钮的 click 回调里页面 onload 时自动调用会被浏览器直接当成非法请求拒掉。第二navigator.bluetooth 只存在于安全上下文HBuilderX 打包后页面是 file:// 协议属于安全上下文但如果你把页面放在 http 的非本机地址上测试这个 API 就是 undefined。这两个条件不满足后面代码写得再对也没用。调用链上每个环节返回的都是 Promise失败模式也各不相同requestDevice 用户取消会抛错connect 失败多数是设备不可连接getPrimaryService 失败多半是 UUID 对不上。我的习惯是每层都 catch 并打日志别让错误在 Promise 链里被静默吞掉。拿到特征对象之后的事情——读写、通知、分包——全都可以在这条链上原地展开。2.2 plus.bluetooth 与 navigator.bluetooth 怎么选用 HBuilderX 打包 HTML5 项目时会发现运行时除了浏览器自带的接口还注入了一个 plus 对象里面有整套原生蓝牙能力。于是就有了两条路一条是标准的 Web Bluetooth一条是 HTML5 的原生封装。两者不是替代关系而是互补关系具体差异看这张表对比项navigator.bluetooth标准 HTML5plus.bluetoothHTML5 原生能力平台覆盖仅 Android 的 Chromium 内核 WebViewAndroid、iOS 打包后都可用API 风格Promise 链异步方法逐层 await回调风格类似小程序蓝牙 API依赖条件系统 WebView 必须实现该 APIHBuilderX 运行时内置打包即有适用场景浏览器调试、H5 工具页要包 App、要覆盖 iOS 的正式项目我一般这么决策如果项目要长期维护、将来要多端跑直接用 plus.bluetooth 做底座因为它跨端稳定不会因为用户手机 WebView 版本不同而翻车如果只是临时验证一块 BLE 硬件或者要在 HBuilderX 内置浏览器里调试走 navigator.bluetooth 最快。Demo 的主路径是后者但它保留了 plus 分支这也是我推荐的结构——用特性检测在运行时决定走哪条路。还有一个容易忽略的点plus.bluetooth 这套 API 只在 HBuilderX 打包后的运行时里存在浏览器预览时 plus 对象是 undefined。所以代码里访问 plus 之前必须先判断 typeof plus ! undefined否则浏览器一打开就是 ReferenceError连后面代码都不执行。2.3 跑之前先做三个判断内核、权限、安全上下文任何蓝牙 Demo 跑起来之前先做一次能力探测是最省时间的动作。下面这段代码是 Demo 里最先执行的部分三个判断分别回答三个问题当前 WebView 有没有标准蓝牙 API、页面是不是安全上下文、打包环境有没有原生蓝牙能力。// 蓝牙能力探测决定当前环境走哪套 API function detectBluetoothSupport() { const support { // 标准 Web BluetoothAndroid 的 Chromium 内核 WebView 才有 webBluetooth: typeof navigator ! undefined typeof navigator.bluetooth ! undefined, // 安全上下文file:// 和 https 都算http 非本机地址不算 secureContext: typeof window ! undefined window.isSecureContext true, // HTML5 原生能力HBuilderX 打包后的 App 环境才有 plusBluetooth: typeof plus ! undefined typeof plus.bluetooth ! undefined }; console.log([蓝牙能力探测], JSON.stringify(support)); return support; } // 页面加载后调用根据探测结果决定走哪条分支 const support detectBluetoothSupport(); if (support.webBluetooth support.secureContext) { // 走标准 Web Bluetooth API } else if (support.plusBluetooth) { // 走 HTML5 原生蓝牙能力 } else { alert(当前环境不支持蓝牙通讯请在 Android 系统 WebView 或打包后的 App 中运行); }这段探测代码的逻辑是逐步降级优先走标准 API没有就退到原生能力再没有就直接提示。参数上注意 secureContext 这一项很多人会漏掉——真机调试时如果页面是从局域网 http 地址加载的webBluetooth 可能是 true但后续调用照样失败因为安全上下文不满足。把 console.log 打开看一眼三个布尔值你就知道该看哪一份代码不用瞎猜。3. 把 Demo 跑起来建项目、配权限、抄最小可用代码从 HBuilderX 新建一个空白 HTML5 项目开始到能对着 BLE 设备完成一次读写我按三步走先声明权限再抄扫描连接最后抄读写。每一步的代码都可以直接粘进你的页面改掉 UUID 就能用。这三步走通之后剩下的调参和排错才有意义。3.1 manifest.json 的蓝牙与定位权限声明Android 下 BLE 扫描是“蓝牙 定位”的双权限组合这是最容易踩的隐性坑。Android 6 到 10扫描蓝牙设备需要 ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION因为系统把蓝牙扫描当作定位来源之一到 Android 12新增了 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 两个权限旧的 BLUETOOTH 反而被降级。很多人只声明了蓝牙权限扫不到设备就是栽在定位权限上。HBuilderX 打包时会把下面这些节点写进 Android 工程清单。真机调试时如果系统没有自动弹定位授权框最快路径是去系统设置里手动给应用打开定位再回到应用重新扫描。{ permissions: { BLUETOOTH: { desc: 使用蓝牙连接设备 }, BLUETOOTH_ADMIN: { desc: 扫描与配对蓝牙设备 }, ACCESS_COARSE_LOCATION: { desc: 蓝牙扫描需要的定位权限 }, ACCESS_FINE_LOCATION: { desc: 精确定位Android 10 以下扫描必需 }, BLUETOOTH_SCAN: { desc: Android 12 扫描蓝牙设备 }, BLUETOOTH_CONNECT: { desc: Android 12 连接蓝牙设备 } } }注意 manifest 只负责“声明”Android 6 的定位权限是运行时权限打包后的 App 首次启动要在页面代码里调用系统的权限请求接口或者让用户去设置里手动开。调试阶段不要在这一步节省时间蓝牙没开、定位没开、权限没给这三件事任何一个不满足扫描结果都是空的而且不报错。3.2 扫描设备requestDevice 的正确姿势扫描在 Web Bluetooth 里叫 requestDevice这个名字已经说明它是“请求用户选择一个设备”所以必然弹系统选择框。过滤条件写在 filters 里按 service UUID 过滤是最标准的做法——几乎每个 BLE 设备广播时都会携带自己主服务的 UUID。如果不确定设备广播的是什么先用 acceptAllDevices 把附近设备都列出来能扫到再反过来补 filters。async function scanAndConnect() { // 1. 检查 API 是否存在 if (!navigator.bluetooth) { alert(当前 WebView 不支持 Web Bluetooth); return; } // 2. 请求用户选择设备必须在用户点击回调里调用 const device await navigator.bluetooth.requestDevice({ // 按设备广播携带的 service UUID 过滤减少干扰项 filters: [{ services: [0000ffe0-0000-1000-8000-00805f9b34fb] }], // 连接后要访问、但广播里没有的 UUID必须在这里声明 optionalServices: [0000ffe0-0000-1000-8000-00805f9b34fb] }); console.log(已选择设备:, device.name, device.id); // 3. 建立 GATT 连接 const server await device.gatt.connect(); console.log(GATT 已连接正在枚举服务…); return { device, server }; }这里的 filters 和 optionalServices 是两个最容易出错的参数。filters 里的 UUID 必须是设备广播包中真实携带的填错了设备根本不会出现在选择列表里optionalServices 则是把连接后你打算访问、但设备广播里没有的服务 UUID 提前声明。这是 Web Bluetooth 一个反直觉的设计不列进去连接后 getPrimaryService 会失败。调试阶段如果拿不准可以把 optionalServices 设成你手里所有相关的 UUID宁可多列也不要漏。3.3 连接后读写service 与 characteristic 最小闭环连上之后就是 GATT 的常规操作。注意两点第一getPrimaryService 拿的是主服务如果设备用的是非主服务需要换成 getService第二读写特征可能是同一个也可能分开取决于设备固件怎么定义。很多串口透传模块的默认配置是 0xFFE0 服务加 0xFFE1 特征下面的代码就用这个组合举例。async function readAndWrite(server) { // 1. 拿到主服务0xFFE0 是常见串口透传模块的默认服务 const service await server.getPrimaryService(0000ffe0-0000-1000-8000-00805f9b34fb); // 2. 拿到特征0xFFE1 是透传读写特征的默认 UUID const characteristic await service.getCharacteristic(0000ffe1-0000-1000-8000-00805f9b34fb); // 3. 写入一帧命令writeValue 只接受 ArrayBuffer const cmd new Uint8Array([0xAA, 0x55, 0x01, 0x00]); await characteristic.writeValue(cmd.buffer); console.log(写入完成:, Array.from(cmd)); // 4. 读取设备返回返回的是 DataView包一层 Uint8Array 方便按字节取 const dataView await characteristic.readValue(); const bytes new Uint8Array(dataView.buffer, dataView.byteOffset, dataView.byteLength); console.log(读取到, bytes.byteLength, 字节:, Array.from(bytes)); return bytes; }writeValue 只接受 ArrayBuffer所以要把命令先包成 Uint8Array 再取 .bufferreadValue 返回的 DataView 带 byteOffset转 Uint8Array 时必须把 byteOffset 和 byteLength 一起带上否则数据会从头开始读错位。0xAA 0x55 是很多设备协议的帧头这里只作示例正式项目以你手里设备的协议为准。如果模块是读写分离的那就分别 getCharacteristic 两个 UUID一个负责收一个负责发。提示连接后第一次读写建议先用设备文档里的固定值做一次“回环测试”即发送一个设备必然回复的命令确认链路是通的再开始调业务数据。4. 调通数据的关键参数UUID、写入模式、MTU 与分包能连上只是开了个头数据对不上才是真正耗时间的环节。我每次调蓝牙必查四样东西UUID 匹配、写入模式、MTU 分包、字节序。前两个决定功能通不通后两个决定数据对不对。这一章把这四个参数逐个说清楚。4.1 三个必调参数serviceId、characteristicId、写入模式这三个参数直接决定你在和设备的哪一层对话。设备文档通常会给你一份服务表上面写着服务和特征的 UUID、属性、用途。把文档和代码一一对应是最笨但最可靠的调法。参数说明填错时的现象serviceId目标服务的完整 UUIDgetPrimaryService 抛错或者返回空characteristicId读写特征的完整 UUID拿不到特征或者读写无响应写入模式带响应 / 不带响应NotSupportedError或写后设备无动作写入模式是这里最微妙的一个。Web Bluetooth 里 writeValue 等价于“带响应的写”设备每收一包会回一个 ATT 层确认writeValueWithoutResponse 则是不带响应的高吞吐写法适合持续透传但设备不确认。判断依据是特征对象的 properties打印 properties.write 和 properties.writeWithoutResponse哪个为 true 就用哪个方法。有些设备文档写得含糊实际属性位和文档还不一致这时候只能以运行时打印的 properties 为准。4.2 MTU 只有 23 字节超过 20 字节必须分包BLE 默认 MTU 是 23 字节去掉 3 字节的 ATT 头单包有效载荷只有 20 字节。Web Bluetooth API 不提供直接设置 MTU 的入口系统在连接时会自动做一次 MTU 协商但协商结果不可控而且很多设备固件只支持默认值。所以可靠的做法是超过 20 字节的数据一律自己分包不要赌设备支持大包。代码块后面必须跟逻辑说明这是分包的核心// 超过 20 字节的数据按包发送包大小默认 20间隔 20ms async function sendLongData(characteristic, data, chunkSize 20) { const bytes data instanceof Uint8Array ? data : new Uint8Array(data); for (let i 0; i bytes.length; i chunkSize) { const chunk bytes.slice(i, i chunkSize); // 复制出一段独立 buffer await characteristic.writeValue(chunk.buffer); // 给设备协议栈留出处理时间避免粘包 await new Promise(resolve setTimeout(resolve, 20)); } console.log(分包发送完成共, Math.ceil(bytes.length / chunkSize), 包); }这里用 bytes.slice 而不是 subarray是因为 subarray 共享原 buffer直接拿它的 .buffer 会把整段原始数据发出去。20ms 的间隔是我习惯的保守值给设备协议栈留处理时间如果你确认设备支持大 MTU可以试着把 chunkSize 往上调但建议一步步加而不是一次跳满。分包发送还有另一个隐藏问题接收端要自己拼包所以协议里最好带上包序号或长度字段否则丢一包你根本发现不了。4.3 收发数据的字节序与帧解析设备端返回的多字节数值绝大多数是低字节在前也就是小端。JavaScript 里用 DataView 的 getUint16(offset, littleEndian) 就能正确读取第二个参数传 true 就是小端。坑在于很多人习惯直接拿 Uint8Array 下标拼数拼出来永远是反的。下面是一个典型的帧解析函数// 解析设备返回的数据帧示例协议AA 55 01 温度低字节 温度高字节 function parseFrame(bytes) { if (bytes[0] ! 0xAA || bytes[1] ! 0x55) { console.warn(帧头不对丢弃); return null; } const cmd bytes[2]; // DataView 第二个参数传 true 表示小端读取 const view new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const temperature view.getUint16(3, true); console.log(命令 0x cmd.toString(16).padStart(2, 0) 温度 (temperature / 10) ℃); return { cmd, temperature: temperature / 10 }; }这个函数里最关键的是 new DataView 时带了 byteOffset 和 byteLength。当你对 Uint8Array 做切片或者从通知事件里拿到一段子数组时它的 buffer 是整块原始缓冲区不带偏移直接 new DataView读出来的第一个字节其实不是帧头。这是我见过最隐蔽的解析 bug症状是“偶尔能收到对的数据大部分时候乱码”。多字节数值解析对了剩下的就是对帧头和命令字节的常规校验这套模板可以直接套用到绝大多数设备协议上。提示写解析函数时把“帧头不对”打成 warn 而不是静默忽略调试时你会感谢这行日志。5. 蓝牙通讯避坑五个最容易翻车的现场下面五条都是我在真机上调 BLE 时真实遇到过的而且单靠看文档解决不了的问题。每条按现象、原因、解决写你遇到同款可以直接对照。蓝牙调试的玄学成分很高但大部分问题回头看都是这几个固定原因。5.1 现象设备列表出得来一点“连接”就报错现象requestDevice 弹出了系统设备选择框选完设备后 connect 报 TypeError或者一直卡在 pending 状态。原因有两类。第一类是用户手势失效requestDevice 和 connect 必须由用户点击直接触发如果你在 click 回调里先 await 了别的东西再调 requestDevice部分内核会判定这次调用不再属于用户手势。第二类是设备广播窗口短很多低功耗设备只在广播的前几秒可被连接你犹豫了几秒再点它已经进入不可连接状态。解决把 requestDevice 和 connect 放进同一个 click 回调里连续执行中间不要插入任何异步等待。界面保留“重新扫描”按钮扫描到设备后提示用户尽快点击连接不要等。5.2 现象能搜到设备但 getPrimaryService 拿不到服务现象设备出现在选择列表里连接也成功但 getPrimaryService(uuid) 抛错或者返回空。原因设备广播里只包含了部分 service UUID你要访问的那个服务并没有广播。Web Bluetooth 的服务发现机制要求这类“连接后才能发现”的服务必须提前写进 requestDevice 的 optionalServices否则服务发现阶段不会把它暴露给你。解决在 requestDevice 里补 optionalServices把你可能访问的所有 UUID 都列进去宁可多列不能少。实在不知道设备有哪些服务连接后调用一次不带参数的 getPrimaryServices() 枚举全部服务把打印出来的 UUID 列表和你手里的设备文档比对就能确认到底是漏声明还是 UUID 写错了。5.3 现象writeValue 报 NotSupportedError现象写入时抛 NotSupportedError或者写入后设备毫无反应。原因这个特征根本不支持“带响应写”只支持 writeWithoutResponse或者反过来你用了不带响应的方法但设备只支持带响应的写。还有少数情况是特征属性表本身写错了运行时 properties 才是真实情况。解决先打印 properties 再选择写入方式// 打印特征属性判断该用哪种写入方式 console.log(特征属性:, { read: characteristic.properties.read, write: characteristic.properties.write, writeWithoutResponse: characteristic.properties.writeWithoutResponse, notify: characteristic.properties.notify }); if (characteristic.properties.writeWithoutResponse !characteristic.properties.write) { await characteristic.writeValueWithoutResponse(cmd.buffer); } else { await characteristic.writeValue(cmd.buffer); }这段代码的逻辑是优先匹配真实属性位只支持不带响应写就走 writeValueWithoutResponse否则走带响应的 writeValue。如果两个都是 false说明这个特征只读要么换特征要么检查是不是拿错了 characteristicId。5.4 现象iOS 上 navigator.bluetooth 是 undefined现象同一套代码 Android 上一切正常iOS 上 navigator.bluetooth 直接是 undefined。原因iOS 的 WebKit 内核没有实现 Web Bluetooth 标准这是平台能力缺失不是你的代码问题。HBuilderX 打包后的 App 在 iOS 上跑的是 WebKit WebView标准 API 同样不存在。解决特性检测后走 plus.bluetooth 分支。这是最简单的兜底写法// plus.bluetooth 分支回调风格按设备 id 直接写 function writeViaPlusBluetooth(deviceId, serviceId, characteristicId, bytes) { plus.bluetooth.writeBLECharacteristicValue({ deviceId: deviceId, serviceId: serviceId, characteristicId: characteristicId, value: bytes.buffer, success() { console.log(写入成功); }, fail(err) { console.error(写入失败, err); } }); }如果只是做调试工具最省事的方式就是 Android 上走 Web Bluetooth、iOS 上提示“当前环境不支持”开发成本最低。要正式覆盖 iOS就得把读写统一封装在自己的函数里内部按平台分别调 navigator.bluetooth 或 plus.bluetooth页面只调用这个封装不要散落到处判断。5.5 现象读写正常几十秒后自动断连现象收发都没有问题但过一会儿连接自己断开重连还得重新扫描。原因低功耗设备进入休眠或者改变了广播策略这是常见情况另一个容易被忽略的原因是另一个调试工具占用了连接——很多 BLE 模块同一时刻只允许一个中心设备连接你用别的 App 连过一次模块就可能不再响应新连接。解决给 device 挂 gattserverdisconnected 监听断开后把界面状态切回“未连接”重连时必须重新走 requestDevice旧的 device 对象不能直接复用因为 GATT server 已经失效。排查时先关闭其他蓝牙调试工具再看设备休眠策略是不是太激进两条线一起查别只盯着代码。6. 把 Demo 改成能用的工具从单次读写到持续通知6.1 startNotifications把设备主动上报的数据接进来设备大多数时候不是“你问它答”而是按自己的节奏主动上报比如心率、温湿度、姿态角。这时候轮询读取效率太低正确姿势是打开通知让设备往你这边推。通知的开关在特征上收数据靠 characteristicvaluechanged 事件// 打开通知设备主动上报不用轮询 async function enableNotifications(characteristic, onData) { if (!characteristic.properties.notify) { console.warn(该特征不支持通知请确认设备固件配置); return; } await characteristic.startNotifications(); characteristic.addEventListener(characteristicvaluechanged, (event) { const value event.target.value; // DataView const bytes new Uint8Array(value.buffer, value.byteOffset, value.byteLength); onData(bytes); }); console.log(通知已开启等待设备上报…); }startNotifications 只是打开开关真正收数据靠事件里的 event.target.value它依然是 DataView转 Uint8Array 时要带上 byteOffset。关闭时调 stopNotifications 并移除监听不然页面切走之后回调还在跑容易出野指针式的问题。配合第 4 章的帧解析函数这一段就足够支撑一个持续收数据的工具页了。6.2 断线重连与一个调试习惯低功耗设备断线是常态不是异常。把断线当异常处理你的工具永远不稳定。正确做法是监听断开事件然后引导用户重新扫描连接// 断开监听与重连入口 device.addEventListener(gattserverdisconnected, () { console.warn(设备已断开请点击重新扫描连接); document.getElementById(reconnectBtn).disabled false; // 注意旧 device 对象不能直接 gatt.connect必须重新 requestDevice });这里最反直觉的是旧 device 对象不能复用。断开后 GATT server 对象已经失效重新 connect 在 Android 上大概率报错所以界面必须保留“重新扫描”入口让用户重新走一遍选择流程。这是我调蓝牙半年来养成的习惯任何蓝牙页面都默认设计成“可重连、可重扫、可切换设备”而不是只能连一次。关于调试习惯我赔过一个下午那次调一批手环连上就断断了再连一整天没找到规律最后发现是代码里 0xFFE0 简写和完整 UUID 混用服务匹配不上。从那以后我所有 UUID 一律用完整字符串集中定义在文件头部换设备只改一处。希望你少走这一步希望帮到你。本文还有配套的精品资源点击获取