资讯详情 HBuilderX蓝牙通讯Demo:BLE全链路时序与分包避坑
📅 2026/10/27 15:12:04
简介这份压缩包围绕 HBuilderX 下的 HTML5 蓝牙通信展开面向前端、移动端开发者及物联网入门者提供可直接参考的 Web Bluetooth 连接、状态监控与数据收发示例并附带 Android 原生蓝牙实现作为补充方案适合智能家居、健康监测等短距通信场景的学习与验证。包内共 258 个文件约 11.45MB以 java、xml、class 构成 Android 工程js、html 承载 H5 蓝牙逻辑另含 apk 样例、png/jpg 截图、pdf 说明及蓝牙模块资料便于对照软硬件配置。现有 5167 人学习。读者可从中梳理设备扫描、GATT 服务读写、连接异常处理与收发调试思路并借助原生源码理解底层控制方式快速搭建可运行的蓝牙通信原型。1. 一份“亲测可用”的 HBuilder 蓝牙通讯 demo真正值钱的是它踩过的时序标着 html5-bluetooth-demo 的包丢到群里十个人里九个先问“能不能搜到设备”搜到就复制走搜不到就换一份。真在产线上对接过 BLE 外设的人清楚HBuilder 里这套 H5 蓝牙通讯 API 只是一层薄壳壳下面 Android 的权限模型、iOS 的 UUID 规则、MTU 协商、连接时序才是决定成败的东西。这份 demo 的价值不在“能连上”而在它把扫描、连接、服务枚举、特征值读写、notify 监听这条链路完整摊开了一遍蓝牙传输源码该有的骨架都在。适合用 HBuilderX 打 App、需要在应用里对接手环、打印模块、传感采集盒的前端和全栈不适合指望纯浏览器 Web Bluetooth 一把梭的人那套 API 在移动端 WebView 里根本不存在。2. HBuilderX 工程结构与 H5 蓝牙 API 的能力边界先把工程摸清楚再谈功能。5 App 项目没有构建步骤页面就是 HTMLplus对象由容器注入到 WebView 里所以“代码写完没反应”这类问题八成不是蓝牙坏了而是调用时机或者权限声明没到位。下面按“看结构、看链路、看环境”三层拆。2.1 拿到源码先看三处manifest、plus 就绪、页面入口打开工程第一件事不是运行是看manifest.json。蓝牙在 5 里属于模块化能力模块没勾、权限没声明plus.bluetooth可能压根不存在或者一调就失败。常见写法是在app-plus.modules里挂上 Bluetooth 模块在app-plus.distribute.android.permissions里补齐权限数组。{ app-plus: { modules: { Bluetooth: {} }, distribute: { android: { permissions: [ uses-permission android:name\android.permission.BLUETOOTH\/, uses-permission android:name\android.permission.BLUETOOTH_ADMIN\/, uses-permission android:name\android.permission.BLUETOOTH_SCAN\ android:usesPermissionFlags\neverForLocation\/, uses-permission android:name\android.permission.BLUETOOTH_CONNECT\/, uses-permission android:name\android.permission.ACCESS_FINE_LOCATION\/, uses-permission android:name\android.permission.ACCESS_COARSE_LOCATION\/ ] }, ios: { plistcmds: [ Set :NSBluetoothAlwaysUsageDescription 需要蓝牙与设备通信 ] } } } }这段配置里前两条是老版本 Android 的蓝牙开关权限BLUETOOTH_SCAN和BLUETOOTH_CONNECT是 Android 12 之后拆出来的运行时权限不声明就扫不到任何东西。ACCESS_FINE_LOCATION是 Android 6 到 11 扫描 BLE 的硬门槛即使你的设备跟定位毫无关系也得给这是系统层的设计。iOS 侧的用途描述必须写缺了会在首次调用时静默失败连报错都不给你。第二处看plusready。5 的 API 在页面 load 完成时可能还没注入直接plus.bluetooth.xxx会抛plus is not defined。demo 里一般会有一段监听我习惯再加一层兜底判断避免热更新或者二次进入页面时事件已经错过。// 5 环境就绪后再碰蓝牙 API错过事件时用轮询兜底 function onPlusReady(cb) { if (window.plus) { cb(); return; } document.addEventListener(plusready, cb, false); // 某些容器里 plusready 可能早于脚本执行加一个短轮询兜底 var timer setInterval(function () { if (window.plus) { clearInterval(timer); cb(); } }, 100); } onPlusReady(function () { console.log(plus 就绪蓝牙能力:, !!plus.bluetooth); });plusready是容器注入完成的信号只触发一次轮询兜底是给“脚本加载晚于注入”这种场景准备的后悔药间隔 100ms、命中即清不会造成负担。第三处看页面入口是不是单页。蓝牙是全局状态多个页面各自调一次openBluetoothAdapter会产生重复监听回调里同一台设备被上报两三次列表直接乱掉。demo 通常只有一个页面所以这个问题被掩盖了搬到多页项目里立刻暴露。2.2 plus.bluetooth 的调用链路与原生层差异H5 的蓝牙 API 是对原生 BLE 接口的一层封装Android 底下走的是 GATT 客户端那一套iOS 走的是 CoreBluetooth。上层方法名一样行为差异不小把差异摸清比背 API 有用得多。能力项Android 表现iOS 表现落地注意deviceId格式通常是 MAC 地址系统分配的 UUID每次配对可能变不能硬编码按名称加服务重新匹配服务/特征值 UUID常见 16 位短 UUID 展开统一返回 128 位大写比较前统一toUpperCase()MTU可尝试setBLEMTU协商由系统协商应用层不可控发送前按 20 字节保守切扫描过滤支持按 service UUID 过滤同样支持但后台受限services参数一定要填后台扫描需要前台服务配合需要后台模式声明普通应用别惦记后台常驻deviceId这条差异是血泪经验。Android 上你把它当 MAC 存进数据库换到 iOS 上同一个设备拿到的是完全不同的字符串重连逻辑直接崩。稳妥做法是设备广播名加主服务 UUID 做业务主键deviceId只在本次会话内使用。2.3 最小可跑通的环境准备与真机调试链路是导入 demo → 改 manifest → 打自定义基座 → 真机运行 → 看日志。标准基座能跑大部分调试但只要改了权限声明必须重新制作自定义调试基座否则新权限不生效你会以为是代码问题。// 初始化适配器所有蓝牙操作的前置条件 plus.bluetooth.openBluetoothAdapter(function (res) { console.log(适配器已打开); // 打开后立刻查一次状态确认系统蓝牙真的是开的 plus.bluetooth.getBluetoothAdapterState(function (state) { console.log(available:, state.available, discovering:, state.discovering); }, function (err) { console.error(状态查询失败, JSON.stringify(err)); }); }, function (err) { // 常见用户关了系统蓝牙、没给权限、设备不支持 BLE console.error(适配器打开失败, err.code, err.message); });openBluetoothAdapter没有业务参数成败全看回调。失败时先看err.code适配器不可用基本等于系统蓝牙没开或者硬件不支持权限类失败要去系统设置里确认应用权限。getBluetoothAdapterState返回的available才是当前真实状态discovering用于判断是否已有扫描在跑避免重复调用startBluetoothDevicesDiscovery。调试期建议同时开两个窗口HBuilderX 的控制台看 JS 侧日志Android 上用adb logcat过滤蓝牙相关 tag 看原生层两边对时间戳能快速区分“上层没发出去”还是“底层被拒了”。3. 从搜设备到连上扫描、连接、发现服务的完整流程这条链路每一步都有超时和顺序要求跳步或者并行调用是新手最常见的翻车点。下面按调用顺序拆代码可直接抄进项目改。3.1 打开适配器与扫描参数扫描不是广播一开就完事参数决定你拿到的是干净列表还是一屏幕噪声。// 按主服务过滤扫描噪声能降一个数量级 var TARGET_SERVICE 0000FFE0-0000-1000-8000-00805F9B34FB; plus.bluetooth.startBluetoothDevicesDiscovery({ services: [TARGET_SERVICE], // 只上报广播里带该服务的设备 allowDuplicatesKey: false, // 同一设备只回调一次避免列表抖动 interval: 0, // 上报间隔0 表示每次发现都回调 success: function () { console.log(扫描已启动); }, fail: function (err) { console.error(扫描启动失败, err.code, err.message); } }); // 设备发现回调可能被高频触发内部别做重活 plus.bluetooth.onBluetoothDeviceFound(function (res) { (res.devices || []).forEach(function (dev) { console.log(发现, dev.name, dev.deviceId, RSSI, dev.RSSI); }); });services填目标设备广播的主服务 UUID是降噪最有效的一招不填会连空气里所有 BLE 设备一起上报列表刷得看不清。allowDuplicatesKey设 false 时同一台设备只上报一次适合做“选择设备”列表要做信号强度热力图才设 true。interval单位毫秒为 0 时来一条报一条功耗高正式版建议设 500 到 1000。注意onBluetoothDeviceFound是全局监听页面卸载要主动移除否则返回再进入会收到双份回调。扫描也不要一直开着找到目标就stopBluetoothDevicesDiscovery安卓上长时间扫描耗电非常明显。3.2 设备过滤与连接时序拿到设备列表后不要按顺序连第一个先按名称前缀和 RSSI 过滤。RSSI 高于 -70 基本可以认为在旁边低于 -85 连上了也容易断。function connectDevice(device, onReady) { // 连接前先停扫描扫描和连接并行会抢射频资源 plus.bluetooth.stopBluetoothDevicesDiscovery(); plus.bluetooth.createBLEConnection({ deviceId: device.deviceId, timeout: 12000, // 单位毫秒信道拥挤时 5 秒太短 success: function () { console.log(连接成功, device.deviceId); // 连接成功不等于可用必须等服务发现完成 plus.bluetooth.getBLEDeviceServices({ deviceId: device.deviceId, success: function (res) { var primary (res.services || []).filter(function (s) { return s.isPrimary; }); onReady(device.deviceId, primary); }, fail: function (err) { console.error(服务发现失败, err.code, err.message); } }); }, fail: function (err) { console.error(连接失败, err.code, err.message); } }); }timeout建议 10 到 15 秒。很多 demo 给 5 秒实验室里够用现场设备多、2.4G 拥挤时就频繁超时然后被误判成“设备坏了”。getBLEDeviceServices只返回服务特征值要再调一次两步不能合并。连接还有个隐藏前提同一台设备在系统里只能有一个 GATT 连接。上一次的连接没closeBLEConnection就重连Android 上大概率直接失败报的错还跟“设备不存在”长得差不多很容易查错方向。3.3 服务与特征值枚举怎么找出可写可通知的那个服务列表出来之后逐个查特征值按properties里的标志位挑。业务上通常需要两个一个可写用于下发指令一个可 notify 用于接收上报。有时候是同一个特征值兼顾读写和通知。function pickCharacteristics(deviceId, services, done) { var result { write: null, notify: null }; var pending services.length; services.forEach(function (svc) { plus.bluetooth.getBLEDeviceCharacteristics({ deviceId: deviceId, serviceId: svc.uuid, success: function (res) { (res.characteristics || []).forEach(function (c) { var p c.properties || {}; // 优先无响应写吞吐高没有再退到有响应写 if (!result.write (p.writeNoResponse || p.write)) { result.write { serviceId: svc.uuid, characteristicId: c.uuid, withResponse: !!p.write }; } if (!result.notify (p.notify || p.indicate)) { result.notify { serviceId: svc.uuid, characteristicId: c.uuid }; } }); }, complete: function () { if (--pending 0) done(result); } }); }); }properties里的字段含义要分清write是有响应写每次写完设备回一个确认稳但慢writeNoResponse是无响应写快但丢了没人告诉你notify是设备主动推indicate是带确认的推。批量下发指令优先用writeNoResponse关键配置用write。UUID 比较前统一toUpperCase()Android 返回小写、iOS 返回大写的坑踩过一次就记住了。4. 数据收发ArrayBuffer 编解码与分包写入到这一步链路是通的真正决定体验的是数据层。H5 的读写接口只认ArrayBuffer字符串得自己转超过 MTU 的报文得自己切notify 回来的半包得自己拼。这三件事不做功能看着能用一上量就出问题。4.1 字符串与 ArrayBuffer 双向转换的三种写法按设备协议选纯 ASCII 指令用字符码直转最省事带中文或者变长字段走 UTF-8二进制协议直接按 HEX 处理。// 1) ASCII 指令一字符一字节最简单 function strToBufferAscii(str) { var buf new ArrayBuffer(str.length); var view new Uint8Array(buf); for (var i 0; i str.length; i) { view[i] str.charCodeAt(i) 0xff; } return buf; } // 2) ArrayBuffer 转 HEX 字符串调试打印必备 function bufferToHex(buffer) { var bytes new Uint8Array(buffer); var out []; for (var i 0; i bytes.length; i) { out.push((00 bytes[i].toString(16)).slice(-2)); } return out.join( ); } // 3) HEX 字符串转 ArrayBuffer用于构造二进制指令 function hexToBuffer(hex) { var clean hex.replace(/\s/g, ); var len clean.length / 2; var buf new ArrayBuffer(len); var view new Uint8Array(buf); for (var i 0; i len; i) { view[i] parseInt(clean.substr(i * 2, 2), 16); } return buf; }strToBufferAscii用charCodeAt 0xff截断只对 ASCII 安全中文会乱码涉及中文必须走TextEncoder或者手写 UTF-8 编码。bufferToHex补齐两位是必须的否则0x0A会打成a日志对不上协议文档排查时能气死人。hexToBuffer里先剔空格再按两位切是给“协议文档里带空格”的场景准备的。4.2 writeBLECharacteristicValue 的分包与间隔控制BLE 默认 ATT MTU 是 23扣掉 3 字节头有效载荷 20 字节。超过就得切。部分 Android 机型支持setBLEMTU协商到 100 以上但不是所有固件都认协商完还要用实际返回值确认。var CHUNK_SIZE 20; // 保守值协商成功后可以放大 var queue []; var sending false; function enqueue(buffer) { var bytes new Uint8Array(buffer); for (var i 0; i bytes.length; i CHUNK_SIZE) { queue.push(bytes.slice(i, i CHUNK_SIZE)); } drain(); } function drain() { if (sending || queue.length 0) return; sending true; var chunk queue.shift(); plus.bluetooth.writeBLECharacteristicValue({ deviceId: current.deviceId, serviceId: current.write.serviceId, characteristicId: current.write.characteristicId, value: chunk.buffer, success: function () { sending false; // 包间隔无响应写必须留时间否则底层缓冲溢出丢包 setTimeout(drain, 30); }, fail: function (err) { sending false; console.error(写入失败, err.code, err.message); queue.unshift(chunk); // 失败回队等下次重试 setTimeout(drain, 200); } }); }这段是串行队列一次只有一包在飞成功再发下一包。CHUNK_SIZE保持 20 是保守策略确认设备支持大 MTU 再改。包间隔 30ms 是经验值writeNoResponse模式下安卓底层缓冲有限连着灌几十包必丢丢的还都是中间段现象是设备收到的数据缺一块特别难查。失败回队加 200ms 退避避免连接刚断时疯狂重试把链路彻底打死。提示分包大小和间隔最终要以设备固件为准协议文档写支持 200 字节不代表 BLE 层能一次发完先小后大逐步试。4.3 notify 监听与粘包处理设备上报走 notify回调里拿到的是ArrayBuffer片段。BLE 不保证一次回调就是一帧完整报文短报文可能两帧粘在一起长报文可能被拆成三段。var rxBuffer []; // 收包缓冲按字节累积 plus.bluetooth.notifyBLECharacteristicValueChange({ deviceId: current.deviceId, serviceId: current.notify.serviceId, characteristicId: current.notify.characteristicId, state: true, // true 开启通知false 关闭 success: function () { console.log(notify 已开启); } }); plus.bluetooth.onBLECharacteristicValueChange(function (res) { var bytes new Uint8Array(res.value); for (var i 0; i bytes.length; i) rxBuffer.push(bytes[i]); // 协议约定帧头 0xAA 0x55 长度字节 载荷 校验 while (rxBuffer.length 4) { if (rxBuffer[0] ! 0xAA || rxBuffer[1] ! 0x55) { rxBuffer.shift(); // 失步丢一字节重新找帧头 continue; } var len rxBuffer[2]; if (rxBuffer.length 3 len 1) break; // 半包等下一批 var frame rxBuffer.slice(3, 3 len); var checksum rxBuffer[3 len]; rxBuffer rxBuffer.slice(4 len); // 校验通过才交给业务层 if ((frame.reduce(function (a, b) { return a b; }, 0) 0xff) checksum) { handleFrame(frame); } } });state: true是开启通知开启后设备才往你这里推数据只写不读是很多 demo 漏掉的一步。缓冲用普通数组按字节累积while循环里先找帧头再判长度长度不够就break等下一批够就切帧、校验、交业务。失步时shift()丢一个字节重找比整段清空更稳能少丢一帧。5. 避坑与排查搜不到、连不上、写不进、断得莫名其妙蓝牙调试的痛苦在于失败现象高度相似原因散落在权限、系统、固件三层。下面这几条是复现频率最高的按阶段分组每条给出现象、原因、解决。5.1 扫描阶段设备列表永远是空的现象startBluetoothDevicesDiscovery返回成功回调一次都不触发列表空白。原因三种可能。Android 12 以上没声明BLUETOOTH_SCAN运行时权限Android 6 到 11 没给定位权限或者系统定位开关被关了这是系统级门槛关掉定位扫描直接返回空services填的 UUID 跟设备广播的服务对不上过滤条件把所有设备都筛掉了。解决先不带services扫一轮能扫到说明过滤条件写错了扫不到就去系统设置看应用权限和全局定位开关。Android 12 的运行时权限除了 manifest 声明还得在首次使用时弹窗申请只声明不申请等于没有。5.2 连接阶段连上就断、133 与超时现象createBLEConnection成功回调刚触发紧接着onBLEConnectionStateChange就报断开或者干脆一直超时。原因上一轮会话没释放设备侧还挂着旧连接新的建链被拒同一台设备被别的 App 占着BLE 外设多数只支持单连接timeout设太短现场信道拥挤时 5 秒不够握手Android 上连续快速重连会触发协议栈的冷却期。解决断开时务必调closeBLEConnection重连前留 500ms 以上间隔确认没有其他 App 占用timeout拉到 12 到 15 秒失败重试做指数退避第一次 1 秒、第二次 3 秒不要死循环硬刚。5.3 收发阶段写成功但设备没反应、连续写丢包现象writeBLECharacteristicValue每次返回 success设备行为毫无变化或者单包正常连发十几包后设备收到的数据缺段。原因写到了只有read权限的特征值上接口层不校验权限返回成功但数据被丢UUID 大小写不一致导致匹配到了另一个特征值开了写没开 notify设备靠 notify 回执你收不到无响应写没有包间隔底层缓冲溢出。解决打印properties确认目标特征值真的带write或writeNoResponseUUID 统一大写再比收发配对检查写和通知是两条独立的特征值加上串行队列和包间隔先从 50ms 试起稳定了再压。5.4 跨平台iOS 的 UUID 与后台限制现象Android 上跑得好好的重连逻辑到 iOS 上第一次能连杀进程重进就找不到设备。原因iOS 的deviceId是系统分配的 UUID不是硬件地址重新配对或者系统清理后可能变按deviceId缓存必然失配。另外 iOS 后台对 BLE 扫描有强限制App 切后台后扫描会被挂起。解决业务层用广播名加主服务 UUID 做主键每次重连重新扫描匹配不要指望后台常驻扫描把连接维持和重连逻辑放在前台生命周期里切后台前主动收尾。6. 进阶把 demo 改成能长期跑的蓝牙模块demo 跑通只是起点真上线要解决的是“连续跑八小时不出事”。我的做法是把散落的回调收敛成一个显式状态机状态只有五个空闲、扫描中、连接中、就绪、断开。每次状态迁移打一条带时间戳的日志出问题时按时间线一拉就知道卡在哪一步。这张表是我项目里直接用的迁移规则。当前状态触发事件下一状态附带动作空闲用户点连接扫描中开适配器、起扫描、8 秒超时扫描中命中目标设备连接中停扫描、建链、15 秒超时连接中建链成功就绪枚举服务、开 notify就绪连接状态变更断开断开清队列、清缓冲、起退避重连断开退避到点扫描中重扫不按旧 deviceId 直连状态机的价值在于禁止非法跳转。比如“连接中”不允许再触发扫描“就绪”状态收到重复的连接请求直接忽略这样能挡掉大部分并发引发的玄学问题。重连我坚持走“重扫再连”而不是拿旧deviceId直连多花两秒但把 iOS UUID 变化和 Android 地址复用两类问题一次挡掉。验证环节我只信三样东西连续 200 次分包写入的丢包率、8 小时挂机后的内存占用、以及断连重连 50 次的成功率。前两个看稳定性最后一个看恢复能力。丢包率用序号字段自校验每包带一个自增 ID设备侧回传缺失区间比肉眼看日志靠谱得多。从那以后我每次接新外设都先把日志开关打开、把 MTU 和分包间隔打印出来再谈功能宁可前期多花半天也不在上线后被“偶发丢包”追着跑。希望帮到你。本文还有配套的精品资源点击获取