分布式微服务系统架构第175集:BLE性能与低功耗优化

364 阅读6分钟

加群联系作者vx:xiaoda0423

仓库地址:webvueblog.github.io/JavaPlusDoc…

1024bat.cn/

github.com/webVueBlog/…

webvueblog.github.io/JavaPlusDoc…

点击勘误issues,哪吒感谢大家的阅读

一套 UniApp(App-Plus)端“能直接开工”的 BLE 方案:

包含两页 UI(设备列表、数据监控)+ 一套可复用的 ble.ts 工具层(扫描、连接、分片发送、通知监听、指标统计)+ 性能与低功耗要点。你只需要把 UUID 换成你设备的即可。


目录结构建议

/pages
  /ble-list/index.vue        # 设备列表(扫描/连接)
  /ble-monitor/index.vue     # 数据监控(速率/丢包/RSSI 等)
/utils
  ble.ts                     # BLE 工具层(核心逻辑)
  throttle.ts                # 节流/防抖工具(可选)
/store
  bleStore.ts                # 简单的全局状态(无需Pinia也可)
pages.json                   # 路由注册

适用平台:App-Plus(Android/iOS) 。H5/小程序 BLE API 不通用,这里全部用 uni.* BLE API(App-Plus 走原生封装),无需额外原生插件即可跑通;后台保活/前台服务见文末增强。


/utils/ble.ts(核心 BLE 工具层)

// /utils/ble.ts
// 核心功能:初始化适配器、扫描、连接、发现服务/特征、开启notify、分片写入、吞吐/丢包/RTT 指标。
// 注意:将 SERVICE_UUID / WRITE_UUID / NOTIFY_UUID 替换为你设备的 UUID(大小写无关)。
const SERVICE_UUID = '00008910-3333-3333-3333-333333333333'
const WRITE_UUID   = '0000FFE9-1111-1111-1111-111111111111'
const NOTIFY_UUID  = '0000FFE4-1111-1111-1111-111111111111'

type DeviceItem = {
  deviceId: string
  name: string
  RSSI: number
  advertisData?: ArrayBuffer
}

type Listener = (buf: ArrayBuffer) => void

const state = {
  inited: false,
  scanning: false,
  connectedId: '' as string,
  notifyEnabled: false,
  mtu: 23,
  // 指标
  rxBytes: 0,
  txBytes: 0,
  pktSeqRx: -1,            // 用于丢包粗略统计(看你协议是否带序号)
  lostPackets: 0,
  rssi: NaN as number
}

const listeners: Set<Listener> = new Set()
let scanTimer: number | null = null
let writeQueueBusy = false

export function getState() { return state }

export function onData(cb: Listener) {
  listeners.add(cb)
  return () => listeners.delete(cb)
}

export async function initAdapter(): Promise<void> {
  if (state.inited) return
  await uni.openBluetoothAdapter().then(() => {
    state.inited = true
  }).catch(err => {
    uni.showToast({ title: '请打开系统蓝牙', icon: 'none' })
    throw err
  })

  // 全局监听:特征值变化
  uni.onBLECharacteristicValueChange(res => {
    const buf = res.value as ArrayBuffer
    state.rxBytes += buf.byteLength
    listeners.forEach(fn => fn(buf))
  })
}

export function startScan(onUpdate: (list: DeviceItem[]) => void, serviceFilter = SERVICE_UUID) {
  if (!state.inited) throw new Error('Adapter not inited')
  if (state.scanning) return

  const map = new Map<string, DeviceItem>()
  state.scanning = true

  uni.startBluetoothDevicesDiscovery({
    services: [serviceFilter],
    allowDuplicatesKey: false,
    success: () => {
      // 轮询拉取设备列表(避免频繁 setState)
      scanTimer = setInterval(async () => {
        const res = await uni.getBluetoothDevices()
        // 过滤 & 去重 & RSSI 排序
        const arr = (res.devices || [])
          .filter(d => d.name || d.localName)
          .map(d => ({
            deviceId: d.deviceId!,
            name: d.name || d.localName || '未知设备',
            RSSI: d.RSSI ?? -100,
            advertisData: d.advertisData
          } as DeviceItem))

        arr.forEach(d => map.set(d.deviceId, d))
        const list = Array.from(map.values()).sort((a, b) => b.RSSI - a.RSSI)
        onUpdate(list)
      }, 800) as unknown as number
    },
    fail: (e) => {
      state.scanning = false
      uni.showToast({ title: '扫描失败', icon: 'none' })
      console.error(e)
    }
  })
}

