
今年上半年我在做一款运动健康类App核心功能之一就是连接蓝牙体脂秤读取体重、体脂、心率等数据。技术栈选了uni-app要求一套代码同时跑Android和iOS。低功耗蓝牙这部分做到一半就发现uni-app官方文档的BLE章节其实不算少但App端和微信小程序端的行为差别非常大文档里根本没有展开讲网上能搜到的完整示例又绝大多数是H5或小程序场景真正能在安卓手机上稳定跑通的App端蓝牙代码非常少。这篇文章把我这段时间踩过的坑和最终沉淀下来的一套方案整理出来核心是BLE的搜索、连接、服务发现、数据收发全部用uni-app实现。如果你接下来要做智能硬件类的App、运动健康类工具或者只是想搞明白uni-app的BLE API到底怎么串起来这篇应该能帮你少走不少弯路。1. 先从需求说起我要用uni-app连接哪类BLE设备1.1 项目背景我做的这个App主要面向家庭健康场景需要连接体脂秤、心率带、血压计这类BLE设备。用户的手机型号五花八门iOS和Android各占一半所以摆在面前的第一道题就是跨端方案选型。由于产品规划里后续还要出微信小程序版本直接用uni-app可以一套代码同时覆盖App和小程序业务侧不用维护两套逻辑。项目的业务逻辑其实不复杂打开页面后自动搜索附近设备用户点选某个设备发起连接连接成功后通过GATT协议读取设备上报的数据并把数据解析成人体指标展示出来。真正的复杂度在于蓝牙状态机的管理搜索、连接、发现服务、订阅特征值、收发数据每一步都有异步回调如果不在工程层面做统一封装页面代码会被回调嵌套塞满后面接手的人肯定骂娘。1.2 为什么选uni-app而不是原生或Flutter做这个选型之前我认真对比过三种方案。原生开发的问题很现实Android和iOS各写一套蓝牙代码工作量大一倍而且两端的蓝牙行为差异特别多需要专门的人分别踩坑。我们的团队不大不想把精力耗在双端维护上。Flutter我也考察过生态里的BLE插件确实有几个但坑更多。最主要的问题是iOS端稳定性参差不齐不同的插件对CoreBluetooth的封装深度不一样有的插件连后台恢复连接都没做有的插件在设备断连后回调会丢失。搜热词就能看到不少人问“flutter 低功耗蓝牙ios有问题嘛”这不是个别现象。uni-app这边反而是官方直接把原生层的蓝牙能力封装成了统一APIHBuilderX云打包也方便最终就定了uni-app。uni-app的BLE API设计思路和小程序端非常接近核心是一组uni.xxxBluetoothXxx方法。如果你之前写过微信小程序蓝牙迁移过来会非常快。但要注意App端的API虽然同名底层实现和权限模型跟小程序完全不同这也是很多人照着小程序Demo写结果在App端跑不通的根本原因。1.3 BLE基础概念扫盲低功耗蓝牙BLE和经典蓝牙最大的区别就是“按需通信”平时不传数据时处于休眠状态功耗极低。所以BLE不适合传大文件适合传小数据包比如传感器指标、遥控指令这一类。要理解BLE的通信模型先记住三个角色中心设备Central发起扫描和连接的一方比如手机App。外围设备Peripheral广播自己并等待连接的一方比如体脂秤。GATT服务Server/Client连接建立后外围设备作为GATT Server中心设备作为GATT Client通过服务、特征值、描述符三级结构交换数据。很多人第一次接触GATT会懵其实可以这样理解一个BLE设备就像一栋楼楼里有很多房间服务每个房间里有很多开关和仪表特征值你进了楼以后要找到对应的房间再操作对应的开关。服务和特征值都用一个128位UUID标识有些标准服务有16位的短UUID比如电池服务是0x180F设备信息服务是0x180A。特征值支持的操作由properties字段决定常见的有read、write、notify、indicate。read就是主动去读一次write就是从手机往设备写数据notify和indicate都是设备主动往手机推数据区别在于indicate有应答确认而notify没有。在实际开发里传感器数据基本都是通过notify或indicate上来的因为设备产生数据的时间是随机的不可能让App一直轮询。2. 蓝牙搜索与连接的完整流程拆解2.1 BLE通信标准流程BLE从开始扫描到正常通信流程是有严格顺序的每一步操作都要在上一部的成功回调里进行不能跳步。完整流程是这样的初始化蓝牙适配器uni.openBluetoothAdapter这一步会检查手机蓝牙是否开启。开始扫描外围设备uni.startBluetoothDevicesDiscovery同时监听found事件。搜索到目标设备后停止扫描uni.stopBluetoothDevicesDiscovery。发起连接uni.createBLEConnection。连接成功后获取设备的服务列表uni.getBLEDeviceServices。根据业务需求在某个服务里获取特征值列表uni.getBLEDeviceCharacteristics。对需要设备主动上报数据的特征值开启notify订阅uni.notifyBLECharacteristicValueChange。通过write方法向设备下发指令通过onBLECharacteristicValueChange监听设备上报数据。页面销毁或业务结束时断开连接uni.closeBLEConnection。这个顺序和原生CoreBluetooth、Android BLE的开发流程基本一一对应。如果你只是按某个教程写了一部分步骤比如连接成功后直接write数据没有先去获取服务大概率会收到一个“service not found”之类的报错。2.2 核心API清单与调用时机这里我把uni-app App端BLE开发常用的API按调用阶段整理成一张表后面写代码时可以直接对照阶段API作用与注意事项初始化uni.openBluetoothAdapter打开蓝牙适配器返回失败表示手机蓝牙未开启或权限未授予初始化uni.getBluetoothAdapterState获取蓝牙适配器状态可用于页面检查开关状态搜索uni.startBluetoothDevicesDiscovery开始扫描参数allowDuplicatesKey控制是否过滤重复设备搜索uni.onBluetoothDeviceFound监听扫描到设备外层devices字段是数组搜索uni.stopBluetoothDevicesDiscovery停止扫描连接前最好先停止避免资源冲突连接uni.createBLEConnection建立连接参数deviceId就是搜索到的deviceId可传timeout连接uni.onBLEConnectionStateChange监听连接/断开状态变化掉线重连要用服务发现uni.getBLEDeviceServices获取服务UUID列表特征值uni.getBLEDeviceCharacteristics获取指定服务下的特征值列表数据收uni.notifyBLECharacteristicValueChange开启notify订阅参数state设为true数据收uni.onBLECharacteristicValueChange监听设备通过notify推上来的数据数据发uni.writeBLECharacteristicValue向设备的特征值写入ArrayBuffer数据断开uni.closeBLEConnection断开连接释放资源这个表里最容易漏的一步是notifyBLECharacteristicValueChange。很多新手以为连接成功就能直接收到设备的数据实际不行。对于大多数用notify上报数据的传感器设备不主动订阅特征值设备根本不会推数据。2.3 权限配置与Android/iOS差异BLE开发在不同平台的权限模型差别非常大这里重点说。Android在6.0到11之间蓝牙扫描需要定位权限。这个其实是个历史遗留规则因为蓝牙扫描可以间接获取用户位置系统安全策略就要求App必须拿到定位权限才能扫描周围设备。所以你得在manifest和运行时都申请位置权限而且部分国产ROM比如小米、华为还要求用户打开系统定位服务开关否则扫描接口直接静默失败。Android 12及以上引入了新的蓝牙权限模型把原来的蓝牙开关权限拆成了BLUETOOTH_SCAN和BLUETOOTH_CONNECT。在uni-app里配置manifest的Android权限时最好把老的和新的权限都声明上兼容不同版本。iOS端主要是Info.plist里必须有NSBluetoothAlwaysUsageDescription否则系统会在调用蓝牙接口时直接杀掉App。这个隐私描述在uni-app的manifest.json里配置具体位置是app-plus的distribute-ios-privacyDescription。除此之外iOS的deviceId是系统为每个蓝牙设备生成的UUID不是固定不变的硬件地址。同一台设备在系统蓝牙重置、App卸载重装后这个UUID可能发生变化。所以不能把iOS的deviceId当作设备的永久业务ID来存数据库业务上应该用设备MAC地址如果协议广播里带或自定义设备标识来识别。3. 可直接复用的BluetoothManager封装代码3.1 权限与manifest配置先看manifest.json的配置。在源码视图里找到app-plus节点往permissions和distribute里加权限{ app-plus: { permissions: { Android: { android.permission.BLUETOOTH: {}, android.permission.BLUETOOTH_ADMIN: {}, android.permission.ACCESS_FINE_LOCATION: {}, android.permission.ACCESS_COARSE_LOCATION: {}, android.permission.BLUETOOTH_SCAN: {}, android.permission.BLUETOOTH_CONNECT: {} } }, distribute: { ios: { privacyDescription: { NSBluetoothAlwaysUsageDescription: 需要使用蓝牙连接智能体脂秤设备 } } } } }注意HBuilderX不同版本的manifest配置位置可能略有差异如果你们用的版本界面没有这些字段切到源码视图手动加也能生效。如果你的App还需要申请定位权限建议做成一个统一的权限检查函数在进入蓝牙搜索页面之前调用。比如可以检查uni.getSystemInfoSync().platform如果是Android平台再走uni.authorize这里代码不展开写在实际项目里这一步要和产品流程串好。3.2 蓝牙工具类完整封装我一般会把蓝牙操作统一封装成一个单例BluetoothManager避免页面里到处都是uni.xxx回调。下面这段代码是我在项目里用的核心类去掉了业务相关部分通用性比较强class BluetoothManager { constructor() { this.adapterAvailable false; this.connected false; this.deviceId ; this.serviceId ; this.writeUUID ; this.notifyUUID ; } /** * 初始化蓝牙适配器 */ init() { return new Promise((resolve, reject) { uni.openBluetoothAdapter({ success: (res) { this.adapterAvailable true; // 初始化时需要用额外变量保存回调避免重复注册监听 this._registerGlobalListeners(); resolve(res); }, fail: (err) { // 常见错误10001表示蓝牙未开启10012表示未授权 this.adapterAvailable false; reject(err); } }); }); } /** * 注册全局监听只需要注册一次 */ _registerGlobalListeners() { // 监听连接状态 uni.onBLEConnectionStateChange((res) { if (!res.connected) { this.connected false; this.deviceId ; } }); // 监听设备上报数据 uni.onBLECharacteristicValueChange((res) { if (this.onData) { this.onData(res); } }); } /** * 开始搜索设备 */ startScan(onFound) { return new Promise((resolve, reject) { // 先停掉上一次扫描再开始新扫描避免状态残留 uni.stopBluetoothDevicesDiscovery({ complete: () { uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: () { // found事件放在start成功之后再注册防止漏掉设备 uni.onBluetoothDeviceFound((res) { const devices res.devices || []; onFound onFound(devices); }); resolve(); }, fail: (err) reject(err) }); } }); }); } /** * 停止扫描 */ stopScan() { return new Promise((resolve) { uni.stopBluetoothDevicesDiscovery({ complete: resolve }); }); } /** * 连接设备 */ connect(deviceId) { return new Promise((resolve, reject) { uni.createBLEConnection({ deviceId, timeout: 10000, success: () { this.deviceId deviceId; this.connected true; resolve(); }, fail: (err) reject(err) }); }); } /** * 获取全部服务找到业务指定的服务UUID */ findService(targetServiceUUID) { return new Promise((resolve, reject) { uni.getBLEDeviceServices({ deviceId: this.deviceId, success: (res) { const services res.services || []; const target services.find((item) { return item.uuid.toUpperCase() targetServiceUUID.toUpperCase(); }); if (target) { this.serviceId target.uuid; resolve(target); } else { reject(new Error(未找到目标服务)); } }, fail: (err) reject(err) }); }); } /** * 获取特征值并保存写入特征值和通知特征值的UUID */ findCharacteristics(serviceId, writeUUID, notifyUUID) { return new Promise((resolve, reject) { uni.getBLEDeviceCharacteristics({ deviceId: this.deviceId, serviceId, success: (res) { const chars res.characteristics || []; const writeTarget chars.find((item) item.uuid.toUpperCase() writeUUID.toUpperCase()); const notifyTarget chars.find((item) item.uuid.toUpperCase() notifyUUID.toUpperCase()); if (!writeTarget || !notifyTarget) { reject(new Error(未找到目标特征值)); return; } this.writeUUID writeTarget.uuid; this.notifyUUID notifyTarget.uuid; resolve({ writeTarget, notifyTarget }); }, fail: (err) reject(err) }); }); } /** * 订阅notify让设备主动上报数据 */ subscribeNotify() { return new Promise((resolve, reject) { uni.notifyBLECharacteristicValueChange({ deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: this.notifyUUID, state: true, success: resolve, fail: reject }); }); } /** * 写入数据value需要是ArrayBuffer */ write(value) { return new Promise((resolve, reject) { uni.writeBLECharacteristicValue({ deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: this.writeUUID, value, success: resolve, fail: (err) reject(err) }); }); } /** * 断开连接 */ close() { return new Promise((resolve) { if (this.deviceId) { uni.closeBLEConnection({ deviceId: this.deviceId, complete: () { this.connected false; this.deviceId ; resolve(); } }); } else { resolve(); } }); } } export default new BluetoothManager();这里有几个细节值得说一下。startScan里我刻意先调了一次stopBluetoothDevicesDiscovery是因为实际开发中经常遇到用户反复进出搜索页的情况如果上一次的扫描没有完全停掉再次start可能会不回调found事件。先stop再start是一个保险做法。findService和findCharacteristics用了精确匹配UUID的方式而不是靠遍历列表猜通道。因为很多设备的服务列表里除了业务服务还有电池服务、设备信息服务这些标准服务。如果只按顺序取前几个非常容易拿到错误的服务。最好从设备厂商的GATT表里确认业务服务UUID然后硬匹配。write方法的参数是ArrayBuffer不是字符串也不是普通数组。这个坑待会儿在问题排查里细说。3.3 页面调用示例下面是一个简单的uni-app页面示例演示了搜索、连接、订阅notify、发送指令的完整流程。template view classcontainer button clickinitAndScan扫描设备/button view v-for(item, index) in deviceList :keyindex classdevice-item clickconnectDevice(item) {{ item.name || item.localName || item.deviceId }} /view text v-ifconnectedMsg{{ connectedMsg }}/text /view /template script import BluetoothManager from /utils/bluetoothManager.js; export default { data() { return { deviceList: [], connectedMsg: , targetServiceUUID: 0000FFE0-0000-1000-8000-00805F9B34FB, targetWriteUUID: 0000FFE2-0000-1000-8000-00805F9B34FB, targetNotifyUUID: 0000FFE1-0000-1000-8000-00805F9B34FB }; }, onUnload() { BluetoothManager.close(); }, methods: { initAndScan() { BluetoothManager.init().then(() { this.deviceList []; BluetoothManager.startScan((devices) { devices.forEach((device) { const key device.deviceId; const existIndex this.deviceList.findIndex((item) item.deviceId key); if (existIndex -1) { this.deviceList.push(device); } }); }); }).catch(() { uni.showToast({ title: 初始化蓝牙失败, icon: none }); }); }, connectDevice(device) { BluetoothManager.stopScan().then(() { return BluetoothManager.connect(device.deviceId); }).then(() { return BluetoothManager.findService(this.targetServiceUUID); }).then(() { return BluetoothManager.findCharacteristics( BluetoothManager.serviceId, this.targetWriteUUID, this.targetNotifyUUID ); }).then(() { return BluetoothManager.subscribeNotify(); }).then(() { this.connectedMsg 连接成功开始接收数据; BluetoothManager.onData (res) { // res.value 是 ArrayBuffer需要转成16进制字符串方便查看 const data this.abToHex(res.value); console.log(收到数据:, data); }; // 发送一条查询指令具体协议按设备文档来 const buffer BluetoothManager.stringToArrayBuffer(ATGETDATA); BluetoothManager.write(buffer); }).catch((err) { console.error(连接流程失败, err); uni.showToast({ title: 连接失败, icon: none }); }); }, abToHex(buffer) { const dataView new DataView(buffer); let hex ; for (let i 0; i dataView.byteLength; i) { const val dataView.getUint8(i).toString(16); hex val.length 1 ? 0 val : val; } return hex; } } }; /script这段代码看起来长但逻辑是一条Promise链初始化 - 扫描 - 连接 - 找服务 - 找特征值 - 订阅notify - 写指令。每一步都等着上一步成功返回任何一个环节失败都会跑到catch里方便排查。注意targetServiceUUID那几个值是我从某款体脂秤的协议里随手写的不是通用值。每个人的项目必须替换成自己设备厂商协议文档里的UUID直接照抄肯定连不上。4. 开发中的坑与排错实录4.1 iOS搜索与连接的坑iOS端我遇到的第一个坑是权限弹窗崩溃。刚开始测试时手机一调用openBluetoothAdapter就直接闪退检查日志才发现Info.plist里缺少NSBluetoothAlwaysUsageDescription。加上描述之后正常弹窗问题解决。iOS第二个坑是deviceId的稳定性。前面说过iOS的deviceId是系统生成的UUID备份恢复或系统蓝牙重置后可能变。有一段时间我们的测试同学反馈同一个体脂秤昨天还能连今天搜到同一个设备名称点连接就一直转圈。排查后发现其实系统给这个设备的UUID变了旧缓存数据没清App用旧UUID去连接当然连不上。解决方法是每次进入搜索页都刷新设备列表不要用本地缓存的deviceId直接连。iOS第三个坑是扫描参数。如果调startBluetoothDevicesDiscovery时传了services参数按服务UUID过滤某些设备会搜不到原因在于设备广播包里的service UUID和GATT服务表里的UUID不一定完全一致有的设备广播包没广播service。建议App端扫描时不要传services过滤器搜到设备后通过设备名称或RSSI判断目标。iOS连接超时也是个常见问题。createBLEConnection默认没有超时时间有时候设备信号不好连接请求挂在那里十几秒没反应。建议显式传timeout参数一般设10秒左右。4.2 Android适配的坑Android端的坑比iOS多主要是权限和厂商限制。先说权限。Android 6到11如果没有定位权限搜索功能完全没反应但不一定会报错。我在某款华为平板上遇到过startBluetoothDevicesDiscovery返回success但onBluetoothDeviceFound一次都不回调的情况排查了一圈才发现是定位没开。后来我在搜索前加了一个检查逻辑如果检测到Android平台先确认定位服务是否可用不可用就提示用户去设置里打开。Android 12以上的机型如果没有BLE相关权限openBluetoothAdapter会返回错误。这里需要检查manifest里有没有BLUETOOTH_SCAN和BLUETOOTH_CONNECT权限。HBuilderX云打包时如果只勾了老的蓝牙权限在Android 12新机上就可能失败。国内ROM的适配问题更头疼。部分机型在锁屏状态下会限制蓝牙扫描还有机型的省电策略会杀掉后台蓝牙连接服务。如果你的App需要长时间维持蓝牙连接建议引导用户把App加入电池优化白名单否则息屏一会连接就断了。还有一个小坑是扫描的重复注册。onBluetoothDeviceFound如果注册了两次一次扫描会发现两条相同设备记录。我上面的封装里没有处理重复问题实际项目里需要在页面层维护一个去重Map以deviceId为key只保留第一次出现的记录。4.3 数据收发与MTU问题BLE的数据传输不是无限长的。默认MTU是23字节刨掉3个字节的BLE协议头应用层一次最多传20字节。Android 8以上系统能协商MTU到更大uni-app的底层会自动做一部分MTU协商但iOS端相对稳定在185甚至更高。设备端能力不同如果你写的数据超过MTU底层就会报错。解决办法是应用层分包发送。我见过很多人写write时直接传一个几十字节的命令结果iOS上报错Android上静默失败这在BLE开发里算是经典问题了。我在实际项目里写了一个通用的分包工具按20字节切分function splitBuffer(buffer, chunkSize) { const chunks []; const view new DataView(buffer); const total view.byteLength; let offset 0; while (offset total) { const length Math.min(chunkSize, total - offset); const chunk new ArrayBuffer(length); const chunkView new DataView(chunk); for (let i 0; i length; i) { chunkView.setUint8(i, view.getUint8(offset i)); } chunks.push(chunk); offset length; } return chunks; }使用时分包写入每包之间间隔几十毫秒避免设备端处理不过来。notify监听位置也很容易踩坑。一定要在write之前先订阅notify因为很多设备收到指令后就立刻开始上报数据如果你监听得太晚数据就丢了。实际项目里还要注意设备可能一次上报多包数据要在onBLECharacteristicValueChange里做粘包处理按协议包头、包尾或长度字段把完整报文拼出来。4.4 常见问题速查表问题现象可能原因解决办法openBluetoothAdapter报10001手机蓝牙未开启提示用户打开蓝牙或用uni.getBluetoothAdapterState轮询状态openBluetoothAdapter报10012没有蓝牙权限或iOS缺隐私描述检查manifest权限iOS检查NSBluetoothAlwaysUsageDescriptionAndroid搜索不到设备或找到后不回调没有定位权限或定位服务关闭检查并申请定位权限引导打开系统定位搜索到了设备但连接失败iOS deviceId变化或设备不在广播范围重新开始搜索拿最新deviceId靠近设备连接成功但getBLEDeviceServices为空服务还没发现完成或设备异常延迟重试获取服务或重新连接收不到设备上报数据没订阅notify或监听注册太晚在write前调用notifyBLECharacteristicValueChangestate设truewrite数据报错数据超过MTU或value不是ArrayBuffer分包写入确保ArrayBuffer类型连接后一会就断开设备休眠、手机锁屏或厂商省电策略引导加入电池白名单按协议维持心跳安卓12上找不到蓝牙设备缺BLUETOOTH_SCAN/BLUETOOTH_CONNECT权限manifest里加上新权限重新打自定义基座5. 调试工具与经验技巧5.1 nRF Connect万能调试工具做BLE开发强烈建议手机里装一个nRF Connect。这是Nordic官方出的工具能扮演中心设备扫描周边BLE设备也能模拟外围设备广播。日常调试时它的用处非常大。比如你的App连不上设备你可以先用nRF Connect手动连一次看看能不能正常发现服务、收发数据。如果工具能连能收数据说明问题在App代码的某个环节如果工具也连不上那基本是设备端问题就不用纠结代码了。nRF Connect还能看设备的广播原始包、RSSI、所有服务和特征值以及每个特征值的属性。这个对你理解设备协议特别有帮助。拿协议文档对照工具里的GATT表就能确认你代码里写的服务UUID和特征值UUID对不对。5.2 日志与断点技巧uni-app开发App时建议用真机调试不要只在模拟器里跑。蓝牙是硬件能力模拟器很难模拟出真实设备的连接行为。真正机调试注意看好console日志但onBluetoothDeviceFound回调里的信息量比较大建议把devices数组原样打印出来看每个设备的name、localName、RSSI、serviceIds这些字段是判断设备是否目标设备的关键。如果你的设备有数据解析错误建议在onBLECharacteristicValueChange里不要直接解析而是先把res.value转成十六进制字符串打出来。这样至少能确认数据通不通再谈解析对不对。上面代码里的abToHex方法可以直接复用。5.3 最后几点个人体会这个项目做完我最深的感受是BLE开发的核心不在API而在你对“状态”的把控。蓝牙连接不是一锤子买卖整个过程有初始化状态、扫描状态、连接状态、服务发现状态任何一个环节被打断下一次操作就可能卡住。所以封装BluetoothManager的时候一定要把状态变量维护好连接断开后及时把deviceId清空扫描失败后要允许重试否则App跑一段时间会出现“能扫到但连不上”之类的玄学问题。另外就是一定要让用户有反馈路径。我加了一个日志导出功能所有蓝牙关键操作都写进本地日志用户遇到问题可以直接把日志发过来。定位问题和改bug的效率比靠用户口头描述高好几倍。如果你正在做类似的项目建议先把流程跑通再优化体验。先用一个固定设备、一套写死的UUID把扫描、连接、收发数据的闭环跑起来之后再考虑多设备兼容、断线重连、防重入这些细节。BLE开发的复杂度是层层展开的刚开始就追求完美反而容易陷在坑里出不来。