Skip to main content

微信小程序蓝牙通信示例

· 19 min read
Allen
software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

作为开发者,最讨厌的事情莫过于多平台适配,在手机端由于大家型号不同,编个APP通过蓝牙控制显然是不方便的,于是做了一个蓝牙小程序来与ESP32通信。

界面设计

这里我想设计成方块按键的格式,所以创建一个矩阵,然后在矩阵对应的位置添加上按钮。非常的简单,只是有一些差异需要注意。

wxml标签

蓝牙小程序标签与html略有不同,以下是小程序标签(即wxml标签)与 HTML 略有不同的标签的对比表:

wxml标签HTML 标签描述
<view><div>用于容器和布局,类似于 HTML 中的 <div>
<text><span>用于文本显示,类似于 HTML 中的 <span>
<button><button>用于创建按钮,与 HTML 中的 <button> 功能相同。
<image><img>用于显示图片,类似于 HTML 中的 <img>,但属性有所不同。
<navigator><a>用于页面导航,类似于 HTML 中的 <a> 标签。
<picker>N/A用于多种选择器,HTML 中无直接对应的标签。
<scroll-view>N/A用于可滚动的视图区域,HTML 中无直接对应的标签。
<swiper>N/A用于滑动视图容器,HTML 中无直接对应的标签。
<map><iframe>用于展示地图,类似于 HTML 中嵌入地图的方式。
<swiper-item>N/A<swiper> 配合使用,HTML 中无直接对应的标签。
<rich-text>N/A用于展示富文本,HTML 中无直接对应的标签。
<block>N/A无实际渲染效果,类似于 HTML 中的 <template>

JS

以下是微信小程序的 JavaScript(JS)与网页的 JavaScript 的对比表格:

特性/功能微信小程序 JS网页 JS描述
全局对象wxwindow微信小程序中使用 wx 对象来调用特定的 API,而在网页 JS 中,所有的全局对象都挂载在 window 对象下。
API 调用基于 wx 对象提供的 API,如 wx.request()wx.navigateTo()使用浏览器提供的 API,如 fetch()window.location微信小程序有一套独立的 API,专门用于微信环境下的开发,无法直接使用标准的浏览器 API。
页面与组件管理通过小程序的 PageComponent 函数定义页面和组件通过 HTML 文件和 JavaScript 结合使用前端框架或直接操作 DOM微信小程序使用特殊的 PageComponent 函数来定义页面和组件,网页 JS 则通过 DOM 结合 JavaScript 实现页面和组件管理。
数据绑定使用 this.setData() 进行数据绑定和更新通常使用 innerHTMLtextContent 或前端框架(如 React 的 setState微信小程序使用 this.setData() 来绑定和更新数据,而在网页 JS 中,常通过直接操作 DOM 或使用前端框架来更新数据。
生命周期函数提供页面与组件的生命周期函数,如 onLoadonShow通过事件绑定或框架提供的生命周期函数(如 React 的 componentDidMount微信小程序有特定的生命周期函数供开发者使用,而网页 JS 通常需要结合框架或事件来处理生命周期管理。
路由与导航使用 wx.navigateTo()wx.redirectTo() 等方法进行页面跳转通过改变 window.location 或使用 history.pushState() 进行路由小程序的路由机制是由微信管理的,开发者需要使用专门的 API 进行导航,而网页 JS 可以直接操作 URL。
模块化使用 require() 和模块化文件系统使用 ES6 import/export 或 CommonJS 模块系统小程序内置的模块化系统与 Node.js 类似,使用 require() 导入模块,而网页 JS 中可以使用 ES6 模块或 CommonJS 模块系统。
网络请求使用 wx.request() 发起 HTTP 请求使用 fetch()XMLHttpRequest 发起 HTTP 请求微信小程序提供了 wx.request() 方法用于网络请求,而网页 JS 通常使用 fetch()XMLHttpRequest
文件系统访问通过 wx.getFileSystemManager() 访问文件系统通过 File API、Blob、FileReader 等访问文件小程序提供了 wx.getFileSystemManager() 接口来管理文件系统,而网页 JS 可以使用浏览器提供的 File API。
样式与布局使用 WXML 和 WXSS 定义页面结构和样式使用 HTML 和 CSS 定义页面结构和样式微信小程序使用 WXML 和 WXSS 分别来代替 HTML 和 CSS,专门为小程序定制。
事件处理事件绑定使用 bindtapcatchtap 等绑定事件事件绑定使用 addEventListener 或内联 onclick微信小程序的事件处理是通过特定的属性绑定事件,而网页 JS 可以直接使用标准的事件绑定方法。
调试与工具使用微信开发者工具进行调试使用浏览器的开发者工具进行调试小程序开发和调试通常在微信开发者工具中进行,而网页开发则依赖于浏览器提供的开发者工具。
存储提供 wx.setStorage()wx.getStorage() 进行数据持久化存储使用 localStoragesessionStorage 进行数据存储微信小程序的存储 API 类似于浏览器的 localStorage,但使用 wx 提供的 API 进行调用。
原生 API 调用不支持直接调用浏览器或操作系统的原生 API可以使用浏览器 API 或通过插件访问系统 API微信小程序无法直接调用浏览器或操作系统的原生 API,而网页 JS 则可以直接使用这些 API。
平台限制运行于微信环境,仅支持在微信客户端中运行运行于浏览器环境,可以在任何支持的浏览器中运行小程序只能在微信客户端中运行,而网页 JS 则可以在任何现代浏览器中运行。

页面代码

界面部分因为手机的尺寸实在太多,所以我创建一个矩阵,然后把有按键的地方加上边框实现规则布局。中间输入设备名称。

在正常的网页开发过程之中。缺省是非常常见的,也就是某一个属性大门不去填写的时候浏览器会默认给一个属性。但是在小程序开发过程之中,他并不会给一个默认的属性或者是说他给的默认属性与浏览器的不同。这就会导致在模拟的时候看到的界面是一个样子(因为使用的是浏览器渲染),真机调试的时候又是另一个样子。因为在手机上跑的服务是小程序自己的编译后的,跑在手机上的小程序环境。

所以我们需要把必要的属性全部都填写上。

<scroll-view class="scrollarea" scroll-y type="list">
<view class="container">
<text>蓝牙连接状态:{{status}}</text>
<div class="button-lines">
<input type="text" placeholder="请输入设备名称" bindinput="onDeviceNameInput" />
</div>
<!-- 3x7 矩阵布局 -->
<view class="button-grid">
<view class="row">
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendUp" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="connectDevice">连接</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendA">A</button></view>
<view class="cell"></view>
</view>
<view class="row">
<view class="cell"><button bindtouchstart="sendLeft" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendRight" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendB">B</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendD">D</button></view>
</view>
<view class="row">
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendDown" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendData">文件</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendC" >C</button></view>
<view class="cell"></view>
</view>
</view>
</view>
</scroll-view>

样式代码

/**index.wxss**/
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.container {
padding: 20px;
}
.button-lines {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
input{
border: 1px solid #ccc;
padding: 8px;
margin-right: 5px;
width: 50%;
height: 30%;
}
.button-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

.row {
width: 100%;
display: flex;
justify-content: space-between;
}

.cell{
width: 12%;
height: 50px;
margin: 5px;

display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}

.cell button{
border: 1px solid #ccc;
}
button {
width: 100%;
height: 100%;
box-sizing: border-box;
}

逻辑设计

考虑到不同设备的蓝牙名称不同,因此我在页面的中间设计一个输入框,输入对应的设备名称(大小写敏感)后,点击连接按钮,即可触发搜索接口。为了让自己知道是否已经连接上,我在输入框的上面添加了一个状态显示,考虑到部分用户不能理解红色绿色的默认含义,我使用了中文来描述连接状态。

底部做了一些按键发送数据的功能,包括:中文上下左右、英文ABCD、还有大文件一键传输(我设置了范围为txt和py)

tip

网页开发中,浏览器的渲染主线程会在解析DOM树的时候给所有HTML节点根据权重添加上属性,而小程序中,一旦缺省关键的属性,在开发界面会正常显示,上真机就会异常,这点尤其需要注意。

蓝牙设备的搜索、连接等功能由微信的API接口提供,其中蓝牙的权限上,如果使用的是:仅在使用中允许,在部分安卓手机上,会出现切后台再返回时蓝牙权限丢失的情况。因此改为:每次使用时询问权限。目前在官方论坛上留言了,我更倾向于是安卓设备的问题。

另外安卓中蓝牙权限与位置权限关联,因此仅开启蓝牙权限依然无法使用。

微信的蓝牙接口搜索到的设备便不再出现,假设我周边存在设备A、设备B、设备C

如果我首先输入了设备B,蓝牙搜索API根据信号强弱依次返回:设备A、设备B(判定成功,建立连接)

此时我再输入设备A,点击连接,就会出现搜不到设备的情况,当然这里是可以优化的,设置一个点击按钮:刷新。不过我右上角点击重新进入小程序也是可以的,所以这里就不是很有必要加这个逻辑判断。

蓝牙设备的连接非常简单,根据参考文档一步一步来即可,需要注意的是,发送中文时可能会乱码,JS原生的解码又不能用,所以我导入了一个包import TextEncoder from './miniprogram-text-encoder'来自动判断文本是中文还是英文执行对应的转化。

既然蓝牙可以通信,传输中文和英文,那么是不是可以传本书过去?首先尝试直接传输,发现接收方只收到了前20字节,后续数据丢失。那么修改程序,将文件分片、每次发20个字节,发送完成之后在发送一个END标记。

和之前发送数据的代码写在一起,就变成了这样:


import TextEncoder from './miniprogram-text-encoder'

Page({
data: {
status: '未连接',
deviceId: null,
serviceId: null,
characteristicId: null,
deviceName: 'None' // 默认设备名称
},
onLoad() {
this.initBluetooth();
},
onDeviceNameInput(e) {
console.log(e.detail.value);
this.setData({
deviceName: e.detail.value,
})
;
},
initBluetooth() {
const that = this;
wx.openBluetoothAdapter({
success(res) {
console.log('初始化蓝牙适配器成功');
that.startBluetoothDevicesDiscovery();
wx.showToast({
title: '蓝牙权限成功',
icon: 'success',
duration: 2000
});
},
fail(res) {
console.log('初始化蓝牙适配器失败', res);
wx.showToast({
title: '蓝牙权限失败',
icon: 'error',
duration: 2000
});
}
});
},
startBluetoothDevicesDiscovery() {
const that = this;
console.log(that.data.deviceName, '57');

// 如果 deviceName 是 "None",不进行蓝牙设备搜索
if (that.data.deviceName === "None") {
console.log('设备名称为 "None",不进行蓝牙设备搜索');
return;
}

wx.startBluetoothDevicesDiscovery({
success(res) {
console.log('开始搜索蓝牙设备');
that.onBluetoothDeviceFound();
},
fail(res) {
console.log('搜索蓝牙设备失败', res);
}
});
},
onBluetoothDeviceFound() {
const that = this;
wx.onBluetoothDeviceFound((devices) => {
devices.devices.forEach(device => {
console.log('发现设备名称:', device.name); // 打印所有发现的设备名称
if (device.name === that.data.deviceName) {
wx.showToast({
title: '发现蓝牙设备',
icon: 'success',
duration: 2000
});
that.createBLEConnection(device.deviceId);
}
});
});
},
createBLEConnection(deviceId) {
const that = this;
wx.createBLEConnection({
deviceId: deviceId,
success(res) {
console.log('连接蓝牙设备成功');
that.setData({
status: '已连接',
deviceId: deviceId
});
that.getBLEDeviceServices(deviceId);
},
fail(res) {
console.log('连接蓝牙设备失败', res);
}
});
},
getBLEDeviceServices(deviceId) {
const that = this;
wx.getBLEDeviceServices({
deviceId: deviceId,
success(res) {
console.log('获取服务成功:', res.services);
for (let i = 0; i < res.services.length; i++) {
if (res.services[i].isPrimary) {
that.getBLEDeviceCharacteristics(deviceId, res.services[i].uuid);
return;
}
}
}
});
},
getBLEDeviceCharacteristics(deviceId, serviceId) {
const that = this;
wx.getBLEDeviceCharacteristics({
deviceId: deviceId,
serviceId: serviceId,
success(res) {
console.log('获取特征值成功:', res.characteristics);
for (let i = 0; i < res.characteristics.length; i++) {
if (res.characteristics[i].properties.write) {
that.setData({
serviceId: serviceId,
characteristicId: res.characteristics[i].uuid
});
return;
}
}
}
});
},
connectDevice() {
this.startBluetoothDevicesDiscovery();
},
sendData() {
const that = this;
// 选择本地 TXT 或 PY 文件
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['txt', 'py'],
success(res) {
const filePath = res.tempFiles[0].path;
const fileName = res.tempFiles[0].name;

// 读取文件内容为 ArrayBuffer
wx.getFileSystemManager().readFile({
filePath: filePath,
success(readRes) {
const fileBuffer = readRes.data;
console.log(readRes.data)
const chunkSize = 20; // 每次发送20字节
const totalChunks = Math.ceil(fileBuffer.byteLength / chunkSize);

// 发送文件名称和分片数
const fileInfo = `${fileName}|${totalChunks}`;
const fileInfoBuffer = that.stringToArrayBuffer(fileInfo);
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: fileInfoBuffer,
success(res) {
console.log('文件信息发送成功');
},
fail(res) {
console.error('文件信息发送失败', res);
}
});

// 逐块发送文件数据
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, fileBuffer.byteLength);
const chunk = fileBuffer.slice(start, end);
const progress = ((i + 1) / totalChunks) * 100;

// 发送当前块数据
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: chunk,
success(res) {
console.log(`数据发送成功: ${i + 1}/${totalChunks} (${progress}%)`);
if (i === totalChunks - 1) {
// 发送结束标志
const endBuffer = that.stringToArrayBuffer('END');
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: endBuffer,
success(res) {
console.log('所有数据发送完成');
}
});
}
},
fail(res) {
console.error(`数据发送失败: ${i + 1}/${totalChunks}`, res);
}
});
}
},
fail(err) {
console.error('文件读取失败', err);
}
});
},
fail(err) {
console.error('文件选择失败', err);
}
});
},

