很多刚开始接触 Android 蓝牙开发的同学,一上来就被 BLE 这套名词砸懵了:GATT、Service、Characteristic、UUID、MTU、广播…… 网上教程也不少,但要么是官网文档的翻译腔,要么直接甩一堆代码让抄,抄完还是不知道为什么要这么写。我最早做这块的时候也差不多,花了大半个月踩坑,才把整个链路真正理顺。
这篇文章我就用新手最容易理解的方式,把 Android BLE 蓝牙通讯协议从头到尾拆一遍。不会只贴代码,重点讲清楚每一步背后的原理是什么、为什么要这么做、坑在哪里。你把这篇文章看完,再回去看官方文档或者看第三方库的源码,会轻松非常多。
适合正在做 Android 蓝牙开发、或者准备做 IoT 设备对接、运动健康类 App、智能硬件控制类项目的同学参考。只要你有点 Android 基础,能写 Activity、能处理权限,这篇文章就够你入门了。
1. 什么是 BLE,先分清楚它和经典蓝牙的区别
BLE 全称 Bluetooth Low Energy,低功耗蓝牙。从 Android 4.3(API 18)开始支持,到现在已经非常成熟。它和传统经典蓝牙(BR/EDR)最大的区别,就是不是为持续的大流量传输设计的。
经典蓝牙像打电话,建立一个稳定的通道,一直占着,持续传输音频或串口数据。BLE 更像收发短信,平时不占信道,需要的时候发一条信息,发完就进入睡眠状态。这种设计让它能用一颗纽扣电池跑几个月甚至几年,代价就是传输速率低,实际的吞吐量一般也就在几 KB/s 到几十 KB/s 这个量级。
这点想清楚之后,你就能判断一个需求到底适不适合用 BLE。比如传输音频流、传大文件图片,BLE 就非常吃力,那是经典蓝牙或者 Wi-Fi 的活儿。而传感器数据的低频上报、设备控制指令、状态同步这一类,BLE 是完美的选择。
另一个容易混淆的点是BLE 和蓝牙 4.0/5.0 的关系。蓝牙 5.0 是硬件规范版本,BLE 是低功耗的通讯模式。市面上很多蓝牙 5.0 的芯片,实际跑的还是 BLE 的 GATT 协议。你用 Android 开发的时候,不需要关心底层射频版本,只需要知道:BLE 通讯走的是 GATT(Generic Attribute Profile)这套逻辑结构。
注意:开发中偶尔会碰到一些老设备,用的是 BluetoothSocket 那套经典蓝牙的 API,和 BLE 完全不是一回事。判断依据很简单——如果你的设备支持 BLE,它一定会广播 GATT 相关的服务,而且系统回调走的是 BluetoothGattCallback 而不是 BluetoothSocket 的输入输出流。
2. 核心架构拆解:GATT 到底是什么
BLE 通讯的整个逻辑,都建立在 GATT 协议之上。你可以把它理解成一套目录结构。一个 BLE 设备,就是一个文件柜,里面装着各种文件,每个文件有规定的格式和读写权限。
这套目录从上到下分四层:
- Profile:设备的整体能力描述,你可以看作是一个应用场景的完整定义。比如心率 Profile,它定义了这个设备需要有心率测量服务、设备信息服务等。
- Service(服务):文件柜里的抽屉。每个 Service 代表一类功能,它有一个唯一的 UUID。比如 0x180D 是心率服务,0x180F 是电池服务。
- Characteristic(特征):抽屉里的具体文件。这是我们真正操作的对象。每个 Characteristic 也有 UUID,而且它决定了数据的格式、读写方式。
- Descriptor(描述符):文件的补充说明,比如 0x2902(CCCD)用来开启通知,0x2901 描述这个特征的用户说明。
新手最容易绕晕的地方,就是 Service 和 Characteristic 的关系。我打个比方:Service 是“电池管理”,Characteristic 是“当前电量”“充电状态”“电压值”。你读数据,永远是在读 Characteristic,而不是读 Service。Service 只是组织数据的壳。
一个设备可以有一个或多个 Service,一个 Service 可以有多个 Characteristic,一个 Characteristic 可以挂多个 Descriptor。数据从 BLE 设备传给手机,实际上就是手机读取或者订阅设备的某个 Characteristic。
UUID 是这套体系里的门牌号。标准服务用 16 bit 的短 UUID,Android SDK 里已经定义了一部分,比如:
| 服务 | UUID | 常见特征 |
|---|---|---|
| 心率服务 | 0x180D | 心率测量 0x2A37、传感器位置 0x2A38 |
| 电池服务 | 0x180F | 电量 0x2A19 |
| 设备信息服务 | 0x180A | 厂商名、序列号等 |
| 自定义服务 | 开发者自定 | 通常用 128 bit UUID |
大部分 IoT 设备走的是自定义服务,UUID 由硬件厂商决定,一般会在芯片原厂的数据手册里给出。比如某个厂商的温湿度传感器,Service 的 UUID 可能是 0000FFF0-0000-1000-8000-00805F9B34FB,里面的写特征、通知特征都配好了。你需要做的,就是照着这份文档去操作。
提示:拿到一台新设备,先别急着写代码。用 nRF Connect 这个 App 连上它,把 Service 和 Characteristic 列表截图出来,对照需求数据格式,你就知道该读写哪些特征了。这一步能省掉后面大量调试时间。
3. Android BLE 开发前置准备:权限与环境检测
动手写代码之前,权限这块必须先捋清楚。Android 的蓝牙权限历经了几个版本的变化,不同系统版本上要求的权限不一样,处理不好可以说连设备都扫不到。
3.1 权限配置清单
在 AndroidManifest.xml 里,需要声明这些权限:
<!-- 传统蓝牙权限 --> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <!-- Android 12(API 31)及以上,使用 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT --> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- Android 11 及以下,需要定位权限才能扫描 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />关键点来了:为什么扫描 BLE 需要定位权限?因为 BLE 广播包里面携带的信息有被用来做位置追踪的风险,所以系统强制要求定位权限来保护用户隐私。虽然你的设备可能根本和定位没关系,但是在 Android 11 及以下,不申请定位权限就是扫不到设备。这是新手第一个容易栽跟头的地方。
到了 Android 12 之后,系统把这个权限拆成 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT,不再依赖定位权限。但是注意,如果你给 BLUETOOTH_SCAN 标注了 neverForLocation,扫描返回的广播包里某些字段可能会被过滤掉,比如 beacon 厂商数据。
3.2 运行时权限申请
光在 Manifest 里声明不够,Android 6.0 以上运行时权限必须动态申请:
private fun requestPermissions() { val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ) } else { arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) } requestPermissions(permissions, REQUEST_CODE) }注意:Android 12 的 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 是危险权限,也需要运行时申请。只写在 Manifest 里不弹窗申请,照样不能用。另外,不同厂商手机上,权限申请时机和位置服务开关有各种奇奇怪怪的联动,有些手机开了定位但关了蓝牙扫描权限,扫描结果是空白。实测下来,把权限给全、把系统定位服务打开,才是最稳的。
3.3 蓝牙开关检测
另外一个很容易踩的坑是:扫描之前必须先确认蓝牙已开启。Android 12 上申请 BLUETOOTH_CONNECT 后,才能调用 enable() 或者 ACTION_REQUEST_ENABLE 来弹窗开启蓝牙:
val adapter = BluetoothAdapter.getDefaultAdapter() if (adapter == null) { // 设备不支持蓝牙 return } if (!adapter.isEnabled) { val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) }4. BLE 通讯完整链路:扫描、连接、发现服务、读写与订阅
核心链路按顺序走五步,环环相扣:扫描设备 → 连接设备 → 发现服务 → 操作特征 → 收发数据。我把每一步单独拆开讲,顺便把每一步的关键参数说清楚。
4.1 扫描设备:不设置过滤就是灾难
扫描 API 从 Android 5.0 开始推荐使用 BluetoothLeScanner,老版本的 startLeScan 已经过时了,新项目别再用。
private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device val rssi = result.rssi val scanRecord = result.scanRecord val deviceName = scanRecord?.deviceName ?: result.scanRecord?.advertisementData?.deviceName // 处理扫描到的设备 } override fun onScanFailed(errorCode: Int) { // errorCode: SCAN_FAILED_ALREADY_STARTED, SCAN_FAILED_APPLICATION_REGISTRATION_FAILED 等 } } fun startScan() { val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() val filters = mutableListOf<ScanFilter>() // 按服务 UUID 过滤,减少无效回调 // filters.add(ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()) BluetoothAdapter.getDefaultAdapter() ?.bluetoothLeScanner ?.startScan(filters, settings, scanCallback) }三个重点参数:
- ScanMode:LOW_LATENCY 扫描最快,但耗电;BALANCED 适中;LOW_POWER 最省电但出结果慢。调试阶段直接用 LOW_LATENCY,发布前根据自己的场景调整。
- 过滤条件:不加过滤的话,办公室里的蓝牙耳机、手环、信标全都会扫进来,回调非常频繁。实际项目里一定加 ScanFilter 做过滤,通常按服务 UUID 过滤最有效。
- 扫描窗口:Android 有个隐藏的 30 秒扫描限制,超过就会静默停止。长按扫描的话要自己在 onScanResult 里做计时,到点调 stopScan。
还有个细节:onScanResult回调里拿到的 name,很多设备广播包里根本没放,这时候 device.name 是空字符串。不要用 name 去匹配合适的设备,要看 address 或者广播数据里的 UUID。
4.2 连接设备与连接状态管理
拿到 BluetoothDevice 之后,调用connectGatt建立连接:
private var bluetoothGatt: BluetoothGatt? = null fun connect(device: BluetoothDevice, context: Context) { // autoConnect = false,表示主动发起连接,速度快 // autoConnect = true,表示等待设备广播再连接,常用于自动回连 bluetoothGatt = device.connectGatt(context, false, gattCallback) }这个方法常被忽略的参数是autoConnect。新手最容易掉坑的就是这里:autoConnect = true并不是“断线后自动重连”的意思,而是不主动发起连接,等设备广播了再连。这个模式连接过程非常慢,可能几十秒,而且不容易调试。绝大多数主动连接场景,直接设 false 就好。
真正原始的“断线自动重连”,需要你自己在 onConnectionStateChange 里检查断开状态,然后重新调用 connectGatt。
连接状态的判断,同样看 onConnectionStateChange:
private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { // 连接成功,开始发现服务 gatt.discoverServices() } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { // 连接断开,处理重连逻辑 } } }4.3 发现服务:连接后的第一步
连接成功后,系统还不会主动告诉你设备有哪些服务。必须调用discoverServices(),然后从onServicesDiscovered回调里拿到完整的 GATT 服务列表:
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { // 发现服务失败,重试或断开 return } val services = gatt.services for (service in services) { // 遍历特征值 } }这里要说一个常见的坑:连接成功和服务发现成功不是一个概念。你在连接成功的回调里立刻去读特征,大概率会失败,因为此时 GATT 服务列表还没准备好。一定要等 onServicesDiscovered 回调。
整体流程用大白话讲就是:连接成功后先进大楼,然后挨个房间看看有什么家具(发现服务),之后才能去操作某个具体的家具(读写特征)。
4.4 读写特征与开启通知
GATT 里面有个基本概念需要注意:读写操作都是异步的,你调用 readCharacteristic 不会立刻返回数据,而是等 onCharacteristicRead 回调。我第一次写的时候把它当成同步方法调,结果拿到的永远是 null,排查了半天才反应过来。
// 获取特征值 fun findCharacteristic(serviceUuid: UUID, charUuid: UUID): BluetoothGattCharacteristic? { val service = gatt?.getService(serviceUuid) ?: return null return service.getCharacteristic(charUuid) } // 读特征 fun readCharacteristic(char: BluetoothGattCharacteristic) { gatt?.readCharacteristic(char) } override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { val data = characteristic.value // 解析数据 } } // 写特征 fun writeCharacteristic(char: BluetoothGattCharacteristic, data: ByteArray) { char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT gatt?.writeCharacteristic(char) } override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { // 写入成功/失败 }写特征这里有两个类型要区分清楚:
- WRITE_TYPE_DEFAULT:带响应的写,设备收到数据后会回 ACK,可靠但慢。
- WRITE_TYPE_NO_RESPONSE:无响应写,指哪打哪,速度快但不知道设备收没收到。
传感器的控制指令一般用带响应的写,因为要确保指令已经生效;特殊场景比如 OTA 升级,因为数据量大,通常用无响应写来提速。
再说说通知(Notification)。这是 BLE 里最核心的机制。设备往手机推数据,最省电的方式不是让手机一直轮询读特征,而是让设备主动通知。步骤是:
- 找到你要接收通知的特征
- 往它的 CCCD(0x2902)描述符里写入 0x0001,开启通知
- 之后设备的数值变化会自动走
onCharacteristicChanged回调
fun enableNotification(char: BluetoothGattCharacteristic, enable: Boolean) { val enabled = if (enable) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE val cccd = char.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) if (cccd != null) { gatt?.setCharacteristicNotification(char, true) // 写 CCCD 这一步很关键,漏了的话设备不会主动推数据 cccd.value = enabled gatt?.writeDescriptor(cccd) } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val data = characteristic.value // 处理设备推送的数据 }重要:开启通知有两个动作,一个是
setCharacteristicNotification(char, true),另一个是往描述符里写值。顺序不能反,漏掉哪一个都不行。我之前帮人排查过一个 bug,代码里只调了 setCharacteristicNotification,没写 CCCD,结果设备端完全没反应。
4.5 MTU 与数据分包:新手最容易忽略的硬限制
BLE 的一个经典限制是:默认单包数据只有 20 个字节。这是由 ATT 协议包的长度决定的。Android 开发的默认 MTU(Maximum Transmission Unit)是 23 字节,去掉 3 字节的 ATT 头,实际有效负载就是 20 字节。
这就是为什么很多硬件设备的协议都会设计成“单条指令不超过 20 字节”,比如智能锁的开关指令、电视遥控器的按键指令。如果你要传 100 字节的数据,不优化的话就只能分 5 包。
解决办法是协商 MTU 变大:
// 在连接成功后、读写数据前调用 override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { gatt.requestMtu(247) // 请求 MTU 增加到 247 } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { // 协商结果,mtu 是设备支持的实际值 // 通常有效负载 = mtu - 3 }requestMtu(247)是 Android BLE 支持的最大值,但要设备端也支持,所以协商结果要以 onMtuChanged 回调里的值为准。有些老设备不支持 MTU 协商,回调里返回的 mtu 还是 23,那你就只能走分包方案。
MTU 协商成功后,有效负载从 20 字节涨到 244 字节,单包能传的数据量大幅提升。但这只是解决了单包传输的容量问题,超过 MTU 的数据仍然需要自己分包。BLE 没有自动拆包组包的机制,写一个自定义分包协议是 IoT App 开发的基本功。
一个简单的分包思路:
fun packData(data: ByteArray, mtu: Int): List<ByteArray> { val maxPayload = mtu - 3 val result = mutableListOf<ByteArray>() var offset = 0 while (offset < data.size) { val length = minOf(maxPayload, data.size - offset) result.add(data.copyOfRange(offset, offset + length)) offset += length } return result }分包之后,需要在数据包头里带上分片序号和总数。比如用 1 个字节表示包序号(0~255),1 个字节表示总包数,接收方拿到后重新拼接。另外还要考虑丢包的兜底逻辑,比如设备端超时没收到后续分包,就丢弃整包数据等待重新发送。
5. 实际开发中绕不开的坑:状态错乱、连接失败与重连策略
BLE 开发里最容易让人抓狂的,不是功能写不出来,而是各种诡异的时序问题和状态错乱。这一节我把最常踩的坑整理一下,全是实战记录。
5.1 onConnectionStateChange 状态乱跳
最常见的一个问题:onConnectionStateChange回调里,刚上报 connected,下一秒就上报 disconnected,过了几秒又 connected。而且连接一会儿成功一会儿失败,看着毫无规律。
排查思路:先看 status 码。status 是 133(GATT_ERROR)是最典型的,这个错误码在 AOSP 源码里叫“Connection timeout”,绝大多数情况是由于connectGatt 之后马上又调用了 connectGatt,把之前的连接覆盖了,或者上一个 gatt 对象没有正确关闭。
解决办法是建立一个连接状态机,任何操作都判断当前状态:
when (state) { CONNECTING -> // 连接中,不做任何操作 CONNECTED -> // 已连接,才能读写 DISCONNECTED -> // 已断开,才能重新连接 }另外,连接失败后直接重连往往没用,因为底层可能还残留之前的连接状态。标准的做法是调用gatt.close()释放旧连接,等一点时间再重新 connectGatt。我给一个重连前的释放模板:
private fun releaseGatt() { if (bluetoothGatt != null) { bluetoothGatt?.disconnect() bluetoothGatt?.close() bluetoothGatt = null } }先 disconnect 再 close,两个都要调。只 close 不 disconnect 或者只 disconnect 不 close,都可能导致蓝牙服务异常。
5.2 扫描不到设备
扫描不到设备,大概率不是硬件问题,而是系统设置问题,按优先级排查:
| 检查项 | 说明 |
|---|---|
| 权限 | FINE_LOCATION(Android 11 及以下)或 BLUETOOTH_SCAN(Android 12+)是否已授予 |
| 定位服务 | 系统“位置信息”开关是否打开 |
| 蓝牙开关 | 系统蓝牙是否已开启 |
| 过滤条件 | ScanFilter 是否设置了不匹配的 UUID |
| 扫描时间 | 是否扫描超过 30 秒被系统静默停止 |
| 缓存问题 | 手机蓝牙缓存异常,重启手机可解决 |
5.3 读写特征失败(status 为 128 或 133)
写入请求发出去,回调回来 status 不等于 GATT_SUCCESS,最常见就是这两个错误码。
Status 128 通常是底层连接已经断了,但你还在操作特征。解决办法是在每次操作前检查bluetoothGatt?.connect状态,状态不对就别发指令。
Status 133(GATT_CONNECTION_TIMEOUT)更隐蔽。它可能出现在这几个场景:重连太频繁、上个 gatt 没 close、设备进入了深度睡眠。我做智能门锁项目时遇到过设备锁屏后即断连,就是门锁为了省电主动断的,需要 App 在收到断开回调后等待几秒再重连。实测下来,断开后等 2~3 秒再发连接请求,成功率高很多。
5.4 自动重连的正确写法
很多业务场景要求断线自动重连。新手最容易写出的 buggy 代码是:在 onConnectionStateChange 的 disconnected 分支里,直接调用 connectGatt。这样会触发一个重连风暴——每次断开都立刻重连,然后立刻失败,再重连,循环多次后系统直接拒绝请求。
一个相对稳妥的重连策略是:
- 断开时,通过 Handler 延迟 3 秒再重连。
- 重连前先 close 旧 Gatt。
- 连续重连 N 次失败后,放弃自动重连,通知用户手动操作。
- 连接成功后重置重连计数。
private fun handleDisconnected() { if (retryCount >= MAX_RETRY) { retryCount = 0 // 回调给 UI,提示用户设备离线 return } releaseGatt() handler.postDelayed({ retryCount++ gatt = device?.connectGatt(context, false, gattCallback) }, 3000) }5.5 主线程与 BLE 回调的时序关系
BLE 回调默认跑在 binder 线程,你可能需要用它去更新 UI,如果你直接开一个 Activity 的成员变量并在回调里赋个值,然后回主线程去读,因为缺少可见性处理,偶尔会出现值没变的情况。建议统一通过主线程 Handler 切回 UI 线程处理,避免一些隐晦的线程安全问题:
val mainHandler = Handler(Looper.getMainLooper()) override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val data = characteristic.value // 不需要强行切线程,直接在这里处理数据即可 // 如果要更新 UI,用 runOnUiThread }6. 实战记录:一个模拟温度传感器的完整对接过程
这一节我拿一个典型的模拟项目来串流程——某物联网公司出的温湿度传感器模块,带屏幕,支持 BLE。整个过程完全按我在真实项目里走过的路径来走,你看看就明白整套操作是怎么衔接的。
6.1 拿到设备之后的第一件事:读文档
很多人拿到硬件的第一反应是打开代码写扫描。我的习惯是,先把文档里的 GATT 服务列表抄下来。这个模块的文档里给了两个关键服务的说明:
| 参数 | 值 |
|---|---|
| 温湿度服务 UUID | 0000181A-0000-1000-8000-00805F9B34FB |
| 温度特征 UUID | 00002A6E-0000-1000-8000-00805F9B34FB |
| 湿度特征 UUID | 00002A6F-0000-1000-8000-00805F9B34FB |
| 数据更新通知 | 需要写 0x2902 使能 |
| 数据格式 | 温度:Int16(0.01 ℃ 分辨率) |
温湿度数据是两个特征分别读的,而且特征是只读的,不能写。这意味着我们不需要写特征的代码,只需要读和订阅通知。
6.2 连接与数据解析的代码串联
整体代码流程其实就是前面讲的主线,我把核心环节串起来:
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) return // 拿到两个特征 val tempChar = gatt.getService(TEMP_SERVICE_UUID).getCharacteristic(TEMP_CHAR_UUID) val humiChar = gatt.getService(HUMI_SERVICE_UUID).getCharacteristic(HUMI_CHAR_UUID) // 开启通知 enableNotification(tempChar, true) enableNotification(humiChar, true) // 主动读一次当前值 gatt.readCharacteristic(tempChar) gatt.readCharacteristic(humiChar) } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { when (characteristic.uuid) { TEMP_CHAR_UUID -> { val raw = characteristic.value // Int16 小端序,值为 0.01 度 val temp = (raw[0].toInt() and 0xFF) or (raw[1].toInt() shl 8) val finalTemp = temp * 0.01 } HUMI_CHAR_UUID -> { val raw = characteristic.value val humi = (raw[0].toInt() and 0xFF) or (raw[1].toInt() shl 8) val finalHumi = humi * 0.01 } } }这里有几个典型的解析细节:
- 温度特征值是 Int16,也就是两个字节。第一个字节是低字节(小端序),第二个是高字节,所以组合成 Int16 的时候要
(raw[0] and 0xFF) or (raw[1] shl 8),顺序反了算出来的温度会完全不对。 - 温度和湿度都是带符号的(温度可能是负数),但传感器说明书注明使用补码表示,所以要保持 Int16 的有符号解读,直接用
toShort().toInt()可能更保险。 - 从 BLE 拿到的 ByteArray 在传输过程中可能有 0x00 填充,解析前先看长度是否足够。我之前遇到过某些安卓设备在回调时填充了多余字节,导致解析结果错乱。
6.3 一个关键优化:减少轮询次数
这个温度计设备如果只支持“读”,那 App 就只能定时去读。但它支持通知,正确的做法是让设备自己上报。开启通知之后,设备端检测到温湿度变化,就会主动推数据到手机,App 完全不用轮询。不仅省电,响应也更及时。
我在实际项目里遇到不少初学同学,把定时读特征收到的数据和通知回调的数据混在一起处理,导致 UI 刷新频率特别乱。最好是统一走通知通道,轮询只在设备不支持通知的场景下才用。
6.4 断线重连与界面恢复的联动处理
传感器设备如果长时间没数据变化,有些硬件会进入低功耗模式,导致连接断开。App 需要处理好断开时的 UI 提示和数据展示。
我的处理方式是:
- 断开回调里,标记当前显示的数据为“过期数据”,界面置灰。
- 自动重连成功后,重新读一次温湿度值,界面恢复高亮。
- 连续 3 次重连失败后,给出“设备离线”的明确提示,引导用户检查设备状态。
- 用户手动断开时,加一个 isUserDisconnected 标志,避免触发重连逻辑。
这些细节看着琐碎,但正是决定了 BLE App 好不好用的关键。缺少重连机制的产品,用户用起来会觉得很“飘”,动不动失联。
7. 调试技巧与工具链:少走弯路的关键
最后聊一聊调试工具。BLE 开发绝对不能靠瞎猜,用对工具,效率翻倍。
7.1 硬件侧调试:nRF Connect
nRF Connect(Nordic 出的)是 BLE 调试神器。连接设备后能直接看到 GATT 服务列表、特征值属性、实时数据变化,甚至能直接手动写特征值、开通知、模拟读操作。每次写代码之前,先用 nRF Connect 验证一下设备的协议行为。如果工具上操作成功了但代码里失败,那问题基本集中在权限、时序、Android 缓存这些环节,而不是协议本身。
7.2 Android 侧调试:打开 HCI 日志
开发者选项里有一个“蓝牙 HCI 信息收集日志”选项,打开后系统会把蓝牙底层的数据包全部记录到 /sdcard/btsnoop_hci.log。这个日志能帮你确认蓝牙数据有没有发出去、设备有没有回包、底层报了什么错误。有些问题在应用层完全看不到,但扒一下 HCI 日志,原因一目了然。
7.3 调试辅助代码:加时间戳和状态日志
BLE 回调是异步的,时序问题很难排查。建议在关键回调里打上日志,格式统一带时间戳:
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { Log.d(TAG, "[${System.currentTimeMillis()}] connection state change status=$status newState=$newState") }连不上、连上秒断这类问题,有了时序日志,你只需要简单对照一下时间线,就能基本定位问题所在。
8. BLE 开发的核心心法:从框架到细节的进阶路径
文章最后,我给你一个总的学习路径建议。
第一步,理解协议。GATT 的分层结构、广播与连接、服务特征值的概念,这些是地基。地基不牢,后面全是空中楼阁。
第二步,跟着官方 Demo 走一遍。安卓官方有个 BltGatt 的开源示例项目,虽然代码风格比较老,但胜在完整,从扫描到连接到一个完整的 GATT 操作流程都能覆盖到。
第三步,换真机实操。拿自己的手机加一个便宜的蓝牙模块(比如某开发板开发板),完整跑一遍扫描、连接、读写、通知。把每一段代码的每一个回调都在 Logcat 里确认过,用 Log 验证思路,而不是猜测思路。
第四步,做项目。给自己设计一个小需求,比如做一个室内温湿度采集,手机上显示数据并绘制曲线。这个项目做完,你对 BLE 的掌握就可以说真正入门了。
很多人问 BLE 开发难不难。我的体会是:技术上并不深,但牵扯到的细节非常多。权限、时序、Mtu 协商、分包、状态机、重连策略,每一环都是坑。把这些坑都趟过了,剩下的就是业务逻辑了。
最后的最后,分享一个我个人的经验:代码里凡是涉及 BLE 的状态操作(连接、断开、读写),最好都通过一个独立封装的 BleManager 类来管理,不要散落在 Activity 或 Fragment 里。BLE 的异步回调并发起来非常复杂,集中管理状态,出问题的时候排查难度会小很多。