简介面向需要使用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 运行时上它走的是 H5Bluetooth 原生桥不是 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 就调用后续 API10001蓝牙适配器不可用手机蓝牙没开或系统权限被拒绝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 和服务 UUIDfunction 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); } });把收到的数据按十六进制打印是调试阶段的好习惯。蓝牙数据里的高低字节顺序、有无符号位、缩放系数单看十进制很难发现问题。我用十六进制打印后基本一眼就能看出数据和协议文档是否对得上。如果外设会主动上报数据需要先开启 Notifyplus.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 设为 trueinterval 保持 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 拆包发送定义自己的帧协议来对抗 MTU20 字节限制让传输任何有意义的数据都变得需要分包设计。我给模拟项目 X 做温湿度记录仪的上位机时定义了一个最简单的帧协议帧头 1 字节 0xAA命令字 1 字节数据长度 1 字节数据段最长 15 字节尾部 1 字节 CRC 校验外设端按帧头聚合分包再解析。代码里的发送函数自动按 18 字节一段切分每段间隔 20msfunction 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 调用配上带标识的日志初始化成功、扫描启动、设备发现、连接成功、服务数量、特征值属性、写入结果、通知开关。每条日志带上时间戳和错误码。这样真机上一跑任何一步失败都能立刻定位到是哪一层出的问题。这套日志习惯让我少走很多弯路从那以后我每次重新搭蓝牙项目都会强制自己先花十分钟把日志框架搭好再写业务逻辑希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?