☰
HBuilderX+HTML5+实现H5蓝牙通讯Demo:扫描连接与数据收发
2026/10/27 22:27:15 网站建设 项目流程

简介:面向使用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 和协议,直接变成自己毕设里的硬件交互模块。希望帮到你。

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

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

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

立即咨询