uni-app 蓝牙 BLE 实战:从扫描到解析体重秤数据的完整接入

2026-10-13 0 908

上个月接了个智能体重秤的项目。客户原本有一批体重秤,每台会通过蓝牙广播体重数据,想在 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 换一换,就换到了心率带、体脂秤、血压计上,结构基本不用动。

uni-app 蓝牙 BLE 实战:从扫描到解析体重秤数据的完整接入
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 蓝牙 BLE 实战:从扫描到解析体重秤数据的完整接入 https://www.taomawang.com/web/uniapp/2930.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务