☰
HBuilderX+HTML5+ BLE蓝牙通讯实战:扫描、收发与断线重连
2026/10/27 22:27:06 网站建设 项目流程

简介:这份资源围绕HBuilderX环境下HTML5蓝牙通信的落地实现展开,面向具备一定前端基础、希望在Web或混合App中接入蓝牙设备的开发者。内容既包含可直接运行的H5蓝牙示例工程,也附带Android原生蓝牙实现的完整源码,可解决Web Bluetooth API在部分设备上兼容性不足、连接稳定性欠佳的问题,适用于健康监测、智能家居控制、硬件透传调试等场景。压缩包共258个文件,约11.45MB,以java、class、xml等Android工程文件与js脚本为主体,另有png截图、gradle构建配置、apk安装包及pdf模块文档,并附MLT-BT05蓝牙模块资料与串口调试相关内容,便于软硬件对照。目前已有5167人学习下载。读者可获得蓝牙连接管理、状态监听、数据收发与异常处理的完整参考实现,也能通过原生源码了解BluetoothAdapter、BluetoothGatt的底层调用方式,是排查兼容性问题、快速搭建联调环境的实用起点。

1. 先把需求摆到桌面上:为什么这套蓝牙通讯要在 HBuilderX 里跑

我接到的第一个蓝牙需求很简单:一块带串口透传的蓝牙模块,手机扫到它、连上、下发一条十几字节的指令、再把模块回的数据显示出来。团队一开始想走纯 Web 的 Web Bluetooth,结果在真机浏览器和内置 WebView 里都调不通,兼容性直接卡死。换成在 HBuilderX 里用 HTML5+ 的 plus.bluetooth 打包成 App,才拿到一条稳定的原生通道,这套 html5-bluetooth-demo 就是把这个过程固化下来的产物:一份能直接跑通的蓝牙通讯骨架,覆盖扫描、连接、服务枚举、收发、断线重连。它适合两类人——正在做物联网配套 App 毕设的学生,和要给硬件配一个调试端、又不想从零啃原生 Android 的开发者。

2. 技术选型:H5+ 蓝牙 API 与 BLE、经典蓝牙两条路线的分工

在动手写第一行代码之前,有一个问题必须先定下来:到底走 BLE(低功耗蓝牙)还是经典蓝牙的 RFCOMM 通道。这个决定会直接改变后面的代码结构,改起来成本很高,比调一个 bug 麻烦得多。

2.1 为什么在 HBuilderX 里选 HTML5+ 而不是纯 Web 方案

纯浏览器里的 Web Bluetooth 有三个硬约束:必须 HTTPS 或 localhost、必须由用户手势触发、设备必须广播标准 GATT 服务。真实项目里这三条经常同时不满足——内网调试没有证书、页面加载完自动扫描不生效、硬件模块用的是自定义服务 UUID。更麻烦的是各家 WebView 内核对 Web Bluetooth 的支持程度不一致,同一份代码在不同机型上表现完全不同,属于典型的玄学问题。

HTML5+ 的 plus.bluetooth 是走原生桥接的,运行在 HBuilderX 打包出来的 App 里,不受浏览器安全上下文限制,也不需要用户点一下才能扫。它提供两套接口:BLE 那一套(openBluetoothAdapter / startBluetoothDevicesDiscovery / createBLEConnection / getBLEDeviceServices / writeBLECharacteristicValue)和经典蓝牙那一套(createBluetoothServerSocket / accept)。前者是主力,后者在部分平台和版本上对客户端主动连接的支持并不完整,通常的做法是:能走 BLE 就走 BLE,实在要用 SPP 透传模块,先确认目标机型上经典蓝牙的服务端/客户端角色是否都可用,不行就换 BLE 模块或补一个原生插件,别在 JS 层硬扛。

这里还有个容易被忽略的前提:plus.bluetooth 只在真机基座或打包后的 App 里有效,电脑上的浏览器预览里plus对象根本不存在。我一般会在入口处做一次环境探测,避免页面白屏又找不到原因。