export function stopScan() {
  state.scanning = false
  scanTimer && clearInterval(scanTimer as any)
  scanTimer = null
  uni.stopBluetoothDevicesDiscovery()
}

export async function connect(deviceId: string) {
  stopScan()
  await uni.createBLEConnection({ deviceId })
  state.connectedId = deviceId

  // 读一下 RSSI(安卓支持)
  try {
    // @ts-ignore
    uni.getBLEDeviceRSSI && uni.getBLEDeviceRSSI({ deviceId, success: r => state.rssi = r.RSSI })
  } catch {}

  await discoverAndSubscribe(deviceId)
}

async function discoverAndSubscribe(deviceId: string) {
  const svcs = await uni.getBLEDeviceServices({ deviceId })
  const svc = svcs.services?.find(s => equalUUID(s.uuid, SERVICE_UUID))
  if (!svc) throw new Error('缺少目标服务')

  const chars = await uni.getBLEDeviceCharacteristics({ deviceId, serviceId: svc.uuid! })
  const w = chars.characteristics?.find(c => equalUUID(c.uuid, WRITE_UUID))
  const n = chars.characteristics?.find(c => equalUUID(c.uuid, NOTIFY_UUID))
  if (!w || !n) throw new Error('缺少读写特征')

  // 开启 notify
  await uni.notifyBLECharacteristicValueChange({
    deviceId, serviceId: svc.uuid!, characteristicId: n.uuid!, state: true
  })
  state.notifyEnabled = true

  // iOS 无法改 MTU;Android 可在原生层处理,这里按 20/182/244 估计
  state.mtu = 23 // ATT开销后默认单包可写 20 字节,写分片时会自动 -3
}

export async function disconnect() {
  if (!state.connectedId) return
  try { await uni.closeBLEConnection({ deviceId: state.connectedId }) } finally {
    state.connectedId = ''
    state.notifyEnabled = false
  }
}

function equalUUID(a?: string, b?: string) {
  return (a || '').toLowerCase() === (b || '').toLowerCase()
}

// —— 分片发送(WRITE_NO_RESPONSE 优先),带简单背压与 15ms 写间隔 —— //
export async function writeBytes(data: ArrayBuffer, serviceId = SERVICE_UUID, characteristicId = WRITE_UUID) {
  if (!state.connectedId) throw new Error('未连接')
  while (writeQueueBusy) await sleep(8)
  writeQueueBusy = true
  try {
    const mtuPayload = Math.max(20, state.mtu - 3) // 兜底 20
    const u8 = new Uint8Array(data)
    for (let offset = 0; offset < u8.length; offset += mtuPayload) {
      const slice = u8.slice(offset, Math.min(offset + mtuPayload, u8.length))
      await uni.writeBLECharacteristicValue({
        deviceId: state.connectedId,
        serviceId,
        characteristicId,
        writeType: 'writeNoResponse', // App-Plus 支持
        value: slice.buffer
      })
      state.txBytes += slice.byteLength
      await sleep(15) // 写间隔,降低丢包/拥塞
    }
  } finally {
    writeQueueBusy = false
  }
}

export function resetCounters() {
  state.rxBytes = 0; state.txBytes = 0; state.lostPackets = 0
}

function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) }

/pages/ble-list/index.vue(设备列表页)

