☰
微信小程序蓝牙发送字符串给设备:BLE数据分包与编码实战
2026/10/14 14:04:29 网站建设 项目流程

1. 内容整体设计与蓝牙通信基础

这几年物联网项目里,微信小程序连蓝牙设备算是最常见的需求了。不管是智能硬件调试、嵌入式设备配网,还是工业现场的采集终端,基本都逃不开“手机小程序 ↔ 蓝牙 ↔ 单片机/设备”这条链路。而标题里说的“蓝牙发送数据给设备,支持发送字符串”,其实就是这条链路里最核心、也最容易踩坑的一段:小程序把用户在界面上输入的文本内容,通过BLE协议原样送达设备端。看起来简单,实际做起来牵扯到字节编码、分包策略、特征值权限、设备端协议约定等多个环节。

1.1 数据链路与核心需求拆解

先说清楚整个链路到底是怎么走的。微信小程序里蓝牙通信并不是像传统蓝牙串口那样直接建立一个“管道”就能收发,而是基于BLE(低功耗蓝牙)的 GATT 协议。简单类比一下:设备端相当于一个“服务器”,它把自己的能力拆成多个“服务”(Service),每个服务下面又有多个“通道”(Characteristic,特征值)。小程序要做的,就是先发现这个服务器,找到某个可写的通道,然后往这个通道里塞数据。

用日常场景来理解,服务(Service)就像一栋楼里的不同楼层,特征值(Characteristic)就是楼层里的各个房间。你要发字符串给单片机,必须先知道数据该送到哪个楼层的哪个房间——这就是服务UUID和特征值UUID。设备端固件工程师会统一维护这张“UUID地图”,小程序开发者和硬件开发者各拿一份,约定好谁写、谁读、谁通知。

标题里强调“支持发送字符串”,这里的需求拆出来其实有三层:

  • 界面层:用户在输入框里填字符串,比如K1 ON、SET:SPEED=80这类的指令。
  • 转换层:蓝牙API只认二进制字节流(ArrayBuffer),不认字符串,所以需要把字符串转成合适的字节数组,还要确定编码方式(UTF-8还是ASCII,直接决定设备端怎么解析)。
  • 通信层:把字节数组按BLE协议规定的长度(一般一次最多20字节)写入特征值,同时处理粘包、分包、失败重发。

如果只照着官方文档写一个wx.writeBLECharacteristicValue调用来发字符串,大概率会遇到两类问题:一是超过20字节后数据被静默截断或报错,二是设备端显示乱码。根源就在于中间两个层没处理干净。

1.2 为什么选BLE而不是经典蓝牙

可能有人会问:微信小程序里也有wx.createBLEConnection和wx.createBLESocket之类的API,历史上还出现过基于 RFCOMM 的经典蓝牙尝试,为什么现在的方案几乎清一色走BLE?

核心原因有三个,都是实测出来的:

  • 系统权限差异化:在 iOS 上经典蓝牙的访问权限管控极其严格,普通小程序很难拿到完整的 socket 权限,而 BLE 是系统级开放的,微信官方提供完整封装。
  • 功耗和可靠性:BLE 的广播、连接、唤醒机制更适合短数据、低频次的指令交互,对穿戴设备和电池供电设备非常友好。很多智能硬件方案本身就是基于 BLE 设计的,你在小程序侧也只能适配 BLE。
  • 接入成本:BLE 的 GATT 结构清晰,服务、特征值是标准化的抽象模型,硬件端用主流蓝牙芯片(比如常见的低功耗蓝牙模组)开箱即支持,不需要额外做蓝牙协议栈适配。

我们团队在模拟项目X里需要控制一套基于BLE的灯光控制设备,刚开始也试过直接用蓝牙串口透传,后来发现透传模式在 iOS 上兼容性差、连接不稳定,果断全部切到 BLE GATT 模式。这事给我的启发是:在工程项目里,跟着平台官方路线走通常是最稳的,别为了一点“看起来更底层”的炫技给自己埋坑。

1.3 字符串发送的适用场景说明