// env.js —— 统一的环境与能力探测,页面加载时先跑一次 export function detectBluetoothEnv() { // #ifdef APP-PLUS if (typeof plus === 'undefined') { return { ok: false, reason: 'plus 未注入,请用真机基座运行' }; } if (!plus.bluetooth) { return { ok: false, reason: '当前基座未包含蓝牙模块,请使用自定义基座' }; } return { ok: true, platform: plus.os.name }; // Android / iOS // #endif // #ifndef APP-PLUS return { ok: false, reason: '当前为 H5 预览环境,蓝牙不可用' }; // #endif }

这段代码用了 HBuilderX 的条件编译注释,#ifdef APP-PLUS之间的内容只会在 App 端被编译进去,H5 端直接返回不可用。这样同一份工程在浏览器里预览页面布局、在真机上跑蓝牙,两边都不冲突。plus.os.name拿到的平台标识后面会用来做权限申请和 UI 差异处理。

2.2 BLE 与经典蓝牙的选型判据

选型不看哪个“更先进”,看硬件模块给的是什么能力。我一般按下面这张表快速定位:

判据BLE(GATT)经典蓝牙(RFCOMM/SPP)
典型硬件手环、传感器、贴片模块老式串口透传模块、打印机
连接方式createBLEConnection + 服务/特征值Socket 连接,按 UUID 建通道
单次写入长度受 MTU 限制,常见 20 字节理论上更大,实际也要分包
数据回传方式notify / indicate 订阅Socket 输入流读取
H5+ 支持度完整,接口齐全服务端可用,客户端支持不稳定
功耗低高

一个实用判断:如果模块手册上给的是 Service UUID 和 Characteristic UUID,那就是 BLE,直接走 BLE 路线;如果手册上写的是“串口透传、配对后当串口用”,那是 SPP,得先验证经典蓝牙通道。还有一类模块两种都支持,那就优先 BLE——接口成熟,调试工具也多。

提示:BLE 的特征值分 read / write / notify 三种属性,写数据前必须确认目标特征值带 write 属性,订阅回传必须选带 notify 或 indicate 的那个。选错了不会报错,只会一直收不到数据,这是新手最常见的卡点。

2.3 运行环境与权限基线

代码写得再对,权限没配就是扫不到设备。Android 这边,扫描 BLE 在 6.0 以后要求定位权限,12 以后又拆出了 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 两个运行时权限;iOS 这边必须在打包配置里声明蓝牙用途描述,否则系统会静默拒绝。这些都要在 manifest 里提前写好,不能等运行时报错了再回头补。

// manifest.json 片段(App 模块配置里补充,字段按当前打包工具为准) { "app-plus": { "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.BLUETOOTH_SCAN\"/>", "<uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\"/>" ] }, "ios": { "privacyDescription": { "NSBluetoothPeripheralUsageDescription": "需要蓝牙连接设备进行数据通讯", "NSBluetoothAlwaysUsageDescription": "需要蓝牙连接设备进行数据通讯" } } } } }

Android 的权限列表要覆盖新旧两代,因为用户的系统版本分布很散,只写新的会导致老机型扫不到,只写旧的会导致新系统被拒。iOS 那两个描述字段缺一不可,且文案要让用户看明白用途,写空字符串会被审核和系统同时拦下。

运行时还要再要一次权限。Android 上可以用 plus.android 反射申请,也可以交给打包工具自带的权限申请接口;不管用哪种,都要在开始扫描之前完成,并且处理用户点“拒绝”的分支——直接给个提示引导去设置页,比让页面一直转圈要体面。

// 在 App 启动或进入蓝牙页时调用 function requestAndroidPermission() { return new Promise((resolve) => { if (plus.os.name !== 'Android') return resolve(true); const main = plus.android.runtimeMainActivity(); const Build = plus.android.importClass('android.os.Build'); // Android 12 及以上走新的运行时权限,以下走定位权限 const perms = Build.VERSION.SDK_INT >= 31 ? ['android.permission.BLUETOOTH_SCAN', 'android.permission.BLUETOOTH_CONNECT'] : ['android.permission.ACCESS_FINE_LOCATION']; plus.android.requestPermissions(perms, (res) => { // granted 非空即视为通过,deniedPresent 里是被拒的项 resolve(res.granted && res.granted.length > 0); }, () => resolve(false)); }); }