<template>
  <view class="page">
    <view class="toolbar">
      <button type="primary" @click="onInit" :disabled="inited">初始化</button>
      <button @click="scan" :disabled="!inited || scanning">开始扫描</button>
      <button @click="stop" :disabled="!scanning">停止</button>
    </view>

    <scroll-view scroll-y class="list">
      <view v-for="d in devices" :key="d.deviceId" class="item" @click="connectDev(d)">
        <view class="name">{{ d.name }}</view>
        <view class="desc">{{ d.deviceId }}</view>
        <view class="rssi">RSSI: {{ d.RSSI }}</view>
      </view>
    </scroll-view>
  </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { initAdapter, startScan, stopScan, connect, getState } from '@/utils/ble'

const inited = ref(false)
const scanning = ref(false)
const devices = ref<any[]>([])

async function onInit() {
  await initAdapter()
  inited.value = true
  uni.showToast({ title: '蓝牙就绪', icon: 'success' })
}

function scan() {
  scanning.value = true
  startScan((list) => { devices.value = list }, /* 可选service过滤 */)
}

function stop() {
  scanning.value = false
  stopScan()
}

async function connectDev(d: any) {
  try {
    await connect(d.deviceId)
    uni.showToast({ title: '已连接', icon: 'success' })
    uni.navigateTo({ url: '/pages/ble-monitor/index' })
  } catch (e:any) {
    uni.showToast({ title: '连接失败', icon: 'none' })
    console.error(e)
  }
}
</script>