“发送字符串”这个需求在不同项目里的具体场景差异还挺大的:

  • 透传指令型:比如发送AT+START、{ "cmd": "reboot" }这样的文本指令给智能插座、WiFi模块、单片机控制板。
  • 数据配置型:比如把一段 JSON 配置(如DEV_NAME=TEST_ROOM_01、TARGET_TEMP=26)下发到设备端,设备完成后回传OK。
  • 交互控制型:比如操作面板上有多个按钮,每个按钮对应不同的文本指令(开、关、调亮度、切模式)。

无论哪种场景,本质都是“小程序尽力把一段字符串可靠、完整、正确编码地送达设备端”。设备端拿到后自己拆包、校验、执行。理解了这一点,你后面再看代码实现里的分包、延迟、回调处理,就知道每一行都不是多余的了。

2. 开发环境准备与API全解析

2.1 小程序蓝牙权限与基础配置

写代码之前,先把地基打好。微信小程序里要用蓝牙能力,首先必须在app.json里声明需要的权限接口。虽然官方文档说wx.openBluetoothAdapter等API调用时会弹权限框,但如果没有在app.json的permission字段里写明用途说明,部分真机在 iOS 上会直接调不起来。

下面是一份我实测可用的基础配置片段:

{ "permission": { "scope.userLocation": { "desc": "用于搜索附近蓝牙设备" } }, "requiredPrivateInfos": ["getLocation", "startLocationUpdate"] }

这段配置里有个容易忽视的点:为什么搜索蓝牙要申请定位权限?这是 iOS 的硬性机制——蓝牙扫描依赖周边无线环境的授权判定,申请scope.userLocation其实是iOS的“地理位置权限”和蓝牙扫描权限被捆绑了。Android 上基本不需要,但 iOS 必须有。如果发现开发工具里搜索不到设备,第一反应就该去检查这个权限有没有声明。

然后是项目的基础结构。我一般会把蓝牙操作单独封装成一个模块,方便在多个页面复用。比如utils/bleManager.js,里面统一管理连接状态、服务缓存、回调注册。不要再往app.js里堆一堆全局变量和回调,后面调试起来会非常痛苦。

2.2 核心API能力地图与特性值权限辨析

先给一张我在实际开发中整理的核心API速查表,现在看正好能帮新手建立大局观:

功能阶段API名称关键说明
初始化wx.openBluetoothAdapter开启蓝牙适配器,失败时一般是蓝牙未开启或权限不足
搜索wx.startBluetoothDevicesDiscovery允许传入services或allowDuplicatesKey参数过滤结果
监听wx.onBluetoothDeviceFound每发现一个新设备就触发一次,需要在回调里做去重
连接wx.createBLEConnection发起连接,成功后持有设备ID
获取服务wx.getBLEDeviceServices返回该设备所有Service列表,注意可能会有缓存
获取特征值wx.getBLEDeviceCharacteristics返回某个Service下的所有Characteristic
写入wx.writeBLECharacteristicValue向特征值写入ArrayBuffer数据,一次最多20字节
接收wx.onBLECharacteristicValueChange监听设备主动推送的数据(通知/指示),也常用于回复接收
断开wx.closeBLEConnection主动断开连接
清理wx.closeBluetoothAdapter关闭整个蓝牙适配器

这张表里最容易被忽略、也最值得深入说的是特征值的属性(Properties)。每个 Characteristic 都有read、write、writeNoResponse、notify、indicate这几个属性。

  • write:写入后设备端会回复一个确认(Write Request/Response),在小程序里表现为wx.writeBLECharacteristicValue的success回调在“设备确认”之后触发。适合要求高可靠性的指令。
  • writeNoResponse:写入后没有设备端确认,小程序侧立即返回成功。速度快,但可靠性差,一旦中途丢包,设备端可能收不到。
  • notify:设备端往小程序推送数据时使用的属性,小程序端需要调用wx.notifyBLECharacteristicValueChange先订阅,才能收得到。

