
前阵子把 AS 项目前面的几篇都补完了基础的界面框架、数据存储、主业务流程都跑通了唯独到最后真机联调时被蓝牙通信这块卡了整整三天。老实说蓝牙通信这个功能单看官方文档和各类教程每个 API 感觉都不难但真正串起来做一套可靠的双工通信链路坑远比想象中多。这篇就把我在 AS 项目里实现蓝牙通信的全过程包括技术选型的思考、踩过的坑、最后沉淀下来的可用方案一次性整理清楚给后面做类似跨设备联调的朋友一个能直接抄作业的参考。1. 整体设计先想清楚再动手1.1 蓝牙通信模块在 AS 项目中的定位AS 项目到第六篇这个阶段核心功能不是单一的 App 开发而是一个跨设备联动的控制系统一端是运行在平板或手机上的客户端另一端是需要通信的硬件设备端。客户端负责下发指令、接收设备回传的状态数据和传感器数据设备端根据指令执行动作。这里面的通信层就必须由蓝牙通信来完成。为什么要单独用一个系列篇来梳理这个模块因为蓝牙通信在项目里不是孤立存在的它夹在业务逻辑和硬件驱动中间。往上要对接指令解析、数据存储、通知推送往下要应对各种硬件协议栈的差异。我曾经试过直接从业务层调蓝牙 API结果指令和回调满天飞状态完全不可控。所以第一步不是写代码而是把蓝牙通信模块抽象成独立的服务层对外只暴露连接状态、数据收发事件、错误信息三个维度内部再去处理扫描、连接、GATT 操作、分包重组这些细节。这样设计的好处是后续换硬件设备或者升级协议只动中间层不会把业务层弄得一团糟。1.2 经典蓝牙还是低功耗蓝牙蓝牙通信方案选型第一个要定下来的问题是用经典蓝牙BR/EDR还是低功耗蓝牙BLE。AS 项目最开始我也纠结过这个问题因为有的硬件模块支持 SPP 串口透传用经典蓝牙的 RFCOMM 通道写起来似乎更直观——一端当服务端监听另一端直接连上来当 Socket 读写就行。但放到实际场景里权衡还是选了 BLE原因有几点功耗差异明显。设备端是电池供电的BLE 在待机和短连接时的功耗远低于经典蓝牙对这个场景来说几乎是决定性的。系统 API 兼容性。从移动端看BLE 的扫描、连接、服务发现 API 在主流版本上已经很稳定经典蓝牙的可见性发现和配对流程在不同设备上行为差异大很容易遇到设备能搜到却连不上的尴尬。连接模型更清晰。BLE 的 GATT 结构天然适合设备控制类场景服务Service里挂特征Characteristic一个特征负责下发指令一个特征负责回传状态再配合通知Notify机制就实现了双向通信不需要像 Socket 那样自己管理会话状态。所以 AS 项目最终采用了 BLE 方案。如果你手头是音频传输、大批量文件同步这类高带宽场景才需要考虑经典蓝牙否则尽量优先考虑 BLE。这个结论在我后来和几个做硬件的朋友交流时也得到了验证。1.3 通信协议与链路设计选完协议紧接着要设计应用层的通信协议。这一步很多人会忽略直接把字符串发过去就完事结果到联调阶段各种对齐问题。我建议在蓝牙之上再封装一层简单的帧协议核心是四个要素帧头、长度、指令/数据类型、数据段最后可以加校验。AS 项目的指令帧结构大概是这样的帧头(2字节) 长度(2字节) 类型(1字节) 数据段(N字节) 校验(2字节)帧头用固定字节 0xAA 0x55 做标识收到一帧数据后先判断帧头对不对长度字段记录从类型到数据段的总长度防止粘包校验用 CRC16 或简单异或都行我用的 CRC16稳定性更好。这样设计出来无论是下发的控制指令还是设备回传的采样数据都能在接收端用统一逻辑解析不用每个业务单独处理边界问题。另外还要考虑最大传输单元MTU的问题。BLE 默认的 MTU 只有 23 字节除去协议头应用数据最多 20 字节这在后面收发逻辑里会有很大影响。所以我从设计阶段就明确了大于单包承载的数据必须做分包和重组并且包序号放在数据段头部。这些细节如果等到联调时再补返工成本非常高。2. 权限与初始化基础打牢后面少踩坑2.1 蓝牙权限的版本差异蓝牙权限这个事看着简单实际是 AS 项目里我最早踩坑的地方。不同系统版本的权限模型差异非常大如果只是照着旧教程写uses-permission android:nameandroid.permission.BLUETOOTH/,在较新版本上直接连扫描都跑不起来。归纳下来要区分这几个阶段系统版本需要的权限关键说明Android 6.0 ~ 11.0BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION扫描结果会暴露设备位置必须动态申请定位权限Android 11.0增加ACCESS_BACKGROUND_LOCATION如果想在后台持续扫描需要额外申请后台定位权限Android 12.0 及以上BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE定位权限不再强制但仍要视具体扫描场景而定其中 Android 12 的变化是最需要注意的它引入了细粒度的蓝牙权限把扫描和连接分开了BLUETOOTH_SCAN负责扫描设备BLUETOOTH_CONNECT负责建立连接和读写BLUETOOTH_ADVERTISE是广播设备时用。如果没有在清单文件里声明这些新权限在较新设备上连BluetoothAdapter的startScan方法都可能直接抛SecurityException。在清单文件里我用的声明是这样的uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /注意maxSdkVersion30这种写法目的是在较新系统上不重复申请旧权限同时兼容旧设备。实际测试中有些制造商系统的行为不完全一致所以我还是会在运行时做兜底判断。2.2 动态申请权限的完整流程权限声明只完成了第一步运行时申请才是更麻烦的地方。AS 项目需要的权限不只是蓝牙权限还有定位权限而且在旧版系统上如果没拿到定位权限扫描回调可能永远扫描不到任何设备。动态申请逻辑我放在进入蓝牙通信页面前统一处理。大致流程是检查系统版本确定当前需要哪些权限。遍历权限列表逐个检查是否已授权。对未授权权限发起requestPermissions申请。在回调里确认结果拒绝权限时提示用户并退出页面。有个容易被忽视的细节在 Android 12 及以上即使声明了BLUETOOTH_SCAN如果用户此前关闭过定位服务部分厂商的设备上蓝牙扫描也可能受影响。所以更稳妥的做法是在申请蓝牙权限的同时也检查一下LocationManager的定位开关状态必要时弹窗引导用户打开。虽然这个行为看似多余但我实际遇到过几台设备定位关闭时整个扫描列表是空的打开定位后立刻就恢复了。2.3 扫描回调与设备过滤权限就绪后扫描的逻辑反而没那么复杂但细节不少。核心是通过BluetoothLeScanner启动扫描然后在回调里处理结果val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() bluetoothLeScanner?.startScan(null, settings, scanCallback)SCAN_MODE_LOW_LATENCY能显著提高扫描速度代价是耗电快一点。调试阶段用很合适正式使用时可以根据需求调整成SCAN_MODE_BALANCED。设备过滤是另一个要提前想好的点。如果不过滤回调里会不断收到所有扫描到的 BLE 设备既有我们的目标设备也有用户周围的各种手环、耳机、体脂秤结果列表会非常杂。我的做法是根据设备的广播名称和 MAC 地址做双重校验优先通过设备名称前缀匹配比如 AS 项目的设备统一以AS-开头再做去重处理用设备地址BluetoothDevice.getAddress()作为 key 存入 Map避免同一个设备重复出现。private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult?) { val device result?.device ?: return val name device.name ?: if (name.startsWith(AS-)) { val key device.address if (!discoveredDevices.containsKey(key)) { discoveredDevices[key] device updateUi(device) } } } }这样一个简单的过滤逻辑就让设备列表变得干净可控后续连接时也能直接定位到正确的设备。3. 连接、服务发现与数据收发3.1 连接与 GATT 回调线程模型设备扫描出来之后下一步是建立连接。调用方式很直接就一行device.connectGatt(context, false, gattCallback)但这里有几个非常重要的坑不是看文档能立刻察觉的。第一个是autoConnect参数。传false表示主动连接传true表示让系统在设备可用时自动重连。调试阶段我建议传false因为主动连接出问题时会立刻有回调反馈autoConnect true时连接行为更被动失败时可能隔很久才回调甚至没有任何回调排查起来非常痛苦。第二个是 GATT 回调的线程模型。onConnectionStateChange、onServicesDiscovered、onCharacteristicChanged这些回调都不会跑在主线程而是来自某个 Binder 线程。如果在回调里直接操作 UIApp 会崩溃。所以我习惯在回调里用Handler(Looper.getMainLooper())切回主线程再用接口回调通知上层private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { mainHandler.post { onConnected?.invoke(gatt) } gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { mainHandler.post { onDisconnected?.invoke() } } } }第三个坑是status参数。onConnectionStateChange的status不等于newState。newState是连接状态status才是错误码。当status ! GATT_SUCCESS时即使newState显示已连接这个连接也无法正常使用。所以判断连接成功的方法是status GATT_SUCCESS newState STATE_CONNECTED两个条件缺一不可。3.2 特征读写与通知订阅连接成功并且服务发现完成后要拿到具体的特征Characteristic才能通信。AS 项目里设备端提供两个核心特征写特征Write Characteristic客户端下发指令到这里。通知特征Notify Characteristic设备端主动推送状态数据到这里。拿特征的代码很简单遍历服务下的特征列表即可override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status ! BluetoothGatt.GATT_SUCCESS) { onServiceDiscoverFailed?.invoke(status) return } val service gatt.getService(UUID.fromString(SERVICE_UUID)) val writeCharacteristic service?.getCharacteristic(UUID.fromString(WRITE_UUID)) val notifyCharacteristic service?.getCharacteristic(UUID.fromString(NOTIFY_UUID)) if (writeCharacteristic null || notifyCharacteristic null) { onServiceIncomplete?.invoke() return } // 错误示范只读取特征立刻写入或订阅 // 正确做法先处理通知订阅再写数据 enableNotification(gatt, notifyCharacteristic) }订阅通知这一块是很多人容易忽略细节的地方。光调用gatt.setCharacteristicNotification(characteristic, true)是不够的还必须往这个特征的描述符CCCD即 Client Characteristic Configuration Descriptor里写入0x01或0x02通知才会真正生效private fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { gatt.setCharacteristicNotification(characteristic, true) val descriptor characteristic.getDescriptor(UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)) descriptor?.let { it.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(it) } }这个00002902的 UUID 是固定的 CCCD 标准 UUID基本上所有 BLE 设备的通知开关都走它。如果漏了这一步你会发现设备端的回调onCharacteristicChanged一次都不触发特征是白订阅的。这个坑我踩过整整一个下午最后是抓了设备端的日志才发现问题。再说写入。写入特征时最好判断一下特征支持的写入类型val writeType characteristic.writeType if (writeType BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) { characteristic.value data gatt.writeCharacteristic(characteristic) } else { // 带响应写入需要监听 onCharacteristicWrite 回调 }带响应写入WRITE_TYPE_DEFAULT会等待设备端确认可靠性高但慢不带响应写入WRITE_TYPE_NO_RESPONSE更快但可能丢数据。AS 项目这种指令控制场景我都是用带响应写入因为每条指令都要确保送达。这里还要注意写入操作有并发限制如果连续快速写多个数据包是会有问题的得用一个队列串行处理一个写完再发下一个。3.3 MTU 协商与分包策略MTU 是 BLE 通信里无法绕过的核心概念。默认 MTU 是 23 字节但这 23 字节是协议层的真正留给应用的只有 20 字节。AS 项目要传的数据经常超过 20 字节比如设备回传的一组校准数据长度可能是 100 字节以上如果不处理 MTU就只能拆成很多包来发效率极低。解决方式是用gatt.requestMtu(247)主动请求更大的 MTU。这个方法要放在服务发现之后调用并且只能在连接成功后请求一次。在回调里拿到实际的 MTU 值后再根据它来决定分包策略override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { val actualPayload mtu - 3 // 减去 L2CAP 头 // 后续写数据时按 actualPayload 分包 } }这里有个关键点requestMtu请求的值是期望值不是最终保证值。不同设备的协议栈支持的上限不同有的支持 247有的最多支持 185还有的老设备压根不支持。所以不能硬编码分包大小必须根据onMtuChanged回调里实际拿到的mtu值动态计算。我实际遇到过的情况是硬件端宣称支持 180 字节的 MTU但实际响应只返回 23最后不得不把所有指令按 20 字节分包来发同时靠帧协议里的包序号来做重组。分包的重组逻辑需要用到设计阶段帧结构里的序号字段。接收端收到分帧后先按序号组装成完整帧再交给上层解析。这个逻辑放在蓝牙模块内部完成上层业务无感知。4. 问题排查与实战经验4.1 扫描不到设备扫描不到设备的比例在蓝牙联调中非常高。我把它分成两类看待。一类是权限问题这是新手上路时的高发问题。特别是旧板上要求定位权限如果不申请或者用户拒绝了扫描结果就是空的。我在代码里加了定位开关检查和引导。另一类是过滤条件过严。比如设备广播名与实际不符或者广播名本来就为空恰好过滤代码里用name.startsWith(AS-)判断结果目标设备全被排除了。这时候可以改用一个调试开关让它直接展示所有扫描结果方便定位。还有一个容易忽略的是扫描冲突。如果项目里同时用BluetoothLeScanner和BluetoothAdapter.startDiscovery()做扫描它们是不能并行的后者会直接中断前者。AS 项目里没这个问题但如果你在同一个 App 里既做 BLE 扫描又做经典蓝牙扫描就需要特别注意调度。4.2 连接不稳定、服务发现失败连接不稳定最常见的表现是刚连上没几秒就断开或者连接状态回调里status ! GATT_SUCCESS。第一件事是查设备的广播参数和连接参数。有些硬件端的连接间隔设置过大或者从机延迟太夸张导致移动端误判连接超时。这个时候需要用抓包工具看底层的连接事件而不仅是看 App 层日志。第二件事是排查系统版本差异。在测试中我发现个别较新系统版本上主动连接很容易失败但设备端主动连入却始终稳定。后来通过将autoConnect从false改为true规避了问题。不过如前所述autoConnect true会让调试变得困难所以我建议在正式逻辑里提供一个调试入口可切换两种方式。服务发现失败也是常客。onServicesDiscovered回调里status ! GATT_SUCCESS时我做过几个实验结果发现多半原因是设备端协议栈 bug 或者配对过程未完成。这时候重新读取一遍device.fetchUuidsWithSdp()再重试discoverServices()往往能解决。我甚至在代码里做过自动重试三次的逻辑虽然有点“暴力”但实测成功率确实提升了。4.3 数据收发异常数据收发异常AS 项目里遇到过的表现有收到的手偶字符串乱码、回调不触发、写数据没反应、丢包。先说不触发的问题。最经典的原因就是漏了写 CCCD。另外还有一种可能是订阅位置不对必须先等onServicesDiscovered成功之后才能设置通知不能连接成功就马上订阅。再说丢包。BLE 通信本身是不可靠链路两端信号波动或者连接间隔大偶发丢包是正常现象。对策是应用层做超时重传发指令时记录时间戳如果在规定时间内没收到设备的 ACK就重新发送该帧最多重试三次。这个逻辑看起来很简单但真实提升效果明显。乱码问题一般是字节数组和字符串转换时编码没对齐。统一用 UTF-8 转换收发两端保持一致就行。还有的乱码不是因为编码而是因为没有完整重组收到半帧数据。如果设备端数据长度超过一包接收端只发一包就上报了就会造成半包这种情况下默认当脏数据处理或者丢弃重传机制兜底。4.4 快速排查速查表结合 AS 项目实际调试过程中积累的经验我把高频问题整理成下面的速查表联调时对照着查绝大多数问题能在几分钟内定位现象大概率原因处理建议扫描完全没结果权限未申请或定位开关关闭检查动态权限与系统定位开关扫描有结果但找不到目标设备过滤条件太严或设备名不符临时放开过滤条件核对设备广播名连接成功马上断开硬件连接参数设置不合理抓包确认连接间隔用autoConnecttrue测试服务发现失败服务 UUID 不匹配或协议栈异常确认 UUID重试服务发现通知回调不触发漏写 CCCD 描述符写入00002902描述符写数据无响应写入类型不支持或并发阻塞确认特征写类型加入串行写队列收到数据乱码分包未重组或编码不一致按帧序号重组统一 UTF-8收数据偶尔丢失一帧BLE 链路不可靠增加 ACK 超时重传机制再补充一个通用技巧尽量把蓝牙通信模块的日志完整打出来尤其是show出每次 GATT 回调的状态码。状态码和常见错误对应关系很有价值比如133通常表示连接超时或设备停止响应257表示远程断开。把这些日志做成可开关的开关平时不刷屏调试时打开能省下大量排查时间。5. 最后一步实测与收尾的经验分享写到这里其实还想多提一句关于联调环境的事。蓝牙通信这种功能光看回调日志是不完整的有条件的话尽量抓一份底层的 HCI 日志作为参照。我在 AS 项目里曾经遇到过一个特别诡异的场景App 层看所有回调完全正常数据也显示发出去了但设备端就是没收到。后来抓了底层日志才发现部分数据包在 L2CAP 层就被丢弃了。没有底层日志这种问题靠猜恐怕永远定位不到。还有个小经验想分享给大家在调试阶段尽量把超时时间调短一些比如连接超时设为 5 秒扫描超时设为 10 秒这样跑一轮流程不用等太久。正式落地时候再把这些值放大避免在弱网环境下误判。AS 项目的蓝牙通信模块从最开始能收到几包乱数据到后来稳定跑完一整轮的指令下发和状态回传中间基本就是本篇梳理的这些内容在反复调试。回头看最难的不是某个 API 不会用而是把连接状态、数据分包、MTU、超时重传这些东西作为一个整体来设计。只要前期设计把框架搭对了后面每一步调试都有方向。如果你现在也在做类似的跨端蓝牙联调不妨把这篇的问题排查表打印一份放旁边踩坑的时候对照处理应该能帮你少走不少弯路。