☰
HBuilderX+html5-bluetooth-demo:BLE蓝牙通讯实战与避坑指南
2026/10/18 11:25:11 网站建设 项目流程

简介:面向需要使用HBuilderX进行移动端蓝牙开发的前端与物联网开发者,这份已实测可用的HTML5蓝牙通信示例,提供了从设备扫描、连接管理到数据收发的一整套可运行工程。压缩包共包含258个文件,既有前端js、html与H5+插件配置,也有Android原生Java源码及对应apk安装包,还附带MLT-BT05蓝牙模块资料与串口调试工具说明,整体约11.45MB,目录结构能帮助快速区分Web端和原生端实现。资源已有5166人学习下载,适合希望绕开繁琐底层适配、直接参考跨平台蓝牙交互逻辑的开发者使用。通过学习包内的示例,可以掌握navigator.bluetooth相关API的调用、Android BluetoothAdapter/Gatt原生通信方式,并借助配套模块文档完成硬件联调,为健康监测、智能控制等BLE场景提供可复制的基础框架。

1. 用 HBuilderX 调蓝牙通讯,比想象中顺:html5-bluetooth-demo 的套路先弄清

很多从业者拿到蓝牙传输源码后的第一反应,是在浏览器里打开 HTML 页面,然后对着白屏怀疑人生。这套 html5-bluetooth-demo 必须跑在 HBuilderX 打包出来的 5+ App 运行时上,它走的是 H5+Bluetooth 原生桥,不是 Web Bluetooth,浏览器里根本没有对应的原生模块。它解决的问题很具体:在 Android/iOS 真机上,用 HTML 页面直接扫描 BLE 外设、建立连接、读写特征值,把硬件数据收进前端逻辑里。适合三类人:给 IoT 硬件写调试工具的前端、做蓝牙外设配套 App 的开发者、刚接触 HBuilderX 想验证蓝牙能力的新手。下面按原理、配置、代码、踩坑、进阶的顺序一次讲透。

2. 先看清 H5+ 蓝牙模块的边界:为什么源码只针对 BLE 外设

2.1 手机是主设备,外设是从设备:BLE 角色模型别搞反

这套源码能跑通的前提,是先理解 H5+ 蓝牙模块的能力边界。plus.bluetooth 在 H5+ 规范里的名字虽然叫 Bluetooth,但实际实现的是低功耗蓝牙(BLE 4.0+),并且只实现了中心设备(Central)角色。意思很明确:手机主动去扫描、去连接外设,外设是被连接的周边设备(Peripheral)。一旦角色搞反,后面所有的 API 调用都会莫名失败。

我见过不少开发者的翻车现场:手里拿着一个蓝牙转串口模块,模块默认工作在经典蓝牙 SPP 模式,然后拿这套 Demo 去连,连半天都扫不到设备。原因很简单,SPP 是经典蓝牙的协议,plus.bluetooth 里没有对应的扫描和连接封装。遇到这种情况,要么把模块切到 BLE 模式,要么换支持 BLE 的外设,不要在代码层面硬耗。另外,手机与手机之间的蓝牙通讯也不适合这套方案,因为另一台手机默认不会把自己广播成 BLE 外设。做这类需求的人应该去看经典蓝牙相关方案,而不是在这份源码里找答案。

确认外设支持 BLE 后,再看数据模型。BLE 通讯不是像 TCP 一样建立一个长连接然后收发流式数据,它基于 GATT 协议,数据以属性(Attribute)的形式组织。手机端能对这些属性做读、写、订阅通知三种操作。所以写代码之前,先问外设厂商三个问题:外设的服务 UUID 是什么、特征值 UUID 是什么、每个特征值支持读还是写还是通知。这三个问题直接决定代码怎么写。

2.2 服务、特征值、描述符:数据到底从哪一层取