你可以简单理解为:write像寄了封挂号信,有回执;writeNoResponse像发微信消息,对方看不看得到不好说。具体选哪个,取决于设备端固件的设计,不是小程序端能单方面决定的。所以开工前一定先查设备端文档,搞清楚它到底是一个只支持writeNoResponse的轻量级特征值,还是标准write模式。如果拿不准,就用wx.getBLEDeviceCharacteristics打印出来看一下,实测最靠谱。

2.3 连接流程编排与状态管理

蓝牙开发最大的坑不是API难,而是状态零散、时序混乱。刚接触的时候我经常把一连串异步调用全写在同一个回调里,结果就是:连接成功后,服务还没获取到,就开始写数据了,然后莫名其妙报错。

我的做法是把整个连接过程编排成一条清晰的状态链:

初始化蓝牙 → 开始搜索 → 发现设备 → 停止搜索 → 发起连接 → 获取主服务 → 获取特征值 → 订阅通知(如需) → 就绪

每一步的回调里,只做本步骤必要的事,然后调用下一步函数。这样出了问题,看日志就知道卡在哪一环。这里给一个实际调试的小提示:连接前一定要先wx.stopBluetoothDevicesDiscovery,否则在Android部分机型上会出现搜索和连接抢占资源、导致连接异常断开的情况。这是个非常典型的“文档没写但必须做”的坑。

另一个状态管理的关键点是多次进入页面时的蓝牙适配器状态。有些手机从上一个页面退出来再进来,适配器还开着;有些直接失效了。所以在onShow里不要盲目调openBluetoothAdapter,可以先查询状态,也可以用一个模块级标志变量来判断。毕竟一些老机型上openBluetoothAdapter调用频繁会弹出无意义的系统提示。

3. 字符串发送核心实现与代码实战

3.1 字符串与ArrayBuffer的转换原理

微信小程序的蓝牙接口,所有写入的数据必须是一个ArrayBuffer。这就像你要寄快递,快递单必须填某种固定格式,直接拿一张写着字的纸塞进信封是不行的。所以字符串转字节,是发送流程里绕不开的一步。

先说最常用的ASCII/UTF-8转换。在JavaScript环境里,最直白的思路是遍历字符串、取每个字符的charCodeAt,再拼成字节数组:

function stringToArrayBuffer(str) { const buffer = new ArrayBuffer(str.length); const view = new Uint8Array(buffer); for (let i = 0; i < str.length; i++) { view[i] = str.charCodeAt(i); } return buffer; }

这段代码对纯ASCII字符串(英文字母、数字、英文标点)完全够用。但如果你发的是中文、emoji或者特殊符号,charCodeAt只能拿到UTF-16的码元,高位部分会丢,设备端解析出来就是乱码。所以更稳妥的做法是使用TextEncoder接口做UTF-8编码。在微信小程序的基础库较新版本里,TextEncoder是可用的;如果兼容性要求极高,也可以用一个简易的UTF-8编码函数替代。

function stringToBytes(str) { if (typeof TextEncoder !== 'undefined') { const encoder = new TextEncoder(); return encoder.encode(str); } const bytes = []; for (let i = 0; i < str.length; i++) { const codePoint = str.codePointAt(i); if (codePoint < 0x80) { bytes.push(codePoint); } else if (codePoint < 0x800) { bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)); } else { bytes.push(0xe0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f)); } if (codePoint > 0xffff) { i++; } } return new Uint8Array(bytes); }

这里有一个非常容易踩的细节:到底用ASCII编码还是UTF-8,取决于设备端的固件解析方式。比如设备端是按ASCII的0x41来解释“A”,那你用UTF-8发送纯英文字符串,结果是一样的;但如果包含中文,设备端按GBK解析的话,你发UTF-8就是乱码。所以在项目设计阶段,建议让硬件厂商明确通信协议里的编码要求,并直接写进文档里。

3.2 蓝牙写入核心代码与分包策略

拿到字节数据之后,就可以往特征值里写了。官方API调用如下:

wx.writeBLECharacteristicValue({ deviceId: that.deviceId, serviceId: that.serviceId, characteristicId: that.characteristicId, value: buffer, success(res) { // 写入成功 }, fail(err) { // 写入失败 } });

关键点来了:一次调用最多写入20字节。这是BLE协议里默认的“单包最大负载”,同时微信小程序层也对单次写入做了限制。如果你发送的字符串超过了20字节,直接写入大概率会报错或者被系统截断。

那超过20字节怎么办?核心思路就是分包发送。把长字符串切成长度不超过20字节的若干段,依次发送。分包发送有两个方案:

方案一:一次性把所有包都写入,不等待回调,邮寄式连发。这个方案在小数据量下似乎可以,但实际上设备端接收时很容易丢包、乱序,而且部分手机蓝牙栈会直接拒绝待处理的写入请求。

方案二:等上一包写入成功的回调触发之后,再延迟几十毫秒,发送下一包。这个方案稳妥得多。我实测下来,每包之间加 20ms 到 50ms 的延迟,基本不会丢,而且速度也够快。一个40字节的字符串发送一遍,总耗时约100毫秒,完全能满足交互需求。

下面是我写的一个完整分包发送函数,可以直接抄作业:

function sendLongString(str, options) { const bytes = stringToBytes(str); const chunkSize = 20; const chunks = []; for (let i = 0; i < bytes.length; i += chunkSize) { chunks.push(bytes.slice(i, i + chunkSize)); } let index = 0; const sendNext = () => { if (index >= chunks.length) { if (typeof options.onComplete === 'function') { options.onComplete(); } return; } const chunk = chunks[index]; const buffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength); wx.writeBLECharacteristicValue({ deviceId: options.deviceId, serviceId: options.serviceId, characteristicId: options.characteristicId, value: buffer, success() { index++; setTimeout(sendNext, options.interval || 30); }, fail(err) { if (typeof options.onError === 'function') { options.onError(err, index); } } }); }; sendNext(); }

这个函数有几个设计细节值得说明:

  • 先一次性把字节切片(chunk),避免了在异步回调里处理切片逻辑带来的脏数据。
  • 用递归加setTimeout串行发送,保证同一时间只有一个写入请求在途。
  • fail回调里把当前分包序号index传出去,方便定位是哪一段失败了。
  • 每一段都从原始的Uint8Array里切出独立的ArrayBuffer,避免多个ArrayBuffer视图共享底层内存导致写入数据错乱。

说到这,要特别提醒一下:有些设备端固件并不支持分段重组。比如设备端一次只接收一条完整指令,它一收到20字节就当作一条命令执行,那你的分包策略就没用了。遇到这种设备,正确做法是在应用层做协议封装,在字符串前面加上帧头、帧尾、长度字段,设备端根据长度字段自行拼包——但那就是另一个更复杂的项目了。标题场景里如果是简单的字符串透传,分包+延迟已经足够覆盖90%的用法。

3.3 写入属性writeNoResponse的特殊处理

前面提到,有些特征值只支持writeNoResponse,小程序写这种特征值时,wx.writeBLECharacteristicValue的success回调实际含义是“数据已交给系统蓝牙栈”,并不代表设备端一定收到了。这种模式下,分包策略尤其重要,因为没有任何确认机制,设备端少收一个包它也不会告诉你,可能等到超时才发现数据不完整。

我的经验是:对于writeNoResponse型特征值,可以引入一个应用层确认机制——也就是发送方发完所有分包后,再让设备端通过另一个 notify 特征值回一个OK或者把收到的数据原样回传。如果小程序在设定的超时时间内没收到确认,就提示重发。这在强干扰环境下(比如车间里多设备同时通信)特别管用。

function sendWithAck(str, options) { sendLongString(str, { deviceId: options.deviceId, serviceId: options.serviceId, characteristicId: options.characteristicId, interval: 30, onComplete() { // 启动一个3秒超时计时器 options.onAckTimeout(() => { // 提示重发或做重试 }); } }); // 在 onBLECharacteristicValueChange 里收到设备端 ack 后取消计时器 }

3.4 接收设备端返回数据

如果设备端收到指令之后会回一条确认消息,比如OK或ERR,小程序侧需要提前订阅这条通知通道。具体流程是:先找到支持notify或indicate的特征值,然后调用:

wx.notifyBLECharacteristicValueChange({ deviceId: that.deviceId, serviceId: that.serviceId, characteristicId: notifyCharacteristicId, state: true, success() { // 订阅成功,之后通过 onBLECharacteristicValueChange 接收数据 } });

订阅成功之后,要第一时间在全局注册wx.onBLECharacteristicValueChange监听。注意这个监听是全局性的,也就是说你在任何页面注册的都可能收到其他设备的数据。所以回调里一定要判断res.deviceId是否等于当前连接的设备,否则多设备场景下会串数据。

设备端返回的同样是一段ArrayBuffer,如果需要展示成字符串,逆转换一下即可:

function arrayBufferToString(buffer) { const bytes = new Uint8Array(buffer); let str = ''; for (let i = 0; i < bytes.length; i++) { str += String.fromCharCode(bytes[i]); } return str; }

如果要接收的是中文返回,还是建议用TextDecoder,或者让设备端始终回传ASCII字符(比如OK、ERR:2这种),在实际项目中能省掉一堆编码烦恼。

4. 常见问题与排查技巧实录

4.1 高频问题速查表

问题现象可能原因排查思路与解法
搜索不到设备未声明定位权限;设备不在广播状态;iOS与Android搜索策略差异核对app.json配置;确认设备处于可广播状态;尝试过滤特定Service UUID搜索
连接成功但服务列表为空设备端没有开启服务广播;缓存了空洞数据断开后清缓存重新连接;用工具确认设备端Service的UUID是否正确广播
能连接,但20字节以上数据失败忽略了单包限制改用分包发送,串行发送并加延迟
写入返回错误码10005特征值不支持当前写入方式打印特征值Properties,确认是否支持write,或改用writeNoResponse
收到乱码编码不一致确认小程序侧是UTF-8还是ASCII,设备端按什么解析,统一约定
iOS能连,Android连不上适配器未完全初始化在openBluetoothAdapter的success后再启动搜索,严禁“背靠背”调用
写入没有回调特征值是writeNoResponse类型按无确认模式处理,加应用层ack机制

这张表算是把日常开发中最常见的值都覆盖到了。每个问题背后其实都对应着“设备端固件设计”和“小程序端实现”两种归因路径。排查的时候一定不要只盯着一端看,小程序API报错是一方面,设备端有没有正确上电、有没有在处理数据,是另一方面。蓝牙项目是一个人机协同的调试过程,只改一端往往是白费功夫。

4.2 那个让人头大的“偶发断开”

连接过程中最烦人的问题是连接不稳定,尤其Android上。我在模拟项目X的调试阶段,遇到过无数次连上一个设备,几秒后自动断开的问题。后来跟踪日志发现,是我在连接成功后立刻调了wx.getBLEDeviceServices,根本没有等系统蓝牙适配器完全切换到连接状态,导致底层状态机错乱。

解决办法很简单:连接成功的回调里加一个小延迟,再获取服务,比如setTimeout(() => wx.getBLEDeviceServices(...), 100)。这个“人工保险丝”虽然看起来笨,但确实能规避大量老旧手机蓝牙栈的状态不同步问题。另外,如果设备固件在连接后还会重新初始化广播参数,也容易导致系统端误判连接断开,这就只能找硬件工程师一起抓log分析了。

4.3 老生常谈的“MTU限制”误区

很多人以为20字节限制是微信定的,其实底层更准确的解释是:BLE 4.0/4.1 时代默认的 MTU(最大传输单元)只有23字节,扣掉 ATT 头就是20字节有效载荷。虽然BLE 4.2以后支持扩展MTU到247字节,微信也在部分手机上支持写更长数据,但为了兼容性,老老实实以20字节为分包基准是最稳的。

如果你想追求更高吞吐,可以考虑用wx.getBLEMTU查询当前手机的信道MTU值(部分版本支持)。但我的建议是:除非你传的是文件流、音频采样等高波特率场景,否则对字符串指令这种小数据量,用20字节分包就足够了,别为这点吞吐量折腾兼容性问题。

4.4 真机调试技巧与工具链推荐

调试蓝牙项目,光靠微信开发者工具的模拟器是不行的,很多API在模拟器上要么不可用,要么行为与真机差异极大。我的习惯是:每写一个阶段,立刻在真机上跑一遍完整流程。真机调试时用vConsole插件或直接在wx.getLogManager里打日志,别看手机上小屏日志难受,关键时候比什么都强。

另外推荐准备一套嗅探工具,比如一台能开抓包模式的老Android手机或专业BLE调试器。虽然微信开发者工具里可以打印API回调日志,但要确认设备端到底收没收到数据、收的是不是完整数据,还是得从“空气里”抓一手数据才放心。在小组织环境里,这通常也是唯一能单方面定位问题的办法。否则你只能频繁找硬件工程师配合,两边来回猜,项目周期直接被拉长。

5. 能力扩展与工程化建议

5.1 从“发字符串”到“发完整指令协议”

如果你已经实现了字符串发送,那距离完整指令协议只差一步。工程上通常会设计一个简单的帧格式,比如:

[帧头 0xAA] [长度] [载荷...] [校验和]

小程序侧负责把字符串指令塞进这个帧格式里,计算校验和,再分包发送。设备端收到后严格按帧格式解析,校验错误就回一个ERR。这种设计最早看起来有点“浪费”,但一旦你的指令种类变多、数据敏感度变高,它带来的排查效率提升是立竿见影的。至少不用再为“设备端到底收到的是ON还是O+N(恰好被拆成两包)”这种问题头大。

具体帧格式的例子:

function buildFrame(cmdStr) { const payload = stringToBytes(cmdStr); const len = payload.length; const frameLen = len + 4; // 帧头 + 长度 + 数据 + 校验 const frame = new Uint8Array(frameLen); frame[0] = 0xAA; frame[1] = len; frame.set(payload, 2); let checksum = 0; for (let i = 0; i < len; i++) { checksum += payload[i]; } frame[frameLen - 1] = checksum & 0xFF; return frame.buffer; }

像这种帧结构,设备端固件里也配套相同的解析逻辑,整个链路的可靠性会有一个质的提升。

5.2 多设备管理与连接池设计

做智能硬件类小程序,一个页面往往要同时管理多台设备。这种情况下,如果继续用“全局单一回调”的结构,代码会迅速失控。我的建议是可以设计一个简单的连接管理器:

  • 每个设备用一个实例对象封装,管理自己的deviceId、serviceId、characteristicId、状态机。
  • 全局监听只做事件分发,把回调按deviceId路由到对应实例。
  • 写数据时通过实例方法调用,而不是在页面里直接操作全局API。

这套方案初版写的时候确实多花点时间,但到后期加设备、加指令时就能看到明显收益。如果你打算做一个小的BLE工具集或者设备管理平台,这个方向值得投入。

5.3 性能与鲁棒性进一步优化

字符串数据本身很小,性能瓶颈通常不在“发数据”,而在“状态处理”。真正影响体验的往往是初始化慢、搜索慢、连接慢。这里有几个可以优化的点:

  • 过滤搜索:如果你知道设备的Service UUID,搜索时用services参数过滤,能显著缩短发现时间。
  • 做好重试:连接失败不要立刻放弃,间隔几百毫秒重试两三次往往能成功。实测发现设备端临时忙、或系统蓝牙栈尚未释放资源时,重试太急反而更容易失败。
  • 合理使用缓存:有一些已经连接过的设备短时间内重新进入页面,可以用wx.getConnectedBluetoothDevices查一下,避免重新走扫描流程。

我已经在几个项目里用上了这套优化思路,最直观的感受是连接成功率从最初的七八成上升到接近满血。尤其在做演示和交付的时候,多一台机器能连上,比什么都重要。

关于“微信小程序蓝牙发送数据给设备,支持发送字符串”这个主题,核心其实就一句话:看懂协议、管好状态、处理好编码、分好包。做到这四点,你手里这套代码基本可以适配市面上大多数BLE设备。如果再往后扩展,把指令帧协议、多设备管理、可靠重传机制都补上,那就真正具备工程价值了。

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

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

立即咨询