☰
Android蓝牙通信实战:BLE权限适配与GATT数据收发全流程解析
2026/10/14 23:03:43 网站建设 项目流程

前阵子把 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:name="android.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:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

注意maxSdkVersion="30"这种写法,目的是在较新系统上不重复申请旧权限,同时兼容旧设备。实际测试中,有些制造商系统的行为不完全一致,所以我还是会在运行时做兜底判断。

2.2 动态申请权限的完整流程

权限声明只完成了第一步,运行时申请才是更麻烦的地方。AS 项目需要的权限不只是蓝牙权限,还有定位权限,而且在旧版系统上,如果没拿到定位权限,扫描回调可能永远扫描不到任何设备。

动态申请逻辑我放在进入蓝牙通信页面前统一处理。大致流程是:

  1. 检查系统版本,确定当前需要哪些权限。
  2. 遍历权限列表,逐个检查是否已授权。
  3. 对未授权权限发起requestPermissions申请。
  4. 在回调里确认结果,拒绝权限时提示用户并退出页面。

有个容易被忽视的细节:在 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 线程。如果在回调里直接操作 UI,App 会崩溃。所以我习惯在回调里用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 项目实际调试过程中积累的经验,我把高频问题整理成下面的速查表,联调时对照着查,绝大多数问题能在几分钟内定位:

现象大概率原因处理建议
扫描完全没结果权限未申请或定位开关关闭检查动态权限与系统定位开关
扫描有结果但找不到目标设备过滤条件太严或设备名不符临时放开过滤条件,核对设备广播名
连接成功马上断开硬件连接参数设置不合理抓包确认连接间隔,用autoConnect=true测试
服务发现失败服务 UUID 不匹配或协议栈异常确认 UUID,重试服务发现
通知回调不触发漏写 CCCD 描述符写入00002902描述符
写数据无响应写入类型不支持或并发阻塞确认特征写类型,加入串行写队列
收到数据乱码分包未重组或编码不一致按帧序号重组,统一 UTF-8
收数据偶尔丢失一帧BLE 链路不可靠增加 ACK 超时重传机制

再补充一个通用技巧:尽量把蓝牙通信模块的日志完整打出来,尤其是show出每次 GATT 回调的状态码。状态码和常见错误对应关系很有价值,比如133通常表示连接超时或设备停止响应,257表示远程断开。把这些日志做成可开关的开关,平时不刷屏,调试时打开,能省下大量排查时间。

5. 最后一步:实测与收尾的经验分享

写到这里,其实还想多提一句关于联调环境的事。蓝牙通信这种功能,光看回调日志是不完整的,有条件的话,尽量抓一份底层的 HCI 日志作为参照。我在 AS 项目里曾经遇到过一个特别诡异的场景:App 层看所有回调完全正常,数据也显示发出去了,但设备端就是没收到。后来抓了底层日志才发现,部分数据包在 L2CAP 层就被丢弃了。没有底层日志,这种问题靠猜恐怕永远定位不到。

还有个小经验想分享给大家:在调试阶段,尽量把超时时间调短一些,比如连接超时设为 5 秒,扫描超时设为 10 秒,这样跑一轮流程不用等太久。正式落地时候再把这些值放大,避免在弱网环境下误判。

AS 项目的蓝牙通信模块,从最开始能收到几包乱数据,到后来稳定跑完一整轮的指令下发和状态回传,中间基本就是本篇梳理的这些内容在反复调试。回头看,最难的不是某个 API 不会用,而是把连接状态、数据分包、MTU、超时重传这些东西作为一个整体来设计。只要前期设计把框架搭对了,后面每一步调试都有方向。

如果你现在也在做类似的跨端蓝牙联调,不妨把这篇的问题排查表打印一份放旁边,踩坑的时候对照处理,应该能帮你少走不少弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询