这段的关键在版本分支:31 对应 Android 12,是这个权限模型的分水岭。granted返回的是已授予的权限数组,只要非空就说明至少有一项通过,但严谨一点应该逐项比对deniedPresent,把被拒的那一项单独提示给用户。实际项目里我还会加一个“已拒绝且不再询问”的判断,那种情况只能引导去系统设置手动开。

3. 总体架构设计:一个页面、两条通道、三层状态

这个 demo 的界面可能就一个页面,但内部结构不能糊成一团。蓝牙通讯的复杂点不在 UI,而在状态——扫描中、连接中、已连接、订阅成功、正在写入、断线重连中,这些状态互相影响,混在页面里写必然失控。

3.1 分层:适配层、协议层、业务层各管什么

我把整个通讯拆成三层,每层只对上一层负责:

  • 适配层(bt-adapter):唯一直接调用plus.bluetooth的地方。负责打开适配器、扫描、连接、枚举服务、读写特征值、注册各类回调。上层永远不直接碰 plus API。
  • 协议层(bt-protocol):负责帧的封装与解析。把业务对象序列化成字节数组、补长度头、校验和;把收到的 ArrayBuffer 还原成完整帧。这一层不知道蓝牙的存在,纯函数,最好测。
  • 业务层(bt-service):维护连接状态机、设备列表、指令队列、重连策略,向上暴露connect(deviceId)、send(cmd)、on('data', cb)这样的接口。

这么分的好处很实际:换硬件模块时只改协议层的帧格式;换通讯方式(比如以后加 WiFi)只需要再写一个适配层,业务层不动;排查问题时能快速定位是“连不上”(适配层)还是“连上了但数据不对”(协议层)。

目录大致是这样:

/bt adapter.js // plus.bluetooth 封装 protocol.js // 帧编解码 service.js // 状态机 + 队列 + 重连 constants.js // UUID、指令码、超时时间 /pages index.vue // 只负责渲染和调 service

3.2 连接状态机:把“连上了吗”变成一个确定的值

我见过太多项目用一个isConnected布尔值撑全场,结果重连时两个回调同时进来,状态就乱了。正确做法是定义有限状态,并规定每个事件只能在特定状态下触发。

状态集合:IDLE(初始)→SCANNING(扫描中)→CONNECTING(连接中)→CONNECTED(已连接)→READY(服务和订阅都就绪)→DISCONNECTING→ 回到IDLE。任何一步失败都回IDLE并记录原因。

// service.js 中的状态迁移表,key 是当前状态,value 是允许的下一状态 const TRANSITIONS = { IDLE: ['SCANNING'], SCANNING: ['CONNECTING', 'IDLE'], CONNECTING: ['CONNECTED', 'IDLE'], CONNECTED: ['READY', 'DISCONNECTING', 'IDLE'], READY: ['DISCONNECTING', 'IDLE'], DISCONNECTING: ['IDLE'] }; function canTransit(from, to) { return (TRANSITIONS[from] || []).indexOf(to) > -1; } // 所有状态变更走这一个入口,非法迁移直接打日志拦掉 function setState(next) { if (!canTransit(this.state, next)) { console.warn(`[bt] 非法状态迁移 ${this.state} -> ${next}`); return false; } this.state = next; this.emit('state', next); // UI 只订阅这个事件 return true; }

CONNECTED和READY必须分开。前者只代表底层链路建起来了,后者代表已经拿到 service、characteristic 并成功开启 notify。很多“连上了但收不到数据”的问题,本质是把CONNECTED当成了READY就发指令。状态机里加一个canTransit白名单,能挡掉大量时序错乱导致的诡异 bug,这个习惯我强烈建议保留。

3.3 数据帧格式与编解码约定

蓝牙收发的单位是 ArrayBuffer,业务要的是对象,中间必须有一层约定。帧格式我一般定成“帧头 + 长度 + 指令码 + 载荷 + 校验”:

字段长度说明
帧头2 字节固定 0xAA 0x55,用于定位帧起点
长度1 字节载荷长度,不含头尾
指令码1 字节区分业务类型
载荷N 字节业务数据
校验1 字节从长度到载荷逐字节异或
// protocol.js —— 编码,业务对象 -> ArrayBuffer export function encodeFrame(cmd, payloadBytes) { const len = payloadBytes.length; const buf = new Uint8Array(4 + len + 1); buf[0] = 0xAA; buf[1] = 0x55; // 帧头 buf[2] = len; // 载荷长度 buf[3] = cmd; // 指令码 buf.set(payloadBytes, 4); // 载荷 let sum = 0; for (let i = 2; i < 4 + len; i++) sum ^= buf[i]; // 从长度位开始异或 buf[4 + len] = sum; // 校验位 return buf.buffer; }

编码函数是纯函数,输入确定输出就确定,方便在真机出问题前先用几组样例验证。校验位从长度字节开始算,是因为帧头固定,不参与校验更有区分度;如果你对接的硬件手册写的是“从帧头开始累加”,改成从 0 开始循环即可,这类细节一定以手册为准,不要凭感觉。

解码要处理分包,用一个缓冲区累积字节,循环找帧头:

// protocol.js —— 解码,DataView -> 完整帧数组 export function decodeChunk(buffer, state) { const bytes = new Uint8Array(buffer); state.buf = state.buf.concat(Array.from(bytes)); // 追加到累积缓冲 const frames = []; while (state.buf.length >= 5) { // 找帧头,找不到就丢掉前面的垃圾数据 const start = state.buf.findIndex((b, i) => b === 0xAA && state.buf[i + 1] === 0x55); if (start < 0) { state.buf = []; break; } if (start > 0) state.buf = state.buf.slice(start); const len = state.buf[2]; const total = 4 + len + 1; if (state.buf.length < total) break; // 还没收全,等下一包 const frame = state.buf.slice(0, total); let sum = 0; for (let i = 2; i < total - 1; i++) sum ^= frame[i]; if (sum === frame[total - 1]) { frames.push({ cmd: frame[3], payload: frame.slice(4, 4 + len) }); } else { console.warn('[bt] 校验失败,丢弃该帧'); } state.buf = state.buf.slice(total); } return frames; }

state.buf是跨调用保留的数组,接收回调每来一次就喂一次。这里有两处不能省:一是findIndex找帧头并丢弃前置垃圾,二是数据不足时break保留残余等下一包。少了任何一处,分包场景下都会解析失败,而且是那种“大部分时候正常、偶尔丢一条”的偶发问题,最难查。

4. 关键模块实现:扫描、连接、收发、断线重连

架构定下来,落到代码就是四个动作。这一章把每个动作的完整实现和参数含义写清楚,照着改 UUID 就能用。

4.1 扫描与设备发现:去重、过滤、限时

扫描的坑主要是两个:一是onBluetoothDeviceFound会重复上报同一设备,列表越长越卡;二是 Android 上不开定位权限扫不到任何结果,界面上看着就是在转圈。

// adapter.js —— 打开适配器并开始扫描 export function startScan({ services = [], timeout = 10000, onDevice, onError }) { plus.bluetooth.openBluetoothAdapter({ success: () => { plus.bluetooth.onBluetoothDeviceFound((res) => { res.devices.forEach((d) => onDevice(normalize(d))); // 交给上层去重 }); plus.bluetooth.startBluetoothDevicesDiscovery({ services, // 只扫指定服务,空数组表示全扫 allowDuplicatesKey: false, // 关掉重复上报,系统会做一次去重 interval: 1000, // iOS 有效,上报间隔 success: () => setTimeout(() => stopScan(), timeout), fail: (e) => onError(e) }); }, fail: (e) => onError(e) // 常见原因:蓝牙未开启、权限未授予 }); } function normalize(d) { return { deviceId: d.deviceId, name: d.name || d.localName || '(未命名设备)', RSSI: d.RSSI, advertisServiceUUIDs: d.advertisServiceUUIDs || [] }; }

services参数是性能关键。传了具体 UUID,系统只上报匹配的广播,列表干净、功耗也低;传空数组会扫到周围所有蓝牙设备,在办公楼里能刷出几十条。allowDuplicatesKey: false让系统层做去重,但不同平台行为有差异,所以上层还得再按deviceId用 Map 去重一次,双保险。