GATT 协议把外设的数据组织成三层结构:服务(Service)、特征值(Characteristic)、描述符(Descriptor)。可以这样理解:一个外设相当于一个文件夹柜,服务是抽屉,特征值是抽屉里的文件,描述符是文件上的备注。实际开发里,99% 的场景只需要关心服务和特征值,描述符极少直接操作,因为 H5+ 的 API 已经封装好了。

举一个最常见的温湿度计外设:它可能暴露一个服务,UUID 是 0xFFE0,这个服务下面有两个特征值,0xFFE1 用来读取温湿度数据,0xFFE2 用来接收设备主动上报的通知。在协议栈里,短 UUID 0xFFE0 其实是一个 128 位长 UUID 的缩写,完整形式通常是 0000FFE0-0000-1000-8000-00805F9B34FB。问题在于,不同外设厂商返回的 UUID 格式不统一。有的设备返回 16 位短值,有的返回完整的 128 位值,还有的在 Android 上返回小写、在 iOS 上返回大写。写代码时不能拿字符串直接比较,需要统一转成大写再判断。

还有一个容易忽略的属性叫 properties,它标明了特征值支持哪些操作。常见的值有 read、write、writeNoResponse、notify、indicate。一份外设文档里如果只告诉你特征值 UUID 而不告诉你 properties,代码大概率要翻车。比如你想往特征值里写指令,但该特征值只支持 read,调用写入接口就一定会报错。所以在连接成功后,第一件事是把特征值列表拉出来,看清楚每个特征值的操作类型,再决定用哪个 API。

2.3 一条调用链加七个错误码:先背下来再写代码

H5+ 蓝牙模块的 API 调用顺序是固定的,不能跳步。完整的链路是:打开蓝牙适配器 → 开始扫描 → 监听设备发现 → 停止扫描 → 建立连接 → 获取服务列表 → 获取特征值列表 → 读/写/订阅通知。任何一步没执行就调用下一步,大概率拿到错误码。

我把常用的错误码整理成一张表,建议直接贴在工位上。排错的时候先看错误码,能省下一大半瞎猜的时间。

错误码含义常见触发场景
10000蓝牙适配器未初始化没调 openBluetoothAdapter 就调用后续 API
10001蓝牙适配器不可用手机蓝牙没开,或系统权限被拒绝
10002没有找到合适设备扫描范围内没有匹配的设备
10003连接失败外设距离太远、设备正在被其他手机连接
10004没有找到指定服务服务 UUID 写错,或外设服务广播不完整
10005没有找到指定特征值特征值 UUID 写错,或服务下确实没有该特征值
10006当前连接已断开外设主动断开、超出信号范围、手机锁屏
10007当前特征值不支持此操作对只读特征值执行写入,或对不支持通知的特征值开通知

这套错误码和调用链是整份源码的地基。地基没问题,后面写业务逻辑才不会被各种诡异现象带偏。

3. 把 Demo 跑在真机上:配置、扫描、连接、读写的完整步骤

3.1 项目配置:manifest.json 里勾蓝牙模块与定位权限

源码本身是用 HBuilderX 创建的 5+ App 项目,第一步是把工程导入 HBuilderX,然后打开 manifest.json 的可视化配置界面。在「App 模块配置」里找到 Bluetooth(蓝牙)并勾选,这一步对应的是往配置文件里写入蓝牙模块的声明。如果不勾,后面调用任何 plus.bluetooth 方法都会直接失败,而且报的错误往往让人摸不着头脑。

Android 平台的坑在定位权限。BLE 扫描在 Android 6.0 以上被系统判定为需要定位权限,因为蓝牙扫描结果可能暴露用户位置。所以除了在 manifest.json 里声明蓝牙相关权限,还必须把定位权限也配进去。常见做法是在 Android 权限配置里手动补充这几项:

{ "distribute": { "android": { "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\"/>" ] } } }

需要注意的是,实际项目里我更推荐直接在 HBuilderX 的可视化界面里勾选,手写这段配置容易因为格式写错导致打包失败。可视化勾选后生成的内容和上面类似,但格式有官方保证。iOS 平台相对简单,只需要在隐私描述里配上蓝牙用途说明,否则首次调用蓝牙时 App 会被系统直接杀掉。

