我用 UTS 写了一个三端同构的蓝牙插件(Android / iOS / 鸿蒙 NEXT),把踩过的坑都讲给你听
做蓝牙硬件对接的 uni-app 开发者,大概率都被 uni 原生蓝牙 API 折磨过。这篇文章讲讲我为什么自己造了这个轮子,以及造轮子过程中踩的那些"文档不会告诉你的坑"。
一、为什么要自己写蓝牙插件
项目里要做一套防丢器产品:App 扫描设备 → 绑定 → 连接后发指令找设备。用 uni 原生蓝牙 API 做完了第一版,然后陆续遇到这些问题:
- 回调地狱 。
uni.openBluetoothAdapter→startBluetoothDevicesDiscovery→onBluetoothDeviceFound→createBLEConnection→getBLEDeviceServices→getBLEDeviceCharacteristics→notifyBLECharacteristicValueChange......一条链走下来十几个回调,错误处理没法统一。 - 双端行为不一致 。同一个页面 Android 正常,iOS 扫到的设备名全是空;
advertisServiceUUIDs双端格式不一样;iOS 连接超时没有兜底,createBLEConnection可以卡死不回调。 - 缺能力。断线自动重连、刷新 GATT 缓存、连接超时保护,这些做硬件产品的刚需,原生 API 都没有。
- 鸿蒙 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 里了。