扫描一定要有超时。蓝牙扫描是持续耗电的,忘了关,用户手机半天就没电了,这种问题在真机上测不出来,得靠用户投诉才会知道。setTimeout里调stopScan,同时把定时器存起来,在手动停止和页面卸载时clearTimeout,避免回调里操作已销毁的页面。

// 上层去重:同一 deviceId 只保留最新一条,并按信号强度排序 const deviceMap = new Map(); function onDevice(d) { deviceMap.set(d.deviceId, d); const list = Array.from(deviceMap.values()).sort((a, b) => b.RSSI - a.RSSI); renderList(list); // 信号强的排前面,用户一眼能看到贴着手机的那个 }

按 RSSI 倒序是个小细节但很实用:现场调试时桌上有三四块模块,靠信号强度排序基本能猜出哪块是手边这块,省去一个个试的时间。

4.2 建立连接与枚举服务、特征值

连接是三步走:建链路 → 拿服务列表 → 拿目标服务下的特征值。任何一步失败都要回退到IDLE并断开,否则会留下一个“半连接”状态,下次连接报错还找不到原因。

// adapter.js —— 连接并找到可写和可订阅的特征值 export function connect(deviceId, uuids) { return new Promise((resolve, reject) => { plus.bluetooth.createBLEConnection({ deviceId, timeout: 10000, success: () => { plus.bluetooth.getBLEDeviceServices({ deviceId, success: (sRes) => { // 服务 UUID 大小写不统一,统一转小写比较 const svc = sRes.services.find( (s) => s.uuid.toLowerCase() === uuids.service.toLowerCase() ); if (!svc) return reject(new Error('未找到目标服务')); plus.bluetooth.getBLEDeviceCharacteristics({ deviceId, serviceId: svc.uuid, success: (cRes) => { const write = cRes.characteristics.find((c) => c.properties.write); const notify = cRes.characteristics.find( (c) => c.properties.notify || c.properties.indicate ); if (!write) return reject(new Error('没有可写特征值')); resolve({ serviceId: svc.uuid, writeId: write.uuid, notifyId: notify && notify.uuid }); }, fail: reject }); }, fail: reject }); }, fail: reject }); }); }

这段代码有三个关键点。第一,timeout必须显式传,默认值偏长,用户等不及会重复点连接,造成并发连接。第二,UUID 比较统一转小写,不同平台返回的大小写不一致,直接===比较会莫名匹配不上。第三,从properties里挑特征值而不是写死 UUID——写死的坏处是换一批模块就得改代码,按属性挑则通用性强得多;如果你的模块有多个可写特征值,那就得回到写死 UUID 的方式,以手册为准。

连接成功后立刻注册连接状态回调,不要等到业务层再注册。断开事件来得可能比你想象的早。

