上个月接了个智能体重秤的项目。客户原本有一批体重秤,每台会通过蓝牙广播体重数据,想在 uni-app 里直接读出来展示,顺便存个历史记录。
说实话在此之前我对 BLE 协议只知道”有个 GATT”,动手之后才明白,蓝牙这块最难的不是 API 怎么写——uni-app 已经把微信小程序的蓝牙 API 封得挺顺手——难在 Android 平台的权限变化,以及设备之间私有协议的数据解析。这篇文章把我一周里踩过的坑和跑通后的完整代码都记下来。
先把 GATT 概念捋清楚
BLE 设备对外暴露的不是”接口”,而是一棵三层的树:
- Service(服务):一组相关功能的集合。比如”体重测量”是一个服务,”设备信息”是另一个服务。
- Characteristic(特征值):服务里具体的数据点。能读的、能写的、能订阅通知的,都是特征值。
- Descriptor(描述符):给特征值补充元信息,比如通知开关。
每个节点都有一个 128 位的 UUID。为了方便,蓝牙标准里对常用的 UUID 规定了一个 16 位的短格式,比如 0x180F 是电池服务,跟标准的长 UUID(0000180F-0000-1000-8000-00805F9B34FB)是等价的。各家厂商自己的私有服务则会用完整的长 UUID。
体重秤的数据通路一般是这样的:
连接设备
→ 拿到所有 Service
→ 找到「秤数据服务」
→ 找到其中的「体重值特征值」
→ 订阅它的通知
→ 每次测量时,设备主动往这个特征值写数据
→ 我们的回调收到数据,解析成体重
所以整个流程里,唯一需要”厂商告诉你的”就是那几个 UUID。拿到 UUID,剩下的代码跟什么设备都没关系。
Android 权限这一关躲不开
Android 从 12 开始把蓝牙权限拆成了三条:
BLUETOOTH_SCAN // 扫描附近设备
BLUETOOTH_CONNECT // 连接设备、读设备名
BLUETOOTH_ADVERTISE // 广播(我们不用)
而在 Android 11 及以下,扫描附近设备被归为”位置”用途,必须申请 ACCESS_FINE_LOCATION,而且系统定位服务必须处于开启状态。这是新手最容易被卡住的地方:代码写得没错,设备也在旁边,就是扫不到。
在 uni-app 的项目里,manifest.json 的 app-plus.distribute.android.permissions 数组里把两个时期需要的权限都写上:
"permissions": [
"<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>",
"<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>",
"<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>",
"<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>"
]
运行时权限在代码里再请求一次。判断运行版本可以用 plus.os.version:
function ensureAndroidPermission() {
if (uni.getSystemInfoSync().platform !== 'android') {
return Promise.resolve(true)
}
const permissions = []
// #ifdef APP-PLUS
const androidMajor = parseInt(plus.os.version, 10)
if (androidMajor >= 12) {
permissions.push('android.permission.BLUETOOTH_SCAN')
permissions.push('android.permission.BLUETOOTH_CONNECT')
} else {
permissions.push('android.permission.ACCESS_FINE_LOCATION')
}
// #endif
return new Promise((resolve) => {
plus.android.requestPermissions(
permissions,
(result) => {
const denied = result.deniedPresent.concat(result.deniedAlways)
resolve(denied.length === 0)
},
(err) => {
console.error('权限请求失败', err)
resolve(false)
}
)
})
}
这里有个细节值得一提:用户在系统弹窗里点”拒绝”之后,Android 12 及以上不会再弹第二次。下次调用 requestPermissions 会直接返回拒绝。这时候得引导用户去设置页手动开权限,代码里可以读 deniedAlways 判断用户是否勾选了”拒绝且不再询问”。
把整个流程封成一个类
用回调嵌套写这段代码会很难看,用 Promise 包一层清爽很多。下面这个类是我最后跑的版本,去掉业务部分,剩下的几乎可以直接复用。
// utils/ble.js
export class BleClient {
constructor() {
this.deviceId = ''
this.serviceId = ''
this.characteristicId = ''
this.notifyHandler = null
this.onData = null
this.connected = false
}
// 开启适配器
async openAdapter() {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success: resolve,
fail: (err) => reject(new Error('蓝牙适配器打开失败:' + err.errMsg))
})
})
}
// 扫描附近设备,按名字前缀过滤
async startDiscovery(namePrefix, timeout = 8000) {
await this.closeDiscovery()
return new Promise((resolve, reject) => {
const found = []
let done = false
const onFound = (res) => {
res.devices.forEach((d) => {
if (!d.name || !d.name.startsWith(namePrefix)) return
if (found.some((x) => x.deviceId === d.deviceId)) return
found.push(d)
})
}
uni.onBluetoothDeviceFound(onFound)
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
fail: (err) => {
uni.offBluetoothDeviceFound(onFound)
reject(new Error('开始扫描失败:' + err.errMsg))
}
})
setTimeout(async () => {
if (done) return
done = true
uni.offBluetoothDeviceFound(onFound)
await this.closeDiscovery()
if (found.length === 0) {
reject(new Error('未找到匹配的设备'))
} else {
resolve(found)
}
}, timeout)
})
}
async closeDiscovery() {
return new Promise((resolve) => {
uni.stopBluetoothDevicesDiscovery({
complete: () => resolve()
})
})
}
// 连接
async connect(deviceId) {
return new Promise((resolve, reject) => {
uni.createBLEConnection({
deviceId,
timeout: 10000,
success: () => {
this.deviceId = deviceId
this.connected = true
resolve()
},
fail: (err) => reject(new Error('连接失败:' + err.errMsg))
})
})
}
// 发现服务与特征值
async discover(serviceUuid, characteristicUuid) {
const services = await this.getServices()
const target = services.find(
(s) => s.uuid.toUpperCase() === serviceUuid.toUpperCase()
)
if (!target) {
throw new Error('设备没有暴露指定的服务:' + serviceUuid)
}
this.serviceId = target.uuid
const chars = await this.getCharacteristics(this.serviceId)
const char = chars.find(
(c) => c.uuid.toUpperCase() === characteristicUuid.toUpperCase()
)
if (!char) {
throw new Error('服务里没有找到指定的特征值:' + characteristicUuid)
}
this.characteristicId = char.uuid
return char
}
getServices() {
return new Promise((resolve, reject) => {
uni.getBLEDeviceServices({
deviceId: this.deviceId,
success: (res) => resolve(res.services),
fail: (err) => reject(new Error('获取服务失败:' + err.errMsg))
})
})
}
getCharacteristics(serviceId) {
return new Promise((resolve, reject) => {
uni.getBLEDeviceCharacteristics({
deviceId: this.deviceId,
serviceId,
success: (res) => resolve(res.characteristics),
fail: (err) => reject(new Error('获取特征值失败:' + err.errMsg))
})
})
}
// 订阅通知
async subscribe(handler) {
this.onData = handler
if (!this.notifyHandler) {
this.notifyHandler = (res) => {
if (res.deviceId !== this.deviceId) return
if (res.characteristicId.toUpperCase() !== this.characteristicId.toUpperCase()) return
if (this.onData) this.onData(res.value)
}
uni.onBLECharacteristicValueChange(this.notifyHandler)
}
return new Promise((resolve, reject) => {
uni.notifyBLECharacteristicValueChange({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.characteristicId,
state: true,
success: resolve,
fail: (err) => reject(new Error('订阅通知失败:' + err.errMsg))
})
})
}
async disconnect() {
if (!this.deviceId) return
if (this.notifyHandler) {
uni.offBLECharacteristicValueChange(this.notifyHandler)
this.notifyHandler = null
}
return new Promise((resolve) => {
uni.closeBLEConnection({
deviceId: this.deviceId,
complete: () => {
this.connected = false
this.deviceId = ''
resolve()
}
})
})
}
}
几点说明。
扫描那块的 setTimeout 是主动设的。BLE 扫描很费电,找到设备之后应该立刻停掉,但不找到又没法确认设备不在附近,所以给一个 8 秒的窗口,超时就返回”未找到”。业务里可以再给用户一个”重新扫描”按钮。
onBluetoothDeviceFound 每发现新设备就会回调一次,里面 devices 是数组。要按 deviceId 去重,否则同一个设备可能被加进去好几遍。
UUID 比较那几行统一加了 toUpperCase()。不是多余——Android 返回的是小写,iOS 返回的是大写,微信小程序在不同平台上返回的大小写也不完全一致。第一版我按原样比较,iOS 上跑得挺溜,Android 上一台设备都连不上。查了半天才发现是大小写的问题。
数据解析:十六进制与字节序
订阅成功后,回调收到的 res.value 是一个 ArrayBuffer,得转成字节数组才好处理。
function toBytes(buffer) {
return new Uint8Array(buffer)
}
function bytesToHex(bytes) {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join(' ')
}
我手上这台体重秤的协议是这样的:
第 0 字节:0x5A,固定包头
第 1 字节:0x0D,表示这是体重数据
第 2-3 字节:单位 0.01kg,小端序
第 4 字节:校验和(前四字节之和的低 8 位)
其余:保留字节
第三行里”小端序”是关键。同一份数据,高字节和低字节在协议里的排列顺序不同,读出来的数值可能差了几百倍。小端序的解读方式:
function parseWeight(bytes) {
if (bytes[0] !== 0x5A) {
return null
}
if (bytes[1] !== 0x0D) {
return null
}
const expected = (bytes[0] + bytes[1] + bytes[2] + bytes[3]) & 0xFF
if (expected !== bytes[4]) {
console.warn('校验失败:', bytesToHex(bytes))
return null
}
// 小端:低位在前
const raw = bytes[2] | (bytes[3] << 8)
const kg = raw / 100
return Number(kg.toFixed(2))
}
比如字节序列是 [0x5A, 0x0D, 0x2C, 0x1B, ...]:0x2C 是低位、0x1B 是高位,拼出来是 0x1B2C,等于 6956,除以 100 就是 69.56kg。
如果换成大端序解读,0x2C1B 是 11291,出来的 112.91kg 就成了笑话。第一次接上秤看到这个数字的时候我还以为秤坏了。
有一些设备每次测量会推两遍数据,一次是”测量中”的实时值,一次是”测量完成”的最终值。这种可以看协议文档里有没有状态位,或者在应用层做去重。我这边是在协议里加了一个标志位,如果没标就不用管。
断线重连怎么写才靠谱
BLE 连接本身就容易被各种因素打断:走出信号范围、其他应用抢连接、手机息屏、用户切换蓝牙开关。不处理断线,用户会遇到”莫名其妙连不上了”这类问题。
uni-app 提供了 onBLEConnectionStateChange 来监听连接状态。我把它包装成一个简单的重试策略:
async function connectWithRetry(client, deviceId, maxAttempts = 3) {
let lastError = null
for (let i = 1; i <= maxAttempts; i++) {
try {
await client.connect(deviceId)
return true
} catch (err) {
lastError = err
console.warn(`第 ${i} 次连接失败:${err.message}`)
// 关闭旧连接,避免残留
await client.disconnect()
// 指数退避:1s / 2s / 4s
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i - 1)))
}
}
throw lastError || new Error('连接失败')
}
指数退避不是为了炫技。createBLEConnection 失败之后立刻重试,Android 底层经常还处在中间状态,会返回 GATT_ERROR 133 这种含糊的错误。间隔一两秒再试,成功率会高不少。
connect 之前先 disconnect 也是个经验。如果上一次连接没彻底关闭,直接再连新设备,会有两个连接同时挂着,容易出问题。
另外,Android 上有一种情况是连接本身还在、但订阅断了。可以在 onBLEConnectionStateChange 里判断,如果发现 connected: false,主动把 client.disconnect() 调一遍,把内部状态清空,然后再进入重连流程。
页面里怎么用
把类封好之后,页面里就简洁了。下面是一个简化的体重测量页:
<template>
<view class="page">
<view class="status">{{ statusText }}</view>
<view class="weight" v-if="weight !== null">
<text class="number">{{ weight }}</text>
<text class="unit">kg</text>
</view>
<button v-if="!connected" @click="handleConnect">连接体重秤</button>
<button v-else @click="handleDisconnect">断开连接</button>
<view v-if="history.length" class="history">
<view class="history-title">测量记录</view>
<view v-for="(item, idx) in history" :key="idx" class="history-item">
<text>{{ item.time }}</text>
<text>{{ item.weight }} kg</text>
</view>
</view>
</view>
</template>
<script>
import { BleClient } from '@/utils/ble.js'
const SERVICE_UUID = 'FFF0'
const CHARACTERISTIC_UUID = 'FFF1'
const DEVICE_PREFIX = 'Scale-'
export default {
data() {
return {
client: null,
statusText: '未连接',
connected: false,
weight: null,
history: [],
lastWeight: null
}
},
onUnload() {
if (this.client) {
this.client.disconnect()
}
},
methods: {
async handleConnect() {
this.client = new BleClient()
try {
this.statusText = '正在申请权限…'
const granted = await this.ensurePermission()
if (!granted) {
this.statusText = '请在系统设置里开启蓝牙权限'
return
}
this.statusText = '正在打开蓝牙…'
await this.client.openAdapter()
this.statusText = '正在扫描附近设备…'
const devices = await this.client.startDiscovery(DEVICE_PREFIX)
const target = devices[0]
this.statusText = `正在连接 ${target.name || target.deviceId}…`
await this.connectWithRetry(target.deviceId)
this.statusText = '正在发现服务…'
await this.client.discover(SERVICE_UUID, CHARACTERISTIC_UUID)
this.statusText = '订阅数据…'
await this.client.subscribe((buffer) => {
this.handleRawData(buffer)
})
this.connected = true
this.statusText = '已连接,请站上体重秤'
} catch (err) {
console.error(err)
this.statusText = err.message || '连接失败'
if (this.client) await this.client.disconnect()
}
},
async handleDisconnect() {
if (this.client) {
await this.client.disconnect()
}
this.connected = false
this.statusText = '已断开'
},
handleRawData(buffer) {
const bytes = new Uint8Array(buffer)
const weight = parseWeight(bytes)
if (weight === null) return
if (weight === this.lastWeight) return
this.lastWeight = weight
this.weight = weight
const now = new Date()
this.history.unshift({
time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`,
weight
})
},
async ensurePermission() {
// 复用上面那个 ensureAndroidPermission 的实现
return true
},
async connectWithRetry(deviceId) {
// 复用上面的重连实现
await this.client.connect(deviceId)
}
}
}
</script>
两个细节说一下。
handleRawData 里有一个 weight === this.lastWeight 判断。有些秤在放上物体之后会连续推送好几次同一个数值,如果不过滤,历史记录里会出现一连串重复项。判断相等就跳过。
onUnload 里断开连接很重要。页面退出不主动断开,蓝牙连接会继续挂着,用户下次进页面时连接的可能是上一次的对象,数据会乱。
几个真机调试才发现的坑
扫描需要定位服务是开启的
Android 11 及以下,ACCESS_FINE_LOCATION 权限给了还不够,系统下拉菜单里的”定位”开关也必须是打开的。这个没有 API 能绕过,只能在页面上提示用户。用户如果反馈”权限都给了还是扫不到”,第一个要查的就是这个。
onBluetoothDeviceFound 只注册一次
这个 API 是全局的,多次调用 uni.onBluetoothDeviceFound 不会覆盖,而是叠加。如果每次扫描都注册一次,回调会被执行 N 次。所以我在类里把它放到 subscribe 的懒加载里,只注册一次,然后用 offBluetoothDeviceFound 把对应的引用摘掉。
UUID 大写小写要统一
前面提过一遍,但值得再强调。写死在代码里的常量就保持全大写,读到设备的 UUID 之后统一转大写再比。一开始偷懒用 === 比较,结果 Android 上死活连不上,看日志才发现一个 FFF0 一个小写的 fff0。
不要自动重连太频繁
有一些设备一旦连接断开,需要一两秒的恢复时间才能再次接受连接。重连间隔给到 1 秒以上,别写成 while(true) 里瞬间重试。Android 后台的 GATT 有一定几率在频繁重连时进入一个卡死状态,只能重启蓝牙才能恢复。
真机调试不要用模拟器
这一条是常识,但我还是写一下。模拟器根本没有蓝牙硬件,连 openBluetoothAdapter 都会直接失败。开发阶段也至少要有一台真机在旁边,不能只在 IDE 的模拟器里开发。
最后一点想法
BLE 这块的东西看起来很杂,其实跑通了就是一条固定链路:权限 → 打开适配器 → 扫描 → 连接 → 发现服务 → 订阅 → 解析数据。除了权限部分各个 Android 版本行为不一致需要多写几行,其余部分都比较稳定。
真正让项目卡住的往往不是 API 本身,是设备厂商的协议文档写得含糊,或者干脆不给。这次的体重秤还算好,老客户保留了协议。如果是从市场随便买一台来研究,那就得先用真机调试打日志,把收到的字节序列抓出来,自己反推协议。bytesToHex 那个函数在猜协议的时候比什么都好使。
代码我整理成一个 ble.js 加一个页面组件,整个项目里差不多 300 行。把 UUID 换一换,就换到了心率带、体脂秤、血压计上,结构基本不用动。

