加群联系作者vx:xiaoda0423
仓库地址:webvueblog.github.io/JavaPlusDoc…
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/长度/序号),异常包直接丢弃并记日志。
你需要替换/扩展的点
-
设备 UUID:
SERVICE_UUID / WRITE_UUID / NOTIFY_UUID。 -
协议编解码:在
onData()里对ArrayBuffer做解析;如需请求-响应匹配,给每次发送加txnNo并在回包解析后 resolve。 -
指标更细化:在
ble.ts中接入你的帧序号/ACK,精确统计丢包率、RTT、有效吞吐。 -
原生增强(可选) :
- Android 前台服务(通知栏保活)
- iOS 后台 Task 调度
- MTU 请求(Android 原生可调 185/247;iOS 固定)