权限还有一个隐藏层:Android 6.0 以上,定位权限必须在运行时动态申请,光写在清单文件里不够。5+ App 里可以通过原生 API 弹窗申请:

function requestPermission() { plus.android.requestPermissions( ['android.permission.ACCESS_FINE_LOCATION'], function(resultObj) { console.log('权限申请结果:' + JSON.stringify(resultObj)); }, function(error) { console.error('权限拒绝:' + JSON.stringify(error)); } ); }

这段代码要在调用蓝牙初始化之前执行。如果用户拒绝授权,后续的扫描 API 会返回空列表,并且不会有任何提示,这是最容易让人误判为「设备不存在」的原因之一。我一般会在页面加载完成时先检查权限,被拒就引导用户去系统设置里手动打开。

3.2 初始化与扫描:三步拿到外设列表

配置完成后,开始写核心逻辑。先初始化蓝牙适配器,再启动扫描。初始化失败时一定要把错误码打出来,不要只打「失败」两个字,否则后面排查没有依据。

function openAndScan() { // 初始化蓝牙适配器 plus.bluetooth.openBluetoothAdapter(function() { console.log('[蓝牙] 适配器已就绪'); startDiscovery(); }, function(err) { console.error('[蓝牙] 初始化失败:' + JSON.stringify(err)); }); } // 开始扫描 BLE 外设 function startDiscovery() { plus.bluetooth.startBluetoothDevicesDiscovery({ allowDuplicatesKey: true, interval: 100 }, function() { console.log('[蓝牙] 扫描已启动'); }, function(err) { console.error('[蓝牙] 扫描启动失败:' + JSON.stringify(err)); }); }

这里有两个参数值得单独说明。第一个是 allowDuplicatesKey,官方文档的描述是「是否允许重复上报同一设备」。如果设为 false,同一个设备只会回调一次;如果设为 true,扫描期间会持续收到该设备的广播更新,包括 RSSI 信号强度变化。做距离判断或者信号强度展示时,这个参数必须为 true,否则拿到的是固定值。

第二个是 interval,上报间隔,单位毫秒。间隔越短,设备列表刷新越快,但耗电也越快。在调试阶段我习惯设 100ms,能看到比较流畅的信号变化;正式发布时如果没有信号强度需求,可以把间隔拉长到 500ms 以上,或者干脆不传这个参数。

扫描结果通过 onBluetoothDeviceFound 回调返回,这个回调会非常频繁地触发,必须自己做好去重和过滤:

var deviceMap = {}; plus.bluetooth.onBluetoothDeviceFound(function(event) { var device = event.device; if (!device || !device.deviceId) return; // 按名字过滤,只要目标设备 if (device.name && device.name.indexOf('Temp') > -1) { deviceMap[device.deviceId] = device; renderDeviceList(device); } });

在实际外设里,广播名的获取存在两个不稳定因素。一是部分外设为了省电,广播包里不放设备名,只放服务 UUID,此时 device.name 为空,名字过滤会把目标设备过滤掉。二是同一设备在不同手机上可能返回不同的名称格式,有的带后缀,有的不带。所以调试阶段的建议是:先不传 services 过滤条件,也不按名字过滤,把所有扫描到的设备都打印出来,确认目标设备的真实广播信息后,再收紧过滤条件。

3.3 连接与服务发现:时序不对就全白搭

扫描到外设后,先停扫描,再建立连接。不要直接在设备发现回调里发起连接,因为扫描和连接同时进行时,部分外设会拒绝连接请求。常见的做法是用户点击列表项后先停扫描,等停止回调触发后再连接。

function connectDevice(deviceId) { // 先停止扫描 plus.bluetooth.stopBluetoothDevicesDiscovery(function() { console.log('[蓝牙] 扫描已停止'); }); // 等扫描完全停止后再连接 setTimeout(function() { plus.bluetooth.createBLEConnection({ deviceId: deviceId, timeout: 5000 }, function() { console.log('[蓝牙] 连接成功'); discoverServices(deviceId); }, function(err) { console.error('[蓝牙] 连接失败:' + JSON.stringify(err)); }); }, 200); }

这里强制等 200ms 是因为 stopBluetoothDevicesDiscovery 的停止回调触发时,底层蓝牙协议栈可能还没完全释放扫描资源,立刻连接容易失败。这个延时是血泪经验换来的,不要省。timeout 参数也很关键,设为 5000 意味着 5 秒内连不上就进入失败回调,避免一直卡在连接中状态。

连接成功的回调并不代表可以立刻读数据。外设的连接状态需要短暂稳定,服务表也不是瞬间就能拉取。所以发现服务之前,再做一个短延时:

function discoverServices(deviceId) { setTimeout(function() { plus.bluetooth.getBLEDeviceServices({ deviceId: deviceId }, function(event) { var services = event.services || []; console.log('[蓝牙] 发现服务数量:' + services.length); services.forEach(function(service) { // 过滤到目标服务后再拉特征值 if (service.uuid.toUpperCase().indexOf('FFE0') > -1) { getCharacteristics(deviceId, service.uuid); } }); }, function(err) { console.error('[蓝牙] 服务发现失败:' + JSON.stringify(err)); }); }, 300); }

服务列表里通常会有多个服务,有些是标准服务,比如电池电量服务 0x180F、设备信息服务 0x180A,有些是厂商自定义服务。过滤条件建议用 indexOf 而不是全等比较,因为 UUID 格式在不同平台可能不一致。统一转成大写再匹配,是减少跨平台差异最简单的办法。

特征值发现也是同理,传入 deviceId 和服务 UUID:

function getCharacteristics(deviceId, serviceId) { plus.bluetooth.getBLEDeviceCharacteristics({ deviceId: deviceId, serviceId: serviceId }, function(event) { var characteristics = event.characteristics || []; characteristics.forEach(function(char) { console.log('[蓝牙] 特征值:' + char.uuid); console.log('[蓝牙] 属性:' + JSON.stringify(char.properties)); }); }, function(err) { console.error('[蓝牙] 特征值获取失败:' + JSON.stringify(err)); }); }

到这里,整条数据链路的前半段就走完了。接下来是真正的数据交互。

3.4 数据交互:写入特征值与开启 Notify 接收

写入指令是蓝牙通讯最常用的操作。H5+ 的接口要求 value 必须是 ArrayBuffer 类型,不能用字符串直接传。这里最容易犯的错是把字符串转成 ArrayBuffer 时用了错误的方法,导致写入的数据完全不是预期内容。

function sendCommand(deviceId, serviceId, charId, commandText) { // 把字符串转成字节数组 var bytes = []; for (var i = 0; i < commandText.length; i++) { bytes.push(commandText.charCodeAt(i) & 0xFF); } // 字节数组转 Uint8Array,再取 buffer 作为 value var buffer = new Uint8Array(bytes).buffer; plus.bluetooth.writeBLECharacteristicValue({ deviceId: deviceId, serviceId: serviceId, characteristicId: charId, value: buffer }, function() { console.log('[蓝牙] 写入成功:' + commandText); }, function(err) { console.error('[蓝牙] 写入失败:' + JSON.stringify(err)); }); }

这里有个关键点:写法上一定要用 Uint8Array 构造出字节序列,再取它的 .buffer 赋给 value。如果直接把普通数组传进去,不少版本会返回参数错误或者写入空数据。commandText 按字符逐个转字节的做法只适合 ASCII 指令,如果要发中文或者二进制帧,就需要另写编码逻辑。从外设端收到的数据同样是 ArrayBuffer,需要反向解析:

plus.bluetooth.onBLECharacteristicValueChange(function(event) { var bytes = new Uint8Array(event.value); var hexString = Array.prototype.map.call(bytes, function(b) { return ('0' + b.toString(16)).slice(-2); }).join(' '); console.log('[蓝牙] 收到数据:' + hexString); // 按设备协议解析前两个字节作为温度 if (bytes.length >= 2) { var temperature = (bytes[0] << 8) | bytes[1]; updateTemperatureUI(temperature); } });

把收到的数据按十六进制打印是调试阶段的好习惯。蓝牙数据里的高低字节顺序、有无符号位、缩放系数,单看十进制很难发现问题。我用十六进制打印后基本一眼就能看出数据和协议文档是否对得上。

如果外设会主动上报数据,需要先开启 Notify:

plus.bluetooth.notifyBLECharacteristicValueChange({ deviceId: deviceId, serviceId: serviceId, characteristicId: charId, state: true }, function() { console.log('[蓝牙] 通知已开启'); }, function(err) { console.error('[蓝牙] 开启通知失败:' + JSON.stringify(err)); });

开启成功后,外设每次主动上报数据,onBLECharacteristicValueChange 都会触发。如果始终不触发,先确认特征值 properties 里是否包含 notify 或 indicate,再检查外设端是否调用了上报 API。外设端不开报,手机端什么也等不到。

4. 避坑手册:蓝牙 Demo 翻车的五处血泪现场

4.1 初始化失败 10001:权限配置比写代码更先

现象:openBluetoothAdapter 的失败回调返回错误码 10001,循环调用多次都无法成功。

原因:百分之九十是 manifest.json 里的蓝牙模块没有勾选,或者 Android 6.0 以上的定位权限没有动态申请。还有一小部分情况是手机系统蓝牙服务本身异常,但那个概率很低。

解决:先打开 manifest.json 的可视化配置,确认「App 模块配置」里 Bluetooth 已勾选;再确认 Android 权限列表里有 ACCESS_FINE_LOCATION;最后在代码里调用 openBluetoothAdapter 之前,先执行 plus.android.requestPermissions 申请定位权限。申请成功后再初始化,10001 基本不会再出现。

4.2 扫不到设备:广播名过滤和重复上报开关

现象:startBluetoothDevicesDiscovery 的成功回调触发了,但 onBluetoothDeviceFound 一次都没触发,或者只触发一次之后就不再更新。

原因:最常见的两个疑点。第一,设备广播包里的名称字段为空,名字过滤把设备筛掉了;第二,allowDuplicatesKey 设为 false,同一个设备只上报一次,看起来就像「扫不到」。

解决:调试阶段先把 services 过滤和名字过滤全部去掉,把原始广播信息完整打印出来,包括 deviceId、name、RSSI、serviceUUIDs。确认设备确实能被扫到后,再逐步收紧过滤条件。需要持续跟踪 RSSI 时,把 allowDuplicatesKey 设为 true,interval 保持 100ms 左右。

4.3 连上三秒就断线:扫描时序与服务发现延迟

现象:createBLEConnection 的成功回调触发了,但还没执行几个操作,onBLEConnectionStateChange 就回调 connected 为 false,连接被断开。

原因:最常见的情况是没有先停止扫描就发起连接,或者外设的连接状态还没稳定就立刻去拉取服务列表,导致协议栈处理异常。另一类原因是该外设只允许单连接,手机上其他应用已经占用了连接。

解决:严格按顺序来:先 stopBluetoothDevicesDiscovery,停扫描成功后再延时 200ms 发起连接;连接成功后再延时 300ms 再去获取服务列表。这个延时不是玄学,是给底层协议栈留状态切换的时间。同时检查手机上有没有其他应用在占用同一个外设,比如外设厂商自己的调试 App。

4.4 写入失败或丢包:特征值属性和 MTU 20 字节限制

现象:调用 writeBLECharacteristicValue 报错 10007,或者一次写入 30 个字节的数据,外设端只收到前 20 个字节。

原因:10007 表示该特征值不支持 write 操作,需要检查 properties 里是否只有 read 或 notify。数据被截断是因为 BLE 的 MTU 默认只有 23 字节,减去 ATT 头部的 3 字节,每次单包最多只能携带 20 字节的有效数据。这是协议层的硬限制,不是代码写错。

解决:先打印特征值的 properties,确认支持写操作。支持写后,把长数据拆成不超过 20 字节的分包,依次发送,分包的间隔建议 15ms 以上,避免外设端接收缓冲区溢出。具体的拆包策略放在下一章展开。

4.5 收了数据但页面不动:Notify 开关与后台清理

现象:外设确实在发数据,日志里也能看到连接正常,但 onBLECharacteristicValueChange 一直不触发,或者触发几次后停止。

原因:没有调 notifyBLECharacteristicValueChange 开启通知,或者 state 设成了 false。另一种情况是 Android 部分机型在屏幕熄灭或 App 切后台后限制了蓝牙扫描和通知行为,导致回调被系统掐断。

解决:连接成功后统一走一遍「查特征值属性 → 确认支持 notify → 开启通知 → 再订阅回调」的顺序,不要反过来先订阅再开通知。测试期间保持屏幕常亮,打开 HBuilderX 控制台观察日志,避免后台干扰因素干扰判断。数据频率高的外设,还适当降低 interval 以减少 CPU 占用。

5. 从 Demo 到能交付:分帧协议、断线重连与日志习惯

5.1 拆包发送:定义自己的帧协议来对抗 MTU

20 字节限制让传输任何有意义的数据都变得需要分包设计。我给模拟项目 X 做温湿度记录仪的上位机时,定义了一个最简单的帧协议:帧头 1 字节 0xAA,命令字 1 字节,数据长度 1 字节,数据段最长 15 字节,尾部 1 字节 CRC 校验,外设端按帧头聚合分包再解析。代码里的发送函数自动按 18 字节一段切分,每段间隔 20ms:

function sendLongData(deviceId, serviceId, charId, payload) { var chunkSize = 18; for (var i = 0; i < payload.length; i += chunkSize) { var chunk = payload.slice(i, i + chunkSize); sendCommand(deviceId, serviceId, charId, chunk); // 等待外设处理完上一包,避免缓冲区溢出 wait(20); } }

分包后协议栈每包都小于 20 字节,外设端收到后再按帧头重组,基本不会丢。帧协议设计的要点是:长度字段决定了单包数据上限,CRC 只覆盖帧头和负载,不要覆盖到帧尾之后。

5.2 断线自动重连:用状态回调做保活

外设断电、走远、手机锁屏都可能触发断线。监听 onBLEConnectionStateChange 能在断线瞬间拿到原因,再决定是否自动重连。重连要带退避策略,不能疯狂重试,否则会把外设的连接请求队列打爆。我这里用的是递减退避:第一次等 1 秒,第二次等 2 秒,最多等到 5 秒后重置。

var reconnectCount = 0; plus.bluetooth.onBLEConnectionStateChange(function(event) { if (!event.connected) { var delay = Math.min(1000 * Math.pow(2, reconnectCount), 5000); reconnectCount++; setTimeout(function() { connectDevice(event.deviceId); }, delay); } else { reconnectCount = 0; } });

重连成功后,服务列表和特征值要重新走一遍发现流程,因为连接会话是全新的,旧句柄全失效。我一般在重连成功回调里重新调用 discoverServices,保证业务数据链路完整恢复。

5.3 日志定位的固定习惯

蓝牙通讯的黑匣子性质决定了日志是排查问题的唯一钥匙。我的习惯是从初始化第一行开始,给每个 API 调用配上带标识的日志:初始化成功、扫描启动、设备发现、连接成功、服务数量、特征值属性、写入结果、通知开关。每条日志带上时间戳和错误码。这样真机上一跑,任何一步失败都能立刻定位到是哪一层出的问题。这套日志习惯让我少走很多弯路,从那以后我每次重新搭蓝牙项目,都会强制自己先花十分钟把日志框架搭好再写业务逻辑,希望帮到你。

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

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

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

立即咨询