简介:面向使用HBuilderX进行H5蓝牙开发的初、中级开发者,这份实战示例聚焦Web Bluetooth API与蓝牙设备之间的连接、状态监听和数据收发,并附Android原生蓝牙实现作为补充方案。包内共258个文件,以java与class源码、xml布局与配置、png截图、js脚本为主,另有apk安装包、gradle构建文件以及MLT-BT05蓝牙模块资料,整体约11.45MB,兼顾前端示例、原生工程与硬件调试参考。已有5167人学习下载,说明其在H5蓝牙入门与联调场景中具备一定参考价值。读者可获得可运行的蓝牙连接与收发示例、设备扫描和GATT服务操作的实现思路,以及Web端与Android原生两条技术路径的对照,便于快速搭建自己的蓝牙通信原型并排查常见连接异常。
1. 从「网页连不上蓝牙」说起:H5 蓝牙通讯方案的适用场景与选型判断
做过 H5 项目的人大多遇到过一个尴尬场景:页面在手机浏览器里跑得好好的,一到「连接蓝牙打印机/电子秤/手环」这一步就卡住——Web Bluetooth 在部分安卓 WebView 里支持得不完整,iOS 侧限制更多,调试时几乎是个黑匣子。这套基于 HBuilderX 的蓝牙通讯 Demo 就是冲着这个痛点来的:用 HTML5+ 的原生蓝牙扩展能力,绕开浏览器层的不确定性,把「搜索设备 → 连接 → 读服务 → 收发数据」这条链路完整跑通。它适合三类人:正在做物联网方向毕设、需要真实设备联调的学生;要交课程设计、想找一个能演示的硬件交互案例的读者;以及手上有个 H5 项目、临时要接蓝牙外设但不想重写原生 App 的开发者。蓝牙传输源码这类资源网上一搜一大把,真正亲测可用、结构清晰的并不多,下面按需求、设计、实现、验收一路拆开讲。
2. 需求拆解与技术选型:为什么不是纯 Web Bluetooth,而是 HBuilderX + HTML5+
2.1 三类典型业务场景对蓝牙能力的最低要求
把「蓝牙通讯」这四个字落到具体项目里,需求其实差别很大。第一类是数据采集型,比如接蓝牙电子秤、温湿度传感器,特征是低频、小数据包、以读取为主;第二类是命令控制型,比如蓝牙打印机、继电器模块,特征是发送指令后要等回执,对时序敏感;第三类是双向交互型,比如体脂秤、手环,需要先订阅特征值通知(notify),设备主动推数据上来。这三类的共同底线是:能扫描到设备、能建立连接、能枚举出服务和特征值、能读写或订阅。任何一环缺失,Demo 就只是个摆设。
真正容易出问题的是第四点。很多示例代码只演示到「连接成功」,日志打一句 connected 就结束了,读者拿去接自己的设备时完全不知道该读写哪个 UUID。所以一份合格的蓝牙通讯源码,必须把「获取服务列表 → 获取特征值列表 → 按属性分流处理」这段完整暴露出来,让人能照着日志替换成自己设备的 UUID。
选型上还有一层现实约束:项目交付周期。毕设或课设通常只有几周到两个月,如果从零写原生安卓蓝牙代码,光是权限适配和线程处理就能吃掉大半时间,更别说还要做界面。这就是为什么「H5 + 蓝牙」的组合在学生项目里出现频率很高——界面用熟悉的 HTML/CSS 写,硬件交互交给 HTML5+ 的 plus.bluetooth 接口,一套代码还能打包成安卓包直接装到手机上演示。
2.2 HBuilderX 打包链路与 HTML5+ 蓝牙接口的能力边界
选 HBuilderX 的核心理由是它把「写 H5」和「调原生能力」缝在了一起。项目在 HBuilderX 里以 5+App 或 uni-app 的形式创建,运行时通过 plus 对象访问原生模块,打包成 apk 后在真机上跑,蓝牙权限由 manifest 配置声明,不依赖浏览器沙箱。这条链路对需要交付可安装成果的场景非常友好:答辩现场直接装包演示,比开个浏览器地址稳得多。
能力边界也要说清楚。plus.bluetooth 提供的是经典蓝牙与 BLE 的封装,覆盖打开/关闭适配器、扫描、连接、发现服务与特征值、读写数据、监听通知这些核心动作,但它不是万能的:部分厂商对 BLE 的 MTU 有额外限制,分包逻辑得自己写;iOS 侧打包需要证书配置,通常的做法是先用安卓真机把逻辑验证完,再考虑双端。此外,plus 对象只在 5+App 运行环境中存在,直接在普通浏览器打开页面会报 plus is not defined,这一点几乎每个新手都会踩一次,后面避坑章节会详细说。
提示:判断一个 H5 蓝牙项目能不能用,第一件事不是看代码长短,而是看它有没有做「运行环境检测」和「蓝牙适配器状态检测」,缺这两步的示例基本跑不通。
2.3 技术栈组合与工程目录约定
这套方案的技术栈非常轻:HTML/CSS/JavaScript 负责界面与逻辑,HTML5+ 提供 plus.bluetooth 原生桥接,HBuilderX 负责编辑、真机运行和打包。没有引入框架,好处是零构建、改完直接跑,缺点是代码组织要靠自觉。我一般会按下面的方式切文件,避免所有逻辑堆在一个页面里:
bluetooth-demo/ ├── index.html # 主界面:设备列表、连接状态、收发日志 ├── css/ │ └── style.css # 列表、按钮、日志区域的样式 ├── js/ │ ├── plusReady.js # 监听 plusready,做环境与权限预检 │ ├── btManager.js # 蓝牙核心:初始化、扫描、连接 │ ├── btTransfer.js # 数据读写、notify 订阅、分包处理 │ └── logger.js # 统一日志输出,方便现场排查 ├── manifest.json # 应用配置:蓝牙权限、包名、图标 └── unpackage/ # 打包产物目录(由 HBuilderX 生成)这个结构看似简单,但每层职责很明确:plusReady.js 只关心「环境好了没」,btManager.js 只关心「设备连上了没」,btTransfer.js 只关心「数据通了没」。现场调试时,日志里能一眼看出卡在哪一层,比把所有代码写在 index.html 里强太多。manifest.json 里必须勾选蓝牙相关权限,否则打包后会出现在真机上扫描不到任何设备的情况,而且不报错——这是最耗时间的一种失败。
3. 总体设计:状态机、模块划分与数据流
3.1 蓝牙连接状态机:六个状态与合法迁移路径
蓝牙操作本质是一个异步、易失败、失败原因隐蔽的过程。用「点一下按钮就连接」的直线思维写代码,十有八九会翻车。更稳的做法是把整个流程抽象成一个状态机,界面渲染和按钮可用性全部由状态驱动:
| 状态 | 触发动作 | 界面表现 | 失败后的处理 |
|---|---|---|---|
| IDLE | 初始化完成 | 「开始搜索」可点 | 提示检查蓝牙开关 |
| SCANNING | 调用 startBluetoothDevicesDiscovery | 设备列表滚动刷新 | 超时 15 秒自动停止 |
| FOUND | 扫描到目标设备 | 列表项高亮可点 | — |
| CONNECTING | 调用 createBLEConnection | 按钮转圈禁用 | 10 秒超时即断开重试 |
| CONNECTED | 连接成功,枚举服务 | 显示服务与特征值 | 无目标服务则主动断开 |
| READY | 订阅 notify 或准备读写 | 输入框和发送键启用 | 断连后回退到 IDLE |
状态机的好处是把「现在能不能点」这个问题变成查表,而不是散落在各处的 if 判断。尤其是断连回调:BLE 在信号弱时会毫无征兆地掉线,如果不在 onBLEConnectionStateChange 里把状态打回 IDLE 并清理已订阅的特征值,用户点发送按钮会一直卡在无响应,看起来像代码坏了,其实只是少了一个状态回退。
3.2 模块划分:环境层、设备层、传输层、展示层的职责边界
四个模块的分工要卡死。环境层(plusReady.js)负责等待 plusready 事件、检测蓝牙是否开启、申请定位权限——安卓 6.0 以上扫描 BLE 设备必须开定位权限,这条是硬性要求,很多示例漏了它,导致扫描结果永远为空。设备层(btManager.js)只管扫描、连接、断开、枚举服务,不处理任何业务数据。传输层(btTransfer.js)负责字符串与 ArrayBuffer 的互转、写入、notify 订阅、粘包拆分。展示层只做渲染,不写业务逻辑。
这样切分之后,换设备只需要改设备层里的 UUID 常量,换业务协议只需要改传输层的编解码函数,互不干扰。我见过不少同学把所有代码写在一个 script 标签里,最后调 UUID 时要在八百行里翻找,血泪经验。
3.3 数据流向:一次完整的「发指令—收回执」链路
把一次典型交互画清楚,后面的代码才有落脚点。以向蓝牙模块发送一条查询指令为例,数据流是这样的:用户在输入框输入指令 → 展示层调用传输层的 send 方法 → 传输层把字符串按约定编码成 ArrayBuffer → 调用 plus.bluetooth.writeBLECharacteristicValue 写入指定特征值 → 设备处理后通过 notify 回推数据 → 传输层的监听回调收到 ArrayBuffer → 解码成字符串 → 交给展示层追加到日志区。
其中有两个关键点。一是写入前必须确认目标特征值支持 write 属性,否则接口直接失败且错误码含糊;二是 notify 必须在连接后显式调用 notifyBLECharacteristicValueChange 开启,只连接不订阅,是收不到任何回推数据的。这条链路跑通一次,后面所有业务都是在这个骨架上换协议,价值远大于一个只能点亮的 Demo。
4. 核心实现:扫描连接、数据收发与界面联调
4.1 环境预检与权限初始化代码
所有蓝牙逻辑必须等 plusready 之后再执行,这是整个项目的地基。下面这段做三件事:等环境、查蓝牙开关、查定位权限:
// plusReady.js —— 环境预检,任何蓝牙动作之前必须先跑这段 document.addEventListener('plusready', function () { // 1. 检查设备蓝牙是否已开启 plus.bluetooth.getBluetoothAdapterState({ success: function (state) { if (!state.available) { console.warn('蓝牙未开启,请先在系统设置中打开'); plus.bluetooth.openBluetoothAdapter({ success: function () { console.log('适配器已打开'); }, fail: function (e) { console.error('打开适配器失败', e); } }); } }, fail: function (e) { console.error('获取适配器状态失败', e); } }); // 2. 安卓扫描 BLE 需要定位权限,这里做一次兜底申请 plus.android.requestPermissions( ['android.permission.ACCESS_FINE_LOCATION'], function (res) { console.log('权限申请结果:', res.granted); }, function (err) { console.error('权限申请异常', err); } ); }, false);参数上要留意两点:openBluetoothAdapter 是后续所有接口的前置条件,没打开就直接扫描一定失败;requestPermissions 只在安卓上有效,iOS 侧这段会走进 fail 回调,属于正常现象,不要当成 bug 去改。另外这段代码注册在 document 上而不是 window 上,是为了在页面 DOM 就绪前后都能捕获到事件,实践中事件只会触发一次。如果真机上扫描列表一直空白,排查顺序就是:plusready 是否触发 → 适配器是否 available → 定位权限是否授予,按这个顺序查,九成问题能定位。
4.2 扫描与连接:设备过滤与超时控制
扫描不能放任不管,必须有过滤和超时,否则列表会刷出一堆无关设备,演示时非常难看:
// btManager.js —— 扫描、过滤、连接 var TARGET_NAME_PREFIX = 'BT-'; // 按自己设备名改,避免列表被无关设备刷屏 var scanTimer = null; function startScan() { plus.bluetooth.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, // 同一设备只上报一次,减少列表抖动 success: function () { console.log('开始扫描'); // 兜底超时:15 秒还没找到目标就自动停,防止一直耗电 scanTimer = setTimeout(stopScan, 15000); }, fail: function (e) { console.error('扫描启动失败', e); } }); // 监听新设备 plus.bluetooth.onBluetoothDeviceFound(function (res) { res.devices.forEach(function (dev) { if (dev.name && dev.name.indexOf(TARGET_NAME_PREFIX) === 0) { renderDevice(dev); // 交给展示层渲染 } }); }); } function stopScan() { clearTimeout(scanTimer); plus.bluetooth.stopBluetoothDevicesDiscovery({ success: function () { console.log('扫描已停止'); } }); } function connectDevice(deviceId) { stopScan(); // 连接前先停扫描,否则部分机型连接成功率明显下降 plus.bluetooth.createBLEConnection({ deviceId: deviceId, timeout: 10000, success: function () { console.log('连接成功,开始枚举服务'); discoverServices(deviceId); }, fail: function (e) { console.error('连接失败', e); } }); }这段里最值得抄的是「连接前先停扫描」这一行。不停扫描直接连接,在部分安卓机型上会稳定失败或超时,日志里只有一个模糊的错误码,非常难查。allowDuplicatesKey 设为 false 也是同理,重复上报会让界面列表反复重排,用户体验很差。超时时间 10000 毫秒是经验值,信号环境差的现场可以放宽到 15000,但不要无限等——没有超时的异步调用在现场演示时就是灾难。
4.3 服务与特征值枚举:找到能读、能写、能订阅的那三个 UUID
连接成功后,必须把服务列表和特征值列表打出来,这是整个调试过程中信息量最大的一步:
// btManager.js —— 枚举服务与特征值,按属性分流 var serviceId = '', writeCharId = '', notifyCharId = ''; function discoverServices(deviceId) { plus.bluetooth.getBLEDeviceServices({ deviceId: deviceId, success: function (res) { res.services.forEach(function (svc) { console.log('服务 UUID:', svc.uuid, '是否主服务:', svc.isPrimary); // 通常只处理主服务,从服务里很少挂载可读写特征值 if (!svc.isPrimary) return; getCharacteristics(deviceId, svc.uuid); }); } }); } function getCharacteristics(deviceId, uuid) { plus.bluetooth.getBLEDeviceCharacteristics({ deviceId: deviceId, serviceId: uuid, success: function (res) { res.characteristics.forEach(function (ch) { var p = ch.properties; // 按属性分流:可写、可通知的分开记录 if (p.write || p.writeNoResponse) { writeCharId = ch.uuid; serviceId = uuid; } if (p.notify || p.indicate) { notifyCharId = ch.uuid; } console.log('特征值:', ch.uuid, 'read:', p.read, 'write:', p.write, 'notify:', p.notify); }); if (writeCharId && notifyCharId) enableNotify(deviceId); } }); }这里的核心设计是「按 properties 分流」。同一个服务下往往有好几个特征值,读、写、通知各用一个,随便挑一个去写必然失败。把 writeCharId 和 notifyCharId 分别记下来,后面发送和接收各走各的通道,逻辑才清晰。日志里把 read/write/notify 三个布尔值全打出来,是因为不同厂商设备的属性组合差异很大,有的写入要带响应,有的只能无响应写,看日志比看文档快。拿到这两个 UUID 之后,把它们写成常量,就完成了从「通用 Demo」到「你的项目」的关键一步。
4.4 数据收发:字符串与 ArrayBuffer 的编码、分包与日志回显
蓝牙底层传的是字节,界面上是字符串,中间的编解码和分包是实际项目里最容易出问题的地方:
// btTransfer.js —— 发送、订阅、接收解码 function str2ab(str) { var buf = new ArrayBuffer(str.length); var view = new DataView(buf); for (var i = 0; i < str.length; i++) { view.setUint8(i, str.charCodeAt(i) & 0xff); // 单字节编码,按设备协议可换成 GBK/UTF-8 } return buf; } function ab2str(buf) { return Array.prototype.map.call(new Uint8Array(buf), function (b) { return String.fromCharCode(b); }).join(''); } function sendData(deviceId, text) { plus.bluetooth.writeBLECharacteristicValue({ deviceId: deviceId, serviceId: serviceId, characteristicId: writeCharId, value: str2ab(text), success: function () { log('发送成功:' + text); }, fail: function (e) { log('发送失败:' + JSON.stringify(e)); } }); } function enableNotify(deviceId) { plus.bluetooth.notifyBLECharacteristicValueChange({ deviceId: deviceId, serviceId: serviceId, characteristicId: notifyCharId, state: true, success: function () { log('通知已开启,等待设备回传'); } }); // 收到设备数据 plus.bluetooth.onBLECharacteristicValueChange(function (res) { log('收到:' + ab2str(res.value)); // 若单包超过约 20 字节,需在此处做粘包/分包拼接 }); }三个参数说明必须讲透:value 必须是 ArrayBuffer,传字符串会直接失败;单次写入长度受 MTU 限制,经典值是 20 字节,超长指令要自己按协议切段并在段间加延时;ab2str 这里用的是单字节映射,能覆盖绝大多数 ASCII 指令场景,如果设备回传的是中文或 UTF-8 内容,需要换成 TextDecoder 或对应的解码函数。日志回显不要省,收发两个方向都打日志,是后面排查「发出去了但没反应」的唯一依据。
4.5 界面与状态绑定:让按钮跟着状态走
界面层的工作量不大,但设计上要有意识:所有按钮的可用性由 3.1 的状态机决定,而不是用户想点就点。搜索中禁用连接按钮,未连接禁用发送输入框,断连时自动清空已订阅状态并提示重连。日志区用一个固定高度的滚动容器,最新一条自动置底,现场演示时这个区域就是你的「答辩证据」。设备列表项建议显示设备名加信号强度,因为很多模块出厂名相同,光看名字分不清是哪一个,加个 RSSI 能省下大量试错时间。
5. 避坑与常见问题排查:真机上最容易翻车的五个点
5.1 现象:代码在浏览器里跑,报 plus is not defined
原因很明确:plus 对象只存在于 5+App 运行环境中,普通浏览器没有这个注入。解决方式是开发阶段就用 HBuilderX 的「运行到手机」或「真机运行」功能,不要图省事用浏览器预览蓝牙相关页面;同时在代码入口做一次环境判断,把提示写清楚,避免后续接手的人重复困惑。
5.2 现象:扫描能跑,但设备列表永远是空的
按概率排,第一位是定位权限没给——安卓上扫描 BLE 强制要求定位权限,且部分机型还要求打开系统定位开关,两个条件缺一不可。第二位是蓝牙适配器没打开就调用了扫描接口。第三位是设备本身没进入广播状态,有些模块需要先按一下配对键才开始广播。排查顺序固定为:适配器状态 → 定位权限与开关 → 设备是否在广播,基本能覆盖。
5.3 现象:连接成功但一写数据就失败
八成是特征值选错了。同一个服务下有好几个特征值,把 read 的那个拿去 write,接口只会返回错误码,不会告诉你原因。回到 4.3 的日志,确认写入的那个特征值 properties 里 write 或 writeNoResponse 为 true。另外要注意 serviceId 和 characteristicId 必须成对匹配,用了 A 服务的特征值配 B 服务的 UUID,同样失败。
5.4 现象:设备有回传,但界面收不到
最常见的原因是只连接没订阅——notifyBLECharacteristicValueChange 没调用,或者调用时 state 传了 false。其次是订阅的时机太早,必须在 getBLEDeviceCharacteristics 成功回调之后才能订阅,提前订阅会因为服务还没枚举完而失效。还有一种隐蔽情况是断连重连后没有重新订阅,状态机回到 IDLE 时要把订阅标记一并清掉,重连后重走一遍。
5.5 现象:短指令正常,长指令发一半设备就没反应
这是典型的分包问题。BLE 单次写入长度受 MTU 限制,一次塞几十上百字节,超出部分会被截断或直接失败。处理方式是按设备协议把长指令切成不超过 20 字节的片段,逐段写入,段与段之间加 20~50 毫秒延时,全部发完再等下一条。如果设备支持 MTU 协商,可以尝试申请更大值,但不同机型行为不一致,稳妥的做法还是按最小公约数分包。
6. 验收与答辩:怎么证明这套蓝牙链路是真的通了
验收这件事,最忌讳的是「我说通了」。蓝牙项目的验收必须建立在可复现的证据上,我一般按三步走。第一步是链路自证:准备一份固定的测试指令表,每条指令的发送内容、预期回传、实际回传都记录在文档里,答辩现场按表逐条演示,评委看到的是「输入 A 得到 B」的确定性,而不是随机点几下。第二步是异常演示:主动关掉设备电源,展示界面能在一两秒内提示断连并回到可重连状态;主动拒绝定位权限,展示有明确的提示文案。能优雅处理异常的系统,说服力远高于只演示顺利路径的系统。
第三步是把调试日志变成答辩材料。4.4 里的日志区不只是给开发者看的,整理成表格就是一份完整的交互记录:
| 序号 | 操作 | 发送内容 | 设备回传 | 耗时 | 结论 |
|---|---|---|---|---|---|
| 1 | 扫描并连接 | — | 连接成功,枚举到 3 个服务 | 约 3s | 链路建立 |
| 2 | 订阅通知 | notify=true | 订阅成功 | <1s | 接收通道就绪 |
| 3 | 发送查询指令 | AT+INFO | 设备型号与版本 | 约 200ms | 收发正常 |
| 4 | 发送长指令(48 字节) | 按 20 字节分 3 段 | 完整回执 | 约 500ms | 分包逻辑有效 |
| 5 | 断开设备电源 | — | 界面提示断连并可重连 | 约 1.5s | 异常处理有效 |
表格里第 4 行和第 5 行是最能加分的两行,因为它们证明的不是「能跑」,而是「考虑过边界」。答辩提问高频集中在三处:为什么不用纯 Web Bluetooth(答环境兼容与原生桥接)、UUID 是怎么确定的(答从枚举日志里按属性筛出来的)、断连了怎么办(答状态机回退加重新订阅)。这三个问题提前准备好,基本不会被问住。
从我自己带项目的习惯讲,每次交付前我都会强制走一遍「换一台没调试过的手机、关掉定位权限、先不接设备」的冷启动流程,因为绝大多数翻车都发生在第一次安装而不是开发机上。这套 Demo 的价值不在于代码多复杂,而在于它把扫描、连接、枚举、订阅、分包、异常这六个环节都摆在了明面上,你可以照着改 UUID 和协议,直接变成自己毕设里的硬件交互模块。希望帮到你。
本文还有配套的精品资源,点击获取