// 将字符串转换为 ArrayBuffer
stringToArrayBuffer(str) {
const base64 = wx.arrayBufferToBase64(new TextEncoder().encode(str).buffer);
return wx.base64ToArrayBuffer(base64);
},

// 发送控制消息
sendMessage(message) {
const that = this;
const buffer = that.stringToArrayBuffer(message);
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: buffer,
success(res) {
console.log(`消息发送成功: ${message}`);
},
fail(res) {
console.error(`消息发送失败: ${message}`, res);
}
});
},
// 松开按钮时发送消息
handleTouchEnd() {
this.sendMessage('释放');
},
sendUp() {
this.sendMessage('上');
},
sendDown() {
this.sendMessage('下');
},
sendLeft() {
this.sendMessage('左');
},
sendRight() {
this.sendMessage('右');
},
sendA() {
this.sendMessage('A');
},
sendB() {
this.sendMessage('B');
},
sendC() {
this.sendMessage('C');
},
sendD() {
this.sendMessage('D');
}

安卓系统有一个"运行时允许权限",该权限在不可复现的场景下会出现后台程序还在运行,但权限未授予。可以改为本次使用允许,让每次使用时都询问获取权限。

后话

这里是完整代码

程序的优化是无穷无尽的,所以这里我只实现了最少的功能,如果项目对你有帮助,不用问我,直接拿去用。