简介:这份压缩包围绕 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 项目没有构建步骤,页面就是 HTML,plus对象由容器注入到 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或writeNoResponse;UUID 统一大写再比;收发配对检查,写和通知是两条独立的特征值;加上串行队列和包间隔,先从 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 和分包间隔打印出来再谈功能,宁可前期多花半天,也不在上线后被“偶发丢包”追着跑。希望帮到你。
本文还有配套的精品资源,点击获取