
做物联项目有一件事特别明显硬件端的差异远没有想象中那么大真正拖进度的是小程序里反复实现的同一套蓝牙流程。大夏物联这边的几款设备用的是大夏龙雀蓝牙模组不同设备在协议细节上各有不同可开发模式几乎一样——扫描、连接、发现服务、读写特征值、订阅通知、断线重连。每接一个新项目我都要把这些代码从老项目里拷一遍拷完还要改协议适配最后干脆整理成一套模板。这篇文章不聊概念直接讲面向大夏龙雀蓝牙的微信小程序模板怎么设计、怎么落地、踩了哪些坑适合手里有同类 BLE 设备要接小程序的开发同学也适合想把蓝牙通信逻辑从业务代码里抽出来的团队。1. 为什么我需要一套可复用的 BLE 小程序模板1.1 大夏龙雀蓝牙接入的业务场景先交代背景。大夏物联这边做的是设备端到移动端的闭环设备端统一采用大夏龙雀蓝牙模组支持低功耗蓝牙 4.0 以上的协议栈。从业务上看这些设备不是单一的硬件型号而是分成了好几条产品线有数据采集类的定时上报温湿度和电量有控制类的接收小程序下发的指令再执行动作还有定位类的需要持续广播自己的标识信息。这些设备有一个共同点它们都通过 BLE 和小程序交互但每条产品线的 GATT 服务设计不一样。有些设备把状态信息放在一个可读的特征里有些放在通知里有些则需要先写入一个查询指令设备才把数据回传过来。我最早做第一款设备的接入时花了整整两天把官方文档里的接口一个个试完又在真机上验证了安卓和 iOS 的行为差异。等接到第二款设备的时候我发现蓝牙通信这部分代码几乎要推翻重来只是因为服务 UUID 变了、数据帧格式变了。这一下就触到痛点了。BLE 连接是一个有状态的流程状态机本身就容易出问题加上微信小程序的蓝牙接口是异步回调驱动调试起来比命令行工具复杂得多。如果每个项目都从零开始等于把同样的状态机错误再犯一遍。所以我开始考虑把通信层从具体业务里抽出来做成一个统一模板。这个模板不是某个页面的 UI 模板而是整个 BLE 交互链路的工程底座。1.2 模板的核心收益模板化之后收益最直接的一条就是新设备接入时间从两三天压缩到半天左右。前提是通信层已经稳定剩下要改的只是设备配置和帧解析规则。具体来说这套模板把下面几件事固定下来了蓝牙适配器的初始化和权限申请流程固定避免每个页面重复处理。设备扫描、过滤、连接、服务发现的顺序固定统一走同一套状态机。特征值读写和通知订阅的封装固定业务侧不需要直接操作 ArrayBuffer。断线监听和重连策略固定不会出现连接断开后页面毫无感知的情况。日志记录固定真机调试时能把蓝牙事件按时间顺序串起来快速定位问题。很多同学觉得模板就是抄代码其实不然。模板真正有价值的地方在于它沉淀了决策什么时机执行哪一步、异常时怎么处理、边界条件如何兜底。比如 iOS 上getBLEDeviceCharacteristics返回的空数组到底应该重试还是直接报错再比如写入数据时 ArrayBuffer 的字节序问题这些决策在代码里都有体现。后续新同事接手项目不需要重新踩一遍坑只要在配置层面做扩展即可。2. 模板目录与通信层封装把蓝牙能力收进一个模块2.1 目录结构设计这个模板不是一个大而全的框架而是尽量保持克制。我把工程拆成几个独立模块业务页面只依赖一个对外暴露的 BLE 核心模块。miniprogram/ pages/ device/ device.js device.wxml device.json utils/ ble-core.js ble-profile.js crc.js logger.js config/ index.jsble-core.js是核心它负责处理所有微信小程序蓝牙接口的调用并把结果统一成 Promise 风格ble-profile.js专门放设备相关的服务 UUID、特征 UUID、指令模板crc.js是数据帧校验工具不同产品线的校验算法不同但是接口保持一致logger.js做日志收集和导出解决真机调试时看不到 console 的问题。大多数项目的通病是把蓝牙逻辑直接写在页面里页面一多扫描、连接、断线重连这些逻辑到处复制改起来非常痛苦。模板的第一原则就是把这些逻辑全部下沉到ble-core.js页面只负责调用connectDevice、writeCommand、subscribeNotify这类高层次的函数。2.2 BLE 核心模块的函数与状态约定ble-core.js的核心设计思路是状态集中、事件分发。我定义了一个简单的状态机枚举常量如下const BLE_STATE { IDLE: 0, // 空闲 SCANNING: 1, // 扫描中 CONNECTING: 2, // 连接中 CONNECTED: 3, // 已连接 DISCONNECTED: 4 // 已断开 };每一个对外方法都会先检查当前状态避免在连接状态下重复调用扫描接口或者在未连接状态下直接写入特征值。这个检查很基础但很多从零写的项目恰恰忽略了它导致页面多次点击“连接”按钮时蓝牙适配器处于异常状态。ble-core.js的对外接口保持精简主要有initBle()初始化蓝牙适配器。startScan(options)开始扫描设备支持按名称和服务 UUID 过滤。stopScan()停止扫描。connectDevice(deviceId)建立连接带超时控制。discoverServices()获取连接设备的 GATT 服务。writeCommand(cmdId, payload)向指定特征写入指令。subscribeNotify(characteristicId)订阅通知特征。unsubscribeNotify()取消订阅。closeConnection()主动断开连接。onDeviceEvent(callback)统一事件回调把状态变化、数据帧、错误信息都推给页面。为什么要把事件统一成一个回调而不是给每个事件分配一个回调函数因为蓝牙事件是串行的一个页面往往只需要关心当前设备的状态变化。统一回调加上一个事件类型字段比维护一堆onXxx函数要清晰得多后续加日志也方便。3. 标准化连接流程从扫描广播到服务发现3.1 初始化、扫描与设备筛选微信小程序的蓝牙接口全部以wx.开头第一步是调用wx.openBluetoothAdapter初始化蓝牙适配器。这一步很关键它需要用户授权而且在不同手机上行为不一样。安卓 12 之后蓝牙扫描需要精确定位权限iOS 上则是在系统设置里申请蓝牙权限。这些权限问题如果不在初始化阶段处理后面扫描就会直接失败。初始化完成以后调用wx.startBluetoothDevicesDiscovery开始扫描。这个接口有两个常见参数allowDuplicatesKey控制是否重复上报同一个设备services可以传入主服务 UUID 列表只扫描广播中包含这些服务的设备。大夏龙雀蓝牙模组的广播包带有厂商 ID 和设备类型字段所以我在扫描回调里做了两层过滤第一层是设备名匹配第二层是广播数据里特定字节的匹配。过滤逻辑放进模板而不放在业务代码里是因为后续每款新设备都会沿用同样的过滤规则。scanDevice() { wx.onBluetoothDeviceFound((res) { const devices res.devices || []; for (const item of devices) { const matched this.matchDevice(item); if (matched) { this.foundDeviceList.push(item); this.emitEvent(onDeviceFound, item); } } }); wx.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, powerLevel: high, }); }这里的matchDevice函数在基础模板里返回true表示不过滤所有设备。实际项目中团队可以根据自己的广播协议覆盖这个方法增加设备类型判断。模板能保持灵活性正是因为它把这类扩展点放在一个函数里而不是散落在各个地方。要提醒的是wx.onBluetoothDeviceFound在安卓和 iOS 上的回调频率差异非常大。安卓会高频回调iOS 则通常只在发现新设备时回调一次。所以不能依赖回调次数去判断设备是否在线扫描结束后要有一个停止动作避免一直空转耗电。3.2 建立连接与超时控制扫描到目标设备之后调用wx.createBLEConnection建立连接。这个接口调用时要传入deviceId也就是从扫描回调里拿到的设备 ID。连接过程是异步的网上一搜很多代码在success回调里直接认为连接成功其实不对。createBLEConnection的成功只表示设备已被系统蓝牙栈接受不代表 GATT 服务已经就绪。真正可以开始读写操作要等到getBLEDeviceServices返回服务列表之后。所以模板在连接成功的回调里不着急置为CONNECTED状态而是继续调用getBLEDeviceServices。连接还需要做超时控制。某些安卓手机上设备长时间无响应时连接流程可能一直不回调失败。我采用的方案是计时器兜底发起连接的同时启动一个 8 秒钟的定时器如果超时仍然没有进入下一步直接调用closeBLEConnection并上报错误。这个动作看起来简单但在真实场景里能避免页面上出现一个永远转圈的状态。关于连接失败后的处理模板默认连续失败 3 次就不再重试给出“连接失败请靠近设备后重试”的提示。设备距离太远、设备端电池电量过低、同频干扰都可能导致连接失败。用户连续点了三次还在失败大概率是环境问题继续自动重试没有意义。3.3 服务发现与特征选择连接成功之后设备会暴露一组 GATT 服务。每个服务下面有多个特征值有的支持读有的支持写有的支持通知。我们需要找到业务约定的那组 UUID才能进行后续通信。wx.getBLEDeviceServices返回的是设备所有服务模板里用一个提取函数找出需要的主服务getTargetService(services) { const targetUUID BLEProfile.SERVICE_UUID; return services.find( (item) item.uuid.toUpperCase() targetUUID.toUpperCase() ); }这里必须做大小写归一化因为不同手机系统返回的 UUID 大小写格式不一样。有的返回大写有的返回小写不统一处理就会匹配失败。这个坑我踩过不止一次最后直接在模板层面解决。拿到服务对象之后再调用wx.getBLEDeviceCharacteristics获取该服务下的特征列表。这里同样要处理大小写和 UUID 格式有些返回的 UUID 带短格式比如FFF0有些则返回完整的 128 位 UUID比较时不能直接。特征值选择的原则是优先使用设备厂商文档指定的服务不要靠猜。大夏龙雀蓝牙模组的文档里通常会写明某个服务下的Write Characteristic和Notify Characteristic。模板把这两个 UUID 放在ble-profile.js里集中管理换设备时只需要改配置文件。4. 数据交互链路命令写入、通知订阅与帧解析4.1 命令构造与 MTU 边界蓝牙低功耗的一个基础限制是 ATT MTU。早期很多模组默认 MTU 只有 23 字节扣掉协议头之后有效载荷只有 20 字节。微信小程序没有直接修改 MTU 的公开接口所以在写数据时只能按 20 字节一封包来做。大夏龙雀蓝牙的控制类设备指令帧设计得比较紧凑比如AA 55 01 02 00 03 0A这种格式。这个帧里包含了帧头、命令字、数据长度、数据和校验字节。写入之前需要把十六进制字符串转成ArrayBuffer因为微信小程序的writeBLECharacteristicValue接口只接受ArrayBuffer不接受普通数组。buildCommand(cmdId, payload) { const body [0xAA, 0x55, cmdId, payload.length, ...payload]; const checksum this.getChecksum(body); const frame [...body, checksum]; const buffer new ArrayBuffer(frame.length); const view new Uint8Array(buffer); frame.forEach((byte, index) { view[index] byte; }); return buffer; }如果指令长度超过 20 字节就需要分包发送。分包时要注意设备端对拆包的支持情况有的设备要求每一包之间间隔 20 毫秒有的设备则要求必须连续发送中间不能停顿。微信小程序里没有精确的毫秒级定时器我用的是setTimeout加一个标志位确保上一包写入成功后再发下一包。实测下来这种串行分包方式比盲目循环发送稳定得多。还有一点很关键写入特征值的ArrayBuffer一旦传出去之后不要再复用同一个 buffer 去改字节否则可能影响已发出的数据。这一点在一些底层实现比较敏感的安卓手机上特别明显稳妥的做法是每个包都新建ArrayBuffer。4.2 通知订阅与数据回调读取设备数据有两种方式主动读和被动通知。对于频繁上报的数据比如电量、温湿度最佳方案是让设备主动推送也就是订阅通知特征。调用wx.notifyBLECharacteristicValueChange传入characteristicId和state: true成功之后设备的数据就会通过wx.onBLECharacteristicValueChange回调返回。subscribeNotify() { wx.notifyBLECharacteristicValueChange({ deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: this.notifyCharId, state: true, }); }这里容易忽略的是通知订阅成功之后并不是马上就能收到数据。部分设备要等收到一条“开启推送”指令后才开始推送否则即使订阅成功设备端也不会主动上报。不同产品线在这块行为不一致我的做法是在模板的subscribeNotify成功回调里自动发送一条设备配置里声明的开启推送指令。wx.onBLECharacteristicValueChange是全局监听只能注册一次。同一时间连接多台设备时回调会混杂不同设备的数据所以需要使用deviceId做过滤。模板内部把这个回调收拢到ble-core.js在回调里先判断deviceId是否匹配再交给上层处理。4.3 数据帧解析与校验设备上报的原始数据也是ArrayBuffer页面层拿到手的是二进制内容。把二进制转成可读数据是模板里必须提供的功能。数据解析首先要做帧同步。大夏龙雀蓝牙模组的协议一般有明确的帧头和帧尾解析时先找帧头再按长度字段截取完整帧。考虑到数据可能半包到达模板里维护了一个buffer缓冲区收到数据先追加进去再尝试解析。解析成功的帧才向上层抛出解析不了的数据留在缓冲区等待下一包。校验方面常见的有 CRC16 和简单的累加和。模板的crc.js提供了两种实现具体用哪一种由设备配置文件决定。比如function crc16(data) { let crc 0xFFFF; for (let i 0; i data.length; i) { crc ^ data[i]; for (let j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }收到一帧完整数据后会先做crc校验校验失败的数据帧直接丢弃。这个判断必须有因为 BLE 信道虽然相对稳定但在复杂电磁环境下丢帧、错帧的情况依旧存在。如果解析层把错误帧当成正常数据上抛业务页面会显示特别奇怪的数值。5. 小程序平台与手机系统隐藏的限制5.1 安卓与 iOS 的权限差异BLE 功能在不同手机系统上体验差异非常大这是做小程序蓝牙开发绕不开的话题。安卓这边从安卓 6 开始扫描蓝牙设备需要定位权限安卓 12 又把蓝牙权限拆成了BLUETOOTH_SCAN和BLUETOOTH_CONNECT。小程序有自己的一套权限体系在app.json的permission字段里声明位置权限用途同时要在小程序后台的隐私保护指引中补充说明。如果用户拒绝授权扫描会直接失败错误码通常表现为蓝牙初始化失败。iOS 这边稍微简单一点系统只要求蓝牙权限说明。第一次打开小程序时系统会弹出蓝牙授权弹窗用户一旦点了“不允许”后续所有蓝牙操作都会失败。这个授权弹窗只会出现一次用户后悔了以后只能去系统设置里手动打开。所以模板里我会在初始化失败时做一次权限引导明确告诉用户去系统设置开启蓝牙权限而不是直接抛一个“初始化失败”。5.2 回调时序与 setData 性能wx.onBLECharacteristicValueChange的数据回调频率可以很高。如果设备每秒上报 10 条数据页面再把每条数据通过setData刷新一次性能问题会非常明显。小程序setData走的是逻辑层和渲染层的通信频繁调用会让页面卡顿严重时甚至导致回调堆积。模板在处理数据上报时做了一个节流所有数据帧先进入一个队列页面层通过throttle函数统一刷新。对于需要实时绘制的图表比如温度曲线通常每 500 毫秒刷新一次 UI 就够了。数据本身的收集不做节流只对 UI 刷新做节流这样既保证实时性也不至于把渲染层拖垮。另外要注意wx.onBLEConnectionStateChange这个回调的触发时机在 iOS 上比较晚有时设备已经断开十几秒才回调。模板里同时加了业务层的心跳检测如果超过 15 秒没有收到任何设备数据判定连接异常主动重置状态机。这种方式比单靠系统回调可靠得多尤其适合长时间待机场景。5.3 异常断开与错误码处理微信蓝牙接口的错误码比较多模板把常见错误码整理成一张映射表方便排查错误码含义处理建议10000蓝牙适配器未初始化重新调用initBle10001当前蓝牙适配器不可用检查系统蓝牙是否开启10002未找到指定设备确认设备是否在广播范围内10003未找到指定服务确认服务 UUID 是否正确10004未找到指定特征值确认特征 UUID 是否正确10005当前连接已断开触发重连流程10006当前特征值不支持此操作确认特征属性是否支持读写或通知拿到错误码之后页面上不要只显示错误码这一个干巴巴的数字要配合场景给出提示。比如写入失败提示“指令发送失败请靠近设备重试”连接失败提示“设备连接超时请检查设备状态”。模板里专门封装了一个handleBleError函数能够根据错误码和当前操作上下文输出友好文案。6. 模板的工程化沉淀重连、日志与多设备扩展6.1 断线重连策略BLE 连接天然不稳定设备移动出范围、手机息屏、系统回收蓝牙资源都可能导致断连。重连策略设计得好不好直接影响用户体验。模板里把重连分成两种场景主动断开和被动断开。主动断开是指用户在小程序里点击“断开连接”这种场景下设置一个标记不让模板自动重连。被动断开是指设备异常掉线这种情况下模板会记录断连原因如果满足重连条件——用户还在当前页面、设备 ID 没有变化、距离上次断连不超过 30 秒——就自动发起重连。重连次数要有限制不然会在非静止场景下反复消耗电量。模板默认最多重连 3 次每次间隔依次是 500 毫秒、1 秒、2 秒。超过 3 次之后把错误抛给页面由用户决定是否重新搜索。某些项目还要求重连成功后自动恢复之前的通知订阅这个能力模板也做了保存deviceId和characteristicId重连成功之后自动调用subscribeNotify。6.2 日志与现场排查小程序真机调试时看不到完整的console.log输出尤其蓝牙回调这种高频日志很容易被刷没。模板的logger.js会把关键蓝牙事件写入内存队列每个事件包含时间戳、事件类型、附带数据。页面提供日志导出功能用户遇到问题后点击“导出日志”把日志内容复制出来发给开发人员。日志内容不记录敏感信息只记录下面几类适配器初始化时间与结果扫描开始与停止时间设备发现的 RSSI 和服务 UUID连接发起与成功时间服务发现数量特征属性写入指令的原始帧接收数据帧的原始帧与校验结果断连错误码与重连结果这套日志体系上线之后排查效率提升非常明显。以前设备连接不上只能靠用户截图描述现在看日志就能定位到具体是哪一层的问题。有时候用户反馈连不上导出的日志显示服务发现成功后又调用了closeBLEConnection这种基本都是业务页面里自己写了断开逻辑跟蓝牙模块无关。6.3 设备配置与扩展模板能做到多设备兼容靠的是ble-profile.js这个配置文件。每接一款新的大夏龙雀蓝牙设备只需要新建一份 profile 对象写清楚设备名称过滤前缀、服务 UUID、读写特征 UUID、指令表、数据解析规则。const PROFILE { deviceNamePrefix: DX-, serviceUUID: 0000FFF0-0000-1000-8000-00805F9B34FB, writeUUID: 0000FFF2-0000-1000-8000-00805F9B34FB, notifyUUID: 0000FFF1-0000-1000-8000-00805F9B34FB, commands: { open: [0x01], close: [0x02], queryStatus: [0x03] }, parseFrame: function (buffer) { // 返回业务可读的数据结构 } };开发新设备时蓝牙通信层代码基本不用动只替换parseFrame和指令表。这也是模板最终能在大夏物联生态里长期用下去的原因它没有把设备和业务绑死而是通过配置把差异隔离出去。最后给想直接拿这套模板去接自己设备的朋友一个建议别一上来就把所有配置都写死在代码里。我在实际项目中吃亏最多的地方几乎全是协议细节和平台行为的边界模板能帮你把重复流程固定住却没法替你把每一个设备的私有差异提前预估完。把这套模板跑通之后我的习惯是在每一款新设备接入的第一天先做一次完整的真机日志采集把扫描、连接、读写、断开几个节点的时间戳和返回码全部留下来再开始写业务页面。这样后面出了问题至少能快速定位是协议层还是展示层。这也是这套模板能在团队里持续被使用的原因——它不只是一个代码仓库更是一套经过多次联调打磨出来的通信共识。