plus.bluetooth.onBLEConnectionStateChange((res) => { if (!res.connected) { service.setState('IDLE'); // 状态机复位 service.startReconnect(res.deviceId); // 触发重连策略 } });

4.3 订阅通知与分包写入

收数据靠 notify,发数据靠 write,这两件事都有硬约束。

订阅必须在连接成功之后、发指令之前完成,且state传true:

// 开启通知,并在回调里把数据喂给解码器 export function subscribe(deviceId, serviceId, notifyId, onFrame) { const decodeState = { buf: [] }; plus.bluetooth.onBLECharacteristicValueChange((res) => { if (res.characteristicId.toLowerCase() !== notifyId.toLowerCase()) return; const frames = decodeChunk(res.value, decodeState); frames.forEach(onFrame); }); plus.bluetooth.notifyBLECharacteristicValueChange({ deviceId, serviceId, characteristicId: notifyId, state: true, // true 开启,false 关闭 success: () => console.log('[bt] 订阅成功'), fail: (e) => console.error('[bt] 订阅失败', e) }); }

onBLECharacteristicValueChange是全局回调,一条连接上所有特征值的数据都会进这里,所以必须先按characteristicId过滤,否则会把别的通道数据混进来。回调里的res.value是 ArrayBuffer,直接扔给解码器,别在这里做业务判断。

写入的约束是单包长度。BLE 默认 MTU 下有效载荷常见只有 20 字节,超过就得拆。有的平台支持setBLEMTU协商更大的包,但协商结果依赖对端模块,不能假设。

// 分包写入,每包不超过 20 字节,包间留 20ms 间隔 export async function writeChunked(deviceId, serviceId, charId, arrayBuffer) { const bytes = new Uint8Array(arrayBuffer); const CHUNK = 20; for (let i = 0; i < bytes.length; i += CHUNK) { const slice = bytes.slice(i, i + CHUNK); await new Promise((resolve, reject) => { plus.bluetooth.writeBLECharacteristicValue({ deviceId, serviceId, characteristicId: charId, value: slice.buffer, success: resolve, fail: reject }); }); await new Promise((r) => setTimeout(r, 20)); // 包间延时,防止对端缓冲溢出 } }

CHUNK取 20 是保守值,稳妥但慢;如果你的模块手册写明支持 128 或 244,可以调大,但一定要先用长包做压测。包间的 20ms 延时不是可有可无——连续 write 太快,很多模块的接收缓冲会溢出丢包,现象是“短指令正常,长指令后半段丢失”,本质就是发送端比接收端快。这个延时值可以调,短到 10ms 一般也行,但别去掉。

4.4 粘包处理与断线重连策略

粘包在 3.3 的解码器里已经解决了——靠累积缓冲和帧头定位。这里重点说重连。

重连不能是无脑循环,否则用户关掉设备后 App 会一直转。我的做法是“有限次数 + 递增间隔 + 可手动取消”:

// service.js —— 重连策略:最多 3 次,间隔 1s / 2s / 4s const RETRY_DELAYS = [1000, 2000, 4000]; async function startReconnect(deviceId) { if (this.reconnecting) return; // 防止多个断开事件触发多轮重连 this.reconnecting = true; for (let i = 0; i < RETRY_DELAYS.length; i++) { if (this.manualClosed) break; // 用户主动断开就不要重连 await sleep(RETRY_DELAYS[i]); try { await this.connect(deviceId); this.reconnecting = false; this.resubscribe(); // 重连后必须重新订阅,这步最容易漏 return; } catch (e) { console.warn(`[bt] 第 ${i + 1} 次重连失败`, e); } } this.reconnecting = false; this.emit('reconnect-failed'); // 交给 UI 提示用户检查设备 }

两个细节决定成败。一是reconnecting标志位,蓝牙断开时系统可能连发多次状态回调,没有这个锁会同时起好几轮重连,互相打架。二是重连成功后必须重新调subscribe——连接是新建的,之前的 notify 订阅不会自动恢复。漏了这一步的表现是“重连显示成功,但一直收不到数据”,非常典型。

5. 避坑清单:真机上最容易踩坑的五件事

这一章全部来自实际调试现场。每一条都按“看到什么现象 → 为什么会这样 → 怎么解决”来说,遇到问题时可以当检查表用。

5.1 扫描列表一直是空的

现象:点了扫描按钮,转圈十几秒,一条设备都没有,但手机系统蓝牙里能看到模块。

原因:九成是权限。Android 6 以上扫 BLE 需要定位权限,且系统定位开关必须是打开的;Android 12 以上还需要 BLUETOOTH_SCAN 运行时权限。另一个可能是模块根本没在广播——有些模块上电后要等主控发指令才开始广播。

解决:先在系统设置里确认蓝牙和定位都开着,再在代码里把权限申请的结果打出来看。用第三方蓝牙调试工具交叉验证一下模块是否在广播,如果那边也扫不到,问题在硬件侧,不在代码。排查顺序永远是“系统 → 模块 → 代码”,反过来查会浪费大量时间。

5.2 连上了,但收不到任何数据

现象:连接状态显示成功,发指令也返回成功,就是没有回包。

原因:按概率排序有三个——没开 notify、订阅的特征值选错、服务 UUID 不匹配但用了模糊匹配所以“看起来连上了”。还有一种隐蔽情况:模块的 notify 特征值和 write 特征值不是同一个,代码里默认用了同一个。

解决:把getBLEDeviceServices和getBLEDeviceCharacteristics的完整返回打出来,逐个看properties,确认notify: true的是哪个。订阅成功后打一行日志,确认success回调真的进了。如果模块支持 read 属性,先 read 一次看有没有数据,能区分是“模块不发”还是“我们收不到”。

5.3 长指令发出去,只执行了一半

现象:短指令完全正常,超过 20 字节的指令模块只响应前半段,或者干脆不响应。

原因:单包超过 MTU 上限。BLE 在默认 MTU 23 时有效载荷只有 20 字节,超出的部分要么被截断,要么直接写失败但回调仍返回 success。

解决:按 4.3 的方式分包,包间加延时。别再相信“我这条指令只有 30 字节,应该没事”——30 字节必被拆,只是拆的位置不由你控制。如果模块手册声明支持更大的 MTU,可以先调setBLEMTU协商,协商成功后再按协商值分包,协商失败就退回 20。

5.4 换一台手机就跑不起来

现象:在 A 手机上一切正常,换到 B 手机扫描失败或连接超时。

原因:Android 版本差异导致权限模型不同(前面说的 12 分水岭),或者厂商系统对后台扫描做了额外限制。iOS 上则是 UUID 大小写和广播格式更严格。

解决:权限申请写成版本分支;扫描前判断应用是否在前台;UUID 比较统一转小写。做兼容时我一般会准备三台跨度大的机型(老 Android、新 Android、iOS)各跑一遍,比在一个机型上反复调参数有效得多。

5.5 页面切走再回来,连接就废了

现象:App 切到后台,或者从蓝牙页跳到别的页面再返回,连接状态还在,但收发全失效。

原因:页面销毁时没有解绑全局回调,返回时又注册了一遍,导致回调重复触发或者指向已销毁的页面实例;也可能是系统在后台回收了连接。

解决:在页面onUnload/onHide里统一清理——停止扫描、清除定时器、必要时断开连接;全局回调只注册一次,放在 service 单例里而不是页面里。重连逻辑按 4.4 走,别指望系统帮你保住连接。

注意:调试蓝牙一定要用真机,且优先用自定义基座。标准基座在部分版本上不含完整的蓝牙模块,报的错会很误导人。

6. 验收与答辩:怎么证明这套蓝牙方案是真的通了

代码写完不算完,得有一套能拿出来给人看的验收路径。我带项目时习惯把验收分成三级,每一级都有明确的通过标准,这样无论是交给导师看还是交给甲方测,都不会出现“你说通了他说没通”的扯皮。

第一级:链路验收。能扫到目标设备、能连上、能枚举出预期的服务和特征值、能成功开启 notify。这一级的通过标准是日志里四个 success 回调全部出现,且设备名和 UUID 与硬件手册一致。这一级不过,后面全是空谈。

第二级:数据验收。下发一条已知指令,模块返回预期响应;连续发 50 条,统计丢失率应为 0;下发一条超过 20 字节的长指令,模块能完整执行。这一级要留证据——把收发日志按时间戳打印出来,形成一份可回看的记录。

第三级:异常验收。这是最能体现功底的一级,也是最容易被跳过的一级:

异常场景操作方式预期表现
设备断电通讯中直接关模块电源3 秒内进入重连,3 次失败后提示
蓝牙关闭通讯中关闭手机蓝牙状态回 IDLE,UI 提示开启蓝牙
后台切换切到后台 30 秒再回来连接恢复或自动重连成功,数据不丢帧
快速连点连续点击连接按钮 10 次只建立一条连接,无重复回调
弱信号走到 8 米外不崩溃,断开后能重连

答辩或者交付演示时,我一般会提前准备一个“对照脚本”:先演示正常流程,再当场拔掉模块电源演示重连,最后打开日志页展示完整的状态迁移记录。三步下来,评审基本不会再问“这个真的能用吗”这种问题——因为他亲眼看到异常被接住了。

还有一个提升观感的小技巧:在页面上放一个简易的收发监视器,左边滚动显示十六进制发送内容,右边显示接收内容和解析后的指令码。这东西实现成本极低,但在演示现场的说服力远超任何口头描述,评审能直接看到字节在流动。我吃过一次亏——早期版本只显示“发送成功”,评审追问“你怎么知道模块真收到了”,当场答不上来。从那以后我每次做通讯类项目,都强制在页面上留一个原始字节窗口,哪怕只是调试用,也一定保留到验收结束。希望这套结构能帮到你,把它改成你自己的硬件协议,剩下的就是换几个 UUID 的事。

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

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

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

立即咨询