蓝牙连接这块,我踩过的坑比调过的接口还多。表面上看,Android 的蓝牙 API 就那么几个类,BluetoothAdapter、BluetoothDevice、BluetoothSocket,文档翻一遍感觉半小时就能写完。但真到设备联调阶段,配对失败、连接超时、数据收不全、不同厂商 ROM 行为不一致,这些问题会一个接一个冒出来。这篇内容就是把我这些年做蓝牙连接的经验摊开讲,从协议栈的基本认知,到经典蓝牙和低功耗蓝牙两条路线的选型,再到连接流程的完整实现和排错思路,尽量让刚接触这块的同行少走弯路,也让已经写过几版代码但总在稳定性上翻车的人找到症结所在。
1. 先搞清楚你连的到底是哪种蓝牙
很多人写蓝牙代码第一步就错了——没分清自己要连的设备走的是经典蓝牙还是低功耗蓝牙。这两套东西在 Android 上是两套 API,底层协议栈不同,连接方式、数据通道、功耗特性完全不一样。选错了路线,后面代码写得再漂亮也是白费。
1.1 经典蓝牙与低功耗蓝牙的本质差异
经典蓝牙(Classic Bluetooth)的核心场景是持续的数据流传输,比如音频、串口透传、文件传输。它建立连接后维持一条稳定的射频通道,吞吐量高,但功耗也高。低功耗蓝牙(BLE,Bluetooth Low Energy)则是为间歇性小数据量通信设计的,设备大部分时间在休眠,只在需要时短暂唤醒收发数据,功耗极低,但单次传输的数据量小、速率低。
从 Android API 角度看,经典蓝牙走的是BluetoothSocket那一套,通过 RFCOMM 通道建立串口式连接;BLE 走的是BluetoothGatt,基于 GATT 协议做服务发现和特征值读写。两者的连接对象、回调机制、错误码体系都不一样。
判断设备类型最直接的办法是看设备广播。经典蓝牙设备在扫描时会返回BluetoothDevice的getBluetoothClass(),能看出大致设备类别;BLE 设备则通过BluetoothLeScanner扫描,拿到的是ScanResult,里面包含广播数据和 RSSI。如果你连的设备是智能手环、传感器、蓝牙锁这类,大概率是 BLE;如果是蓝牙音箱、打印机、车载串口模块,基本是经典蓝牙。
提示:有些设备是双模的,同时支持经典蓝牙和 BLE。这种情况下要看你的业务需求选通道,不要因为设备支持就随便挑一个,通道选错会导致数据格式对不上。
1.2 从业务需求反推技术选型
选型不是看哪个技术新就用哪个,而是看你的数据特征。我一般用几个问题来快速判断:
| 判断维度 | 倾向经典蓝牙 | 倾向低功耗蓝牙 |
|---|---|---|
| 数据量 | 持续大流量,如音频流、文件 | 小包间歇传输,如传感器读数 |
| 连接时长 | 长时间保持连接 | 短连接或周期性唤醒 |
| 功耗要求 | 设备有稳定供电 | 电池供电,要求长续航 |
| 实时性 | 要求低延迟连续传输 | 可接受一定间隔 |
| 典型设备 | 音箱、打印机、串口模块 | 手环、温湿度计、蓝牙锁 |
举个例子,你要做一个连接蓝牙电子秤读体重数据的功能。电子秤只在称重时唤醒,传几十字节的数据就休眠,这明显是 BLE 的场景。反过来,你要做一个连接蓝牙串口模块持续接收传感器数据流的采集工具,数据每秒几十帧,那就得走经典蓝牙的 RFCOMM。
这里有个容易忽略的点:BLE 的 MTU(最大传输单元)默认只有 20 字节左右,虽然 Android 5.0 以后支持请求更大的 MTU,但不同设备协商结果不一样。如果你的业务单包数据超过 20 字节,就要么做分包协议,要么在连接后主动请求 MTU 扩展。经典蓝牙的 RFCOMM 则没有这个限制,流式数据直接读写就行。
1.3 权限模型的变化与适配
Android 的蓝牙权限在 12 版本(API 31)前后发生了重大变化,这是很多人代码在新系统上跑不通的直接原因。旧版本用BLUETOOTH和BLUETOOTH_ADMIN两个普通权限,加上定位权限来扫描;从 Android 12 开始,引入了BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个运行时权限,把扫描、连接、广播拆开了。
适配时的关键点在于:如果你的targetSdkVersion升到了 31 及以上,就必须动态申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT,并且在清单里给旧权限加上android:maxSdkVersion="30"的限制,否则会出现权限声明冲突。扫描时如果不需要通过蓝牙推断地理位置,可以给BLUETOOTH_SCAN加上android:usesPermissionFlags="neverForLocation",这样能避免额外申请定位权限。
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />这段清单配置我建议直接抄,能覆盖绝大多数场景。动态申请时要注意,BLUETOOTH_SCAN和BLUETOOTH_CONNECT是分组的,用户可能只授予其中一个,代码里每次调用相关 API 前都要检查对应权限,不能假设一次申请就全给了。
2. 连接前的准备工作:扫描与配对的门道
扫描和配对看起来是最简单的环节,实际上问题最集中。设备扫不到、扫到了连不上、配对弹窗不出现,这些现象背后各有各的原因。
2.1 经典蓝牙的发现流程与隐藏陷阱
经典蓝牙的扫描用BluetoothAdapter.startDiscovery(),结果通过BroadcastReceiver接收ACTION_FOUND广播。这个流程有几个坑必须提前知道。
第一,startDiscovery()是异步的,而且默认扫描时间大约 12 秒,期间如果调用了其他扫描相关的方法会打断当前扫描。所以不要在扫描过程中频繁调用cancelDiscovery()再重启,容易导致状态混乱。第二,扫描非常耗电,官方建议只在需要时扫描,拿到目标设备后立刻cancelDiscovery(),否则会影响后续连接的稳定性——这一点很多人不知道,扫描和连接是抢射频资源的,扫描没停就发起连接,失败率会明显上升。
第三,ACTION_FOUND广播里拿到的BluetoothDevice只是发现记录,不代表设备可连接。有些设备广播了但拒绝配对,有些设备需要先进入可发现模式。我遇到过一类串口模块,上电后默认不可被发现,必须长按按键进入配对模式才能扫到,这种硬件行为在代码层面无解,只能靠文档和实测确认。
val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == BluetoothDevice.ACTION_FOUND) { val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE) device?.let { // 记录设备名称、地址、信号强度 } } } }注册广播时注意,Android 14 以后对隐式广播的限制更严,ACTION_FOUND属于系统广播,用registerReceiver动态注册即可,不要写进清单静态注册。
2.2 BLE 扫描的参数调优
BLE 扫描用BluetoothLeScanner.startScan(),可以传ScanSettings和ScanFilter。默认配置下扫描功耗高、结果多,实际项目里几乎都要调参。
ScanSettings里最关键的是setScanMode()。SCAN_MODE_LOW_POWER是低功耗模式,扫描窗口短、间隔长,适合长时间后台扫描;SCAN_MODE_LOW_LATENCY是低延迟模式,几乎持续扫描,适合前台快速发现设备;SCAN_MODE_BALANCED折中。我的经验是,进入扫描页面时用LOW_LATENCY快速出结果,用户选定设备后立刻停扫,不要一直挂着低延迟模式,否则电量掉得肉眼可见。
ScanFilter用来按服务 UUID 或设备名过滤,能大幅减少无效回调。如果你明确知道目标设备的服务 UUID,一定要加上过滤,否则周围几十个 BLE 设备的广播全涌进来,处理逻辑会被淹没。
val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) .build() val filter = ScanFilter.Builder() .setServiceUuid(ParcelUuid(SERVICE_UUID)) .build() scanner.startScan(listOf(filter), settings, scanCallback)setReportDelay(0)表示每个扫描结果立即回调;如果设成大于 0 的值,系统会批量延迟上报,适合后台统计场景,但前台交互时不要用,否则用户感觉设备"半天才出来"。
2.3 配对与绑定:什么时候需要,什么时候不需要
经典蓝牙连接前通常需要配对(pairing),配对成功后系统会保存绑定信息(bonding),下次连接不用再配对。BLE 则不一定需要配对,很多 BLE 设备允许未配对直接连接读写,只有涉及加密特征值时才需要绑定。
配对流程本身是系统弹窗驱动的,代码层面能做的有限。device.createBond()可以主动发起绑定,结果通过ACTION_BOND_STATE_CHANGED广播接收。这里有个经典问题:部分 ROM 上createBond()返回 true 但弹窗不出现,或者弹窗出现后用户点了取消,状态回调却延迟很久。我的处理方式是加超时机制,发起绑定后启动一个定时器,比如 30 秒内没收到BOND_BONDED就判定失败并提示用户手动去系统设置里配对。
还有一个细节:配对过程中不要并发发起连接。绑定和连接在协议栈层面有状态依赖,绑定没完成就调connect(),大概率直接抛异常或者静默失败。正确顺序是先确认getBondState()为BOND_BONDED,再发起 socket 连接。
3. 经典蓝牙连接:RFCOMM 通道的建立与数据收发
经典蓝牙的连接核心是BluetoothSocket,通过 RFCOMM 协议建立一条类似串口的双向通道。流程不复杂,但每一步的错误处理决定了最终稳定性。
3.1 服务端与客户端角色的区分
RFCOMM 连接有明确的客户端和服务端角色。客户端用createRfcommSocketToServiceRecord(UUID)发起连接,服务端用listenUsingRfcommWithServiceRecord()监听。大多数场景下 Android 设备是客户端,去连外设(比如串口模块、打印机)。
UUID 是双方约定的服务标识,必须一致。标准服务有固定的 UUID,比如串口服务(SPP)是00001101-0000-1000-8000-00805F9B34FB。自定义设备则要看厂商文档,UUID 对不上连接会直接失败,而且错误信息往往很模糊,只告诉你连接被拒绝。
val uuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") val socket = device.createRfcommSocketToServiceRecord(uuid)创建 socket 本身不发起连接,只是准备好通道对象。真正连接是调用socket.connect(),这是个阻塞调用,必须放在子线程里执行。
3.2 连接过程的阻塞处理与超时控制
connect()是同步阻塞的,没有内置超时参数。如果目标设备不在范围内或者拒绝连接,这个调用可能卡住十几秒甚至更久。我的做法是把它放进一个带超时控制的线程里,超时后主动close()socket 来中断阻塞。
val connectThread = Thread { try { socket.connect() // 连接成功,进入数据收发 } catch (e: IOException) { // 连接失败,清理资源 socket.close() } } connectThread.start() // 超时监控 handler.postDelayed({ if (!connected) { socket.close() // 触发 connect 抛异常退出 } }, 10000)这里有个关键点:socket.close()在另一个线程调用时,会让阻塞中的connect()抛出IOException,这是中断连接的标准手法。不要用Thread.interrupt(),对阻塞的 socket 操作不一定有效。
连接失败后重试要谨慎。我见过有人写了个无限重试循环,结果设备不在旁边时手机疯狂重连,发热严重还耗电。合理的做法是限制重试次数(比如 3 次),每次间隔递增,全部失败后提示用户检查设备状态。
3.3 输入输出流的使用与粘包处理
连接建立后,通过socket.inputStream和socket.outputStream收发数据。写数据直接write()加flush(),读数据则要开一个独立线程循环读取。
val inputStream = socket.inputStream val buffer = ByteArray(1024) while (isConnected) { val len = inputStream.read(buffer) if (len > 0) { val data = buffer.copyOf(len) // 交给上层解析 } }read()也是阻塞的,连接断开时返回 -1 或抛异常。这里最大的坑是粘包和半包:RFCOMM 是流式协议,没有消息边界,你发两次 10 字节,对方可能一次读到 20 字节,也可能分三次读到。所以应用层必须自己定义帧格式,常见做法是"长度头 + 数据体"或者用固定分隔符。
我一般用长度头方案:每帧前两个字节表示后续数据长度,接收端先读 2 字节拿到长度,再循环读到足够字节数为止。这个逻辑要写得严谨,缓冲区管理不能出错,否则数据错位后整个解析全乱。
注意:读写流不要在 UI 线程操作,也不要在同一个线程里既读又写,读写分离到两个线程是基本要求。写操作如果数据量大,也要考虑加锁,避免多线程同时写导致数据交错。
4. 低功耗蓝牙连接:GATT 协议栈的操作逻辑
BLE 的连接模型和经典蓝牙完全不同,核心是 GATT(通用属性协议)。理解 GATT 的分层结构是写好 BLE 代码的前提。
4.1 GATT 分层模型:Service、Characteristic 与 Descriptor
GATT 把设备的数据组织成树形结构。最上层是 Service(服务),每个服务代表一类功能;服务下面是 Characteristic(特征值),是实际的数据载体;特征值下面还可以有 Descriptor(描述符),用来描述特征值的元信息,比如单位、格式、通知配置。
每个 Service 和 Characteristic 都有唯一的 UUID。连接设备后,第一步是discoverServices()发现所有服务,然后在回调里遍历,找到你关心的服务和特征值。这个过程是异步的,必须等onServicesDiscovered回调后才能进行读写操作,提前操作会直接失败。
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { val service = gatt.getService(SERVICE_UUID) val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID) // 后续读写基于 characteristic } }特征值的属性决定了你能对它做什么:PROPERTY_READ表示可读,PROPERTY_WRITE表示可写,PROPERTY_NOTIFY表示支持通知(设备主动推送数据)。写之前一定要检查属性,对不支持写的特征值调writeCharacteristic()会返回失败。
4.2 连接、发现服务与使能通知的完整链路
BLE 连接用device.connectGatt(),传入BluetoothGattCallback接收各种状态回调。连接成功后系统会自动触发服务发现(部分版本需要手动调discoverServices()),然后在onServicesDiscovered里做后续操作。
如果要接收设备推送的数据,需要使能通知:先写CCC描述符(Client Characteristic Configuration,UUID 固定为00002902-0000-1000-8000-00805F9B34FB)开启通知,再在onCharacteristicChanged回调里处理数据。
fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { gatt.setCharacteristicNotification(characteristic, true) val descriptor = characteristic.getDescriptor(CCC_UUID) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) }这条链路有个严格的顺序依赖:连接 → 发现服务 → 使能通知 → 才能收到数据。任何一步没完成就做下一步,都会失败。而且 BLE 的 GATT 操作是串行的,同一时间只能有一个操作在进行,上一个操作的回调没回来之前,发起下一个操作会被拒绝。这是 BLE 开发中最容易出错的地方,后面排错章节会详细讲。
4.3 读写操作的串行化与回调管理
BLE 的读写不是即时的,每次readCharacteristic()或writeCharacteristic()都是发起一个请求,结果在对应的回调里返回。因为协议栈限制,同一时刻只能有一个未完成的操作,所以必须做操作队列。
我的做法是维护一个请求队列,每次操作完成后从队列取下一个执行。写操作的回调是onCharacteristicWrite,读操作是onCharacteristicRead,描述符写是onDescriptorWrite。只有收到当前操作的回调,才能发起下一个。
private val operationQueue = ArrayDeque<GattOperation>() fun enqueue(op: GattOperation) { operationQueue.addLast(op) if (operationQueue.size == 1) executeNext() } private fun executeNext() { val op = operationQueue.firstOrNull() ?: return when (op.type) { READ -> gatt.readCharacteristic(op.characteristic) WRITE -> { op.characteristic.value = op.data gatt.writeCharacteristic(op.characteristic) } } } override fun onCharacteristicWrite(...) { operationQueue.removeFirst() executeNext() }这套队列机制看起来简单,但不做的话,连续写多个特征值时后面的写入会静默丢失,而且没有任何错误提示,排查起来非常痛苦。
5. 连接稳定性:那些文档不会告诉你的坑
代码跑通只是开始,真正难的是让连接在真实环境中稳定。下面这些坑都是我实际项目中踩出来的。
5.1 连接超时与重连策略的设计
BLE 的connectGatt()有个autoConnect参数。设为 true 时,系统会在设备出现时自动重连,适合后台常驻场景;设为 false 时立即尝试连接,超时时间较短,适合前台主动连接。很多人不分场景一律用 false,结果设备暂时离开范围后连接直接断开且不恢复。
我的策略是:前台交互时用autoConnect=false快速建立连接,失败后手动重试;后台监听时用autoConnect=true,让系统托管重连。但要注意,autoConnect=true的重连时机不可控,可能延迟很久,对实时性要求高的场景不合适。
重连还要考虑退避策略。设备断开后立刻重连,如果设备本身有问题,会形成快速重连风暴。我一般用指数退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒,上限 30 秒,同时限制总重试次数。
5.2 不同厂商 ROM 的行为差异
这是 Android 蓝牙开发最头疼的部分。同一个 API,在不同厂商的系统上行为可能不一样。我遇到过的情况包括:某些 ROM 上onCharacteristicChanged回调不在主线程,直接更新 UI 会崩溃;某些 ROM 对后台扫描做了限制,应用退到后台后扫描直接停止;还有的 ROM 在蓝牙关闭再打开后,旧的BluetoothGatt对象没有正确释放,导致新连接失败。
应对这些差异没有银弹,只能靠防御性编程:所有回调都切到主线程处理,扫描前检查蓝牙状态,连接前先close()旧的 gatt 对象并置空。另外,BluetoothGatt.close()一定要在断开连接后调用,否则会泄漏底层资源,连接次数多了之后新连接会莫名其妙失败。
fun disconnect(gatt: BluetoothGatt?) { gatt?.let { it.disconnect() it.close() // 必须调用,释放底层资源 } }5.3 数据收发的边界问题与线程安全
BLE 单包数据受 MTU 限制,默认 20 字节左右。发送超过 MTU 的数据必须分包,接收端要按协议重组。分包时要注意,每个包之间要有适当间隔,连续快速写入可能被协议栈丢弃。
线程安全方面,GATT 的所有操作都应该在同一个线程上下文里发起,避免多线程并发调用。我习惯把蓝牙相关的操作都收敛到一个单线程的 HandlerThread 里,通过消息队列串行执行,这样既保证了顺序,也避免了并发问题。
6. 排错实战:从现象到根因的排查链路
蓝牙问题的难点在于错误信息模糊,同一个现象可能对应多种原因。下面按现象分类,讲我的排查思路。
6.1 扫描不到设备的三层排查法
扫不到设备时,我按三层排查。第一层是权限和开关:蓝牙是否开启、定位服务是否开启(旧版本扫描需要)、运行时权限是否授予。第二层是设备状态:目标设备是否处于可发现模式、电量是否充足、是否已被其他设备连接(很多 BLE 设备同时只允许一个连接)。第三层是代码逻辑:扫描是否真的启动了、回调是否注册正确、过滤器是否过严导致目标被过滤掉。
排查时最有效的手段是加日志,把startScan的返回值、onScanFailed的错误码、每次回调的设备信息都打出来。onScanFailed的错误码很有价值,SCAN_FAILED_ALREADY_STARTED说明重复扫描,SCAN_FAILED_APPLICATION_REGISTRATION_FAILED说明系统蓝牙服务异常,需要重启蓝牙。
6.2 连接成功但收不到数据的定位过程
这个现象在 BLE 上特别常见,原因通常是通知没使能或者使能顺序错了。排查链路是:确认onServicesDiscovered回调成功 → 确认目标特征值有PROPERTY_NOTIFY属性 → 确认setCharacteristicNotification返回 true → 确认 CCC 描述符写入成功(onDescriptorWrite回调 status 为成功)→ 确认onCharacteristicChanged有回调。
任何一环断了都收不到数据。我遇到过 CCC 描述符写入返回成功但实际没生效的情况,原因是写描述符时上一个 GATT 操作还没完成,被协议栈静默丢弃了。这就是前面强调操作队列的原因。
6.3 连接频繁断开的根因分析
频繁断开的原因比较多,按概率排序:信号干扰(周围 2.4G 设备太多)、距离过远、设备固件问题、Android 端资源未释放。排查时先看断开时的onConnectionStateChange回调里的 status 码,GATT_CONN_TIMEOUT是超时,GATT_CONN_TERMINATE_PEER_USER是对方主动断开,GATT_ERROR(status 133)是最常见的通用错误,通常和资源泄漏或协议栈状态异常有关。
status 133 我处理过很多次,多数情况是旧的 gatt 没 close 就建新连接,或者连接过程中并发发起了其他 GATT 操作。解决办法是严格保证连接生命周期管理:每次连接前清理旧对象,连接过程中不做多余操作,断开后必须 close。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 扫描无结果 | 权限缺失、设备不可发现 | 检查权限、确认设备配对模式 |
| 连接超时 | 设备离线、信号弱 | 靠近设备、检查设备状态 |
| 连上无数据 | 通知未使能、操作顺序错 | 检查 CCC 写入回调 |
| 频繁断开 | 资源泄漏、信号干扰 | 检查 gatt 释放、换环境测试 |
| status 133 | 旧连接未清理、并发操作 | 规范生命周期、串行化操作 |
7. 把连接封装成可复用的组件
写了多个蓝牙项目后,我发现每次都在重复处理连接状态机、重试、线程切换这些逻辑。后来我把这些收敛成一个连接管理组件,业务层只关心"连上了""收到数据了""断开了"这几个事件。
7.1 状态机的设计思路
蓝牙连接本质上是个状态机:空闲 → 扫描中 → 连接中 → 已连接 → 断开中 → 空闲。每个状态允许的操作不同,比如连接中不能发起扫描,已连接不能重复连接。用状态机管理能避免大量非法操作导致的异常。
我用一个密封类表示状态,每次状态变更通过回调通知业务层,同时校验操作合法性。这样业务层不用关心底层细节,也不会因为误操作把连接搞乱。
7.2 回调线程的统一与异常兜底
前面提过不同 ROM 回调线程不一致的问题,组件里统一把回调切到主线程,业务层拿到的永远是主线程回调,可以直接更新 UI。异常兜底方面,所有 GATT 操作都包在 try-catch 里,捕获SecurityException(权限问题)和IllegalStateException(状态异常),记录日志并触发重连或提示。
这套封装不是一蹴而就的,是随着踩坑逐步完善的。我的建议是,第一个蓝牙项目可以先把流程跑通,但一定要把连接生命周期管理做规范,等第二个项目时再抽象组件,这样抽象出来的东西才是经过验证的。
蓝牙连接这个领域,API 只是表象,真正决定成败的是对协议行为的理解和对手上设备脾气的熟悉。多打日志、多做防御、多在不同设备上实测,比读十篇文档都管用。