我用 UTS 写了一个三端同构的蓝牙插件(Android / iOS / 鸿蒙 NEXT),把踩过的坑都讲给你听

我用 UTS 写了一个三端同构的蓝牙插件(Android / iOS / 鸿蒙 NEXT),把踩过的坑都讲给你听

做蓝牙硬件对接的 uni-app 开发者,大概率都被 uni 原生蓝牙 API 折磨过。这篇文章讲讲我为什么自己造了这个轮子,以及造轮子过程中踩的那些"文档不会告诉你的坑"。

一、为什么要自己写蓝牙插件

项目里要做一套防丢器产品:App 扫描设备 → 绑定 → 连接后发指令找设备。用 uni 原生蓝牙 API 做完了第一版,然后陆续遇到这些问题:

  1. 回调地狱 。uni.openBluetoothAdapter → startBluetoothDevicesDiscovery → onBluetoothDeviceFound → createBLEConnection → getBLEDeviceServices → getBLEDeviceCharacteristics → notifyBLECharacteristicValueChange......一条链走下来十几个回调,错误处理没法统一。
  2. 双端行为不一致 。同一个页面 Android 正常,iOS 扫到的设备名全是空;advertisServiceUUIDs 双端格式不一样;iOS 连接超时没有兜底,createBLEConnection 可以卡死不回调。
  3. 缺能力。断线自动重连、刷新 GATT 缓存、连接超时保护,这些做硬件产品的刚需,原生 API 都没有。
  4. 鸿蒙 NEXT 没有蓝牙 API。新端要用,只能等官方或者自己想办法。

UTS 插件恰好是解这三个问题的正确工具:一套 TypeScript 风格的 API 写三端原生实现,编译期直接进 App 包,没有 JS Bridge 的性能损耗,也没有 native.js 那样的反射限制。

二、插件设计思路

2.1 一个事件总线吃掉所有回调

所有异步结果收敛到两条通道:方法回调 (success/fail/complete)只报"操作本身"的成败,事件 (onBluetoothEvent)承载持续产生的数据:

ts 复制代码
import { openAdapter, startScan, onBluetoothEvent } from '@/uni_modules/uts-bluetooth'

onBluetoothEvent((name, data) => {
  switch (name) {
    case 'adapterState':  // 蓝牙开关/授权变化
    case 'deviceFound':   // 扫描到设备
    case 'connectionState': // 连接状态
    case 'servicesDiscovered': // 服务发现
    case 'characteristicChanged': // notify 数据回传
  }
})

openAdapter({ success: () => startScan({ allowDuplicates: true }) })

页面里注册一次事件,整条链路清清爽爽,错误处理也统一成 errMsg + errCode。

2.2 数据格式统一成大写 HEX

BLE 字节流在各端的表示方式是灾难现场:uni 用 ArrayBuffer,Android 原生是 byte\[\](有符号!),iOS 是 Data。插件统一用大写 HEX 字符串 ("AABB01")进出:

ts 复制代码
writeCharacteristic({
  deviceId, serviceUuid, characteristicUuid,
  value: '5502000000000000000000000000000000000000', // 20 字节指令
  writeType: 2 // 带响应写入
})

一个 HEX 转 ArrayBuffer 的工具函数都不用写,跨语言调试时肉眼可读,日志里直接能对指令。

2.3 三端同构 API

能力 Android iOS 鸿蒙 NEXT
扫描多条件过滤(服务UUID/名称/RSSI...) ✅ ✅ ✅
连接超时保护 / 自动重连 ✅ ✅ ✅
连接后自动服务发现 ✅ ✅ ✅
MTU / RSSI / 连接优先级 / PHY ✅ 部分 部分
外设模式(BLE 广播 + GATT Server) ✅ ✅ ❌
经典蓝牙 SPP ✅ ❌(系统限制) ❌
deviceId MAC 地址 Peripheral UUID MAC 地址

设计原则:能力有差异的接口,两端都导出,不支持的一端运行时返回明确失败 (fail: errMsg 里写清楚 iOS 不支持),这样业务层不用写平台判断,插件升级补齐能力时上层零改动。

三、踩坑实录(这部分最有价值)

坑 1:iOS CBCentralManager 创建后 state 一定是 .unknown

这是最隐蔽的一个。iOS 的 CBCentralManager 初始化后,蓝牙状态不会同步拿到 ,必须等 centralManagerDidUpdateState 异步回调。我第一版这样写:

swift 复制代码
static func openAdapter() -> Bool {
    return central.state == .poweredOn // 刚创建时 state == .unknown,必返回 false!
}

结果 iOS 端 openAdapter 必失败。正确做法是把 .unknown 视为"初始化中"返回成功,把真实的失败(未授权、未开启、不支持)区分开并通过事件上报。

坑 2:iOS 没弹授权框,蓝牙状态永久 unknown

如果最终 IPA 里没有 NSBluetoothAlwaysUsageDescription,系统不会弹授权框 ,CBCentralManager.state 会永远停在 .unknown,你连失败原因都看不到。UTS 插件自带的 Info.plist 不保证合并进宿主,最稳的方式是在 manifest.json 里显式声明:

json 复制代码
"app-plus": { "distribute": { "ios": {
  "privacyDescription": {
    "NSBluetoothAlwaysUsageDescription": "需要使用蓝牙搜索并连接附近的设备",
    "NSBluetoothPeripheralUsageDescription": "需要使用蓝牙与附近的设备进行数据通信"
  }
}}}

改完必须重新打自定义基座,热更新不会生效。

坑 3:iOS 扫到的设备全是无名的

iOS 对纯广播设备(不发起连接)拿到的 CBPeripheral.name 经常是 nil。设备名要从广播包里捞回退:

swift 复制代码
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""

而且过滤逻辑必须用这个解析后的名字 ------如果你的业务靠 namePrefix 过滤设备,直接用 peripheral.name 过滤会把设备全部滤掉,表现为"扫描永远没有结果"。

坑 4:Android 12+ 扫描静默返回 0 个设备

BLUETOOTH_SCAN 权限如果不加 neverForLocation 标记,系统会把扫描结果和定位权限挂钩,定位权限没给就一个设备都扫不到,而且不报任何错:

xml 复制代码
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />

这个标记是我见过蓝牙"扫不到设备"工单里出现频率最高的原因。

坑 5:版本敏感 API 编译不过

iOS 端用 CBConnectPeripheralOptionEnableTransportBridgingKey(iOS 13+)直接编译报错,所有新 API 必须加守卫:

swift 复制代码
if #available(iOS 13.0, *), let bridging = options.enableTransportBridging {
    opts[CBConnectPeripheralOptionEnableTransportBridgingKey] = bridging
}

同理 CBManager.authorization 要 iOS 13.1 守卫。UTS 插件每次云打包都在真编译 Swift/Kotlin,类型错误和可用性错误都逃不掉------这既是麻烦也是保障。

坑 6:nvue 页面滚动区用固定高度,Android 不滚动

这是纯 UI 的坑但和插件联调页面强相关:nvue 根容器不支持整页滚动,子滚动区用 height: 160px 这种固定值堆叠,内容一超一屏 Android 上直接裁剪,scroll-view 手势也会失效。nvue 布局铁律:根容器 flex: 1,滚动区一律用 flex 比例分高度。

坑 7:鸿蒙端没有现成蓝牙能力

鸿蒙 NEXT 走 @kit.ConnectivityKit(access + ble,API 12+),API 形态和 Android/iOS 完全不同:deviceId 是 MAC(这点和 Android 一致)、系统 API 只收完整格式 UUID、GattService 不提供特征的 properties。插件内部做了 UUID 自动扩展(短码 CEB2 ↔ 完整 0000CEB2-0000-1000-8000-00805F9B34FB),上层业务传什么都认。

四、实际业务落地

我把原来用 uni 蓝牙 API 写的两个页面迁移到插件上:一个绑定流程页(扫描 → 连接 → 服务发现 → notify 收公钥 → 上报绑定),一个找设备页(持续扫描 + 地图标记 + 发声指令)。迁移下来几个体感:

  • 代码量少了约 40% :回调链变事件流,错误处理统一,ab2hex、UUID 格式转换这类胶水代码全部删掉
  • 断线重连白送 :插件层带 autoReconnect,业务层以前要自己维护重连状态机
  • getKnownDevices 免扫描重连:上次连接过的 deviceId 直接查回设备再连接,重进页面秒连,不用等扫描
  • 服务发现不再靠 setTimeout 猜 :等 servicesDiscovered 事件 + getServices 兜底,比原来固定等 3 秒又快又稳

五、写在最后

这个插件现在覆盖:适配器管理、多条件扫描过滤、4 类设备查询(扫描缓存/系统配对/系统已连接/免扫描重连)、连接管理(超时保护/自动重连/自动服务发现)、读写订阅、MTU/RSSI/PHY/连接优先级、外设模式(Android/iOS)、经典蓝牙 SPP(Android)、鸿蒙 NEXT 支持。

如果你也在做 BLE 硬件对接(防丢器、手环、蓝牙打印机、IoT 传感器),欢迎评论区交流踩坑经验。下一篇文章计划写「BLE 外设模式实战:让手机自己变成一个蓝牙设备」,感兴趣的可以关注一手。

有问题欢迎在评论区提出,能力边界和已知差异都写在插件的 README 里了。

相关推荐
程序员老赵2 小时前
Docker 部署 XWiki:轻松搭建企业知识库与协作 Wiki
前端·docker·开源
console.log('npc')2 小时前
第 10 章 更多业务模块扩展
前端·人工智能·大模型
行者全栈架构师2 小时前
我用 TRAE Work 把桌游之夜搬进了浏览器:一个计分器的从创意到上线
前端·算法·架构
山雀~2 小时前
线上CI流水打包失败排查思路案例
前端·ci/cd
时间的情敌2 小时前
《回顾 2019 前端:React Hooks 问世,前端工程化爆发的一年》
前端·react.js·前端框架
燕落南枝2 小时前
CSS 类选择器详解:从入门到实战
前端·css
tryCbest3 小时前
Vue3——第 7 篇:组件基础——从注册到使用的完整流程
前端·javascript·vue.js
ai工具集合3 小时前
图标集用法,前端项目 SVG 图标 icon 完整接入实操
前端
门思科技3 小时前
ThinkLink 触发模型与告警规则实战:数据一小时才来一次,告警怎么做到不迟到、不刷屏、找对人
前端·后端