<style scoped>
.page{padding:12px}
.toolbar{display:flex; gap:8px; margin-bottom:12px}
.list{height: calc(100vh - 120px)}
.item{padding:10px 12px; border-bottom:1px solid #eee}
.name{font-weight:600}
.desc{font-size:12px;color:#888}
.rssi{font-size:12px;color:#555}
</style>

/pages/ble-monitor/index.vue(数据监控页)

<template>
  <view class="page">
    <view class="cards">
      <view class="card"><text class="kv">连接ID</text><text class="val">{{ sid }}</text></view>
      <view class="card"><text class="kv">MTU</text><text class="val">{{ mtu }}</text></view>
      <view class="card"><text class="kv">RSSI</text><text class="val">{{ rssi || '--' }}</text></view>
      <view class="card"><text class="kv">上行</text><text class="val">{{ txRate }} KB/s</text></view>
      <view class="card"><text class="kv">下行</text><text class="val">{{ rxRate }} KB/s</text></view>
      <view class="card"><text class="kv">丢包(粗略)</text><text class="val">{{ lost }}</text></view>
    </view>

    <view class="actions">
      <button type="primary" @click="sendPing">发送心跳</button>
      <button @click="reset">清零统计</button>
    </view>

    <view class="log">
      <text v-for="(l,i) in logs" :key="i">{{ l }}</text>
    </view>
  </view>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { getState, onData, writeBytes, resetCounters } from '@/utils/ble'

const sid = ref('')
const mtu = ref(23)
const rssi = ref<number | string>('--')
const txRate = ref('0.0')
const rxRate = ref('0.0')
const lost  = ref(0)
const logs = ref<string[]>([])

let stopWatch: any = null
let unSub: any = null
let lastTx = 0, lastRx = 0

onMounted(() => {
  const s = getState()
  sid.value = s.connectedId
  mtu.value = s.mtu
  rssi.value = isNaN(s.rssi) ? '--' : s.rssi

  // 简易速率统计
  stopWatch = setInterval(() => {
    const st = getState()
    const curTx = st.txBytes, curRx = st.rxBytes
    txRate.value = ((curTx - lastTx)/1024).toFixed(2)
    rxRate.value = ((curRx - lastRx)/1024).toFixed(2)
    lastTx = curTx; lastRx = curRx
    lost.value = st.lostPackets
  }, 1000)

  unSub = onData((buf) => {
    logs.value.unshift('RX ' + buf.byteLength + 'B ' + toHex(buf).slice(0,64))
    if (logs.value.length > 100) logs.value.pop()
  })
})

onUnmounted(() => {
  stopWatch && clearInterval(stopWatch)
  unSub && unSub()
})

async function sendPing() {
  // 示例:发送一帧 0xAA55 + 时间戳(根据你协议改)
  const ts = Date.now()
  const u8 = new Uint8Array(8)
  const v = new DataView(u8.buffer); v.setUint16(0, 0xAA55); v.setUint32(2, ts & 0xffffffff)
  await writeBytes(u8.buffer)
  logs.value.unshift('TX ' + u8.byteLength + 'B ' + toHex(u8.buffer))
}

function reset() {
  resetCounters()
  lastTx = lastRx = 0
  logs.value = []
}

function toHex(ab: ArrayBuffer) {
  return Array.from(new Uint8Array(ab)).map(b => ('0' + b.toString(16)).slice(-2)).join(' ')
}
</script>

<style scoped>
.page{padding:12px}
.cards{display:grid; grid-template-columns:repeat(3,1fr); gap:10px}
.card{background:#f7f8fa; border-radius:10px; padding:8px 10px}
.kv{font-size:12px; color:#888; display:block}
.val{font-weight:700}
.actions{display:flex; gap:10px; margin:12px 0}
.log{height:45vh; border:1px solid #eee; border-radius:8px; padding:8px; overflow:auto; font-family:monospace; white-space:pre-wrap}
</style>

pages.json 注册

{
  "pages": [
    { "path": "pages/ble-list/index", "style": { "navigationBarTitleText": "BLE设备" } },
    { "path": "pages/ble-monitor/index", "style": { "navigationBarTitleText": "数据监控" } }
  ],
  "app-plus": {
    "distribute": {
      "android": {
        "permissions": [
          "<uses-permission android:name="android.permission.BLUETOOTH"/>",
          "<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>",
          "<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>",
          "<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>",
          "<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>"
        ]
      },
      "ios": {
        "plist": {
          "NSBluetoothAlwaysUsageDescription": "需要使用蓝牙连接设备",
          "UIBackgroundModes": ["bluetooth-central"]  // 如需后台BLE
        }
      }
    }
  }
}

Android 12/13+ 需要运行时权限:BLUETOOTH_SCAN/CONNECT,UniApp 在 App-Plus 会自动处理大部分,但首次进入建议弹窗引导。


性能与低功耗优化(落地清单)

启动速度

  • 仅在进入 BLE 页时 openBluetoothAdapter;首页不要提前初始化。
  • 扫描采用 短促高功耗 → 自动停止 策略:例如 10~15 秒后 stopBluetoothDevicesDiscovery()
  • 列表展示 节流 800ms 更新一次,避免频繁 DOM diff。

内存占用

  • 设备列表只保留最近 N=200 台,超出丢弃最弱 RSSI。
  • 日志区最多保留 100 行(见示例)。
  • 销毁页面时清理 interval/notify 回调,防泄漏。

后台低功耗

  • 连续数据传输时再保持连接;闲时主动断开。
  • 发送心跳合理降频:前台 5–10s,后台 30–60s。
  • Android 可考虑前台服务以稳定连接(需要原生插件/自研);iOS 勾选 bluetooth-central,仍然要控制心跳频率。
  • 扫描时严控时长,页面 onHide 立即 stopBluetoothDevicesDiscovery()

稳定性

  • 写入分片 mtu-3,默认兜底 20 字节,每片间隔 15ms(已在 ble.ts 实现)。
  • 连接断开后指数退避重连(可以在业务上层做重连调度)。
  • 对通知回包做协议级校验(CRC/长度/序号),异常包直接丢弃并记日志。

你需要替换/扩展的点

  1. 设备 UUIDSERVICE_UUID / WRITE_UUID / NOTIFY_UUID

  2. 协议编解码:在 onData() 里对 ArrayBuffer 做解析;如需请求-响应匹配,给每次发送加 txnNo 并在回包解析后 resolve。

  3. 指标更细化:在 ble.ts 中接入你的帧序号/ACK,精确统计丢包率、RTT、有效吞吐。

  4. 原生增强(可选)

    • Android 前台服务(通知栏保活)
    • iOS 后台 Task 调度
    • MTU 请求(Android 原生可调 185/247;iOS 固定)