做Android蓝牙开发这块,断断续续也折腾了四五年了。从最开始用经典蓝牙连单片机,到后面转低功耗蓝牙做穿戴设备数据采集,中间踩过的坑比代码行数还多。很多刚入门的同学一上来就搜怎么搜索设备、怎么配对,结果写完发现手机连不上、数据收不到、一会儿就断连,然后就开始怀疑人生。这篇东西我把这些年摸出来的路子、踩过的坑、以及能用得上的代码模块整理一遍,照着做能少走不少弯路。不管你是要做蓝牙聊天、连血压计、接手柄,还是跟自己的硬件板子通信,核心思路都很一致。
1. 蓝牙开发整体思路与方案选型
1.1 经典蓝牙与低功耗蓝牙的本质区别
动手写代码之前,先得搞清楚你面对的是哪一套协议。Android下的蓝牙开发大体分成两条线:经典蓝牙(Bluetooth Classic,简称BR/EDR)和低功耗蓝牙(Bluetooth Low Energy,简称BLE)。这俩虽然都叫蓝牙,但工作方式、连接流程、数据交互方式完全不一样,选错了后面全是坑。
经典蓝牙走的是RFCOMM串口协议,一次连接建立后是一条稳定的双向数据通道,适合传输数据量大、持续传输的场景。典型例子比如蓝牙音箱的音频流、蓝牙手柄的按键数据、老式蓝牙串口模块跟单片机的通信。它的特点是连接稳定、吞吐量大、功耗高,而且配对过程涉及pin码确认,用户体验上会多一步操作。
低功耗蓝牙走的是GATT(Generic Attribute Profile)协议,数据交互是"客户端-服务端"模式,设备暴露Service和Characteristic,手机作为中心设备去读写和订阅通知。它的特点是功耗极低、连接快、广播机制灵活,但单次传输数据量小,不适合持续大流量。血压计、手环、体脂秤、ibeacon定位这些基本都是BLE。
判断标准很简单:你要传音频、传大文件、跟老式串口蓝牙模块通信,走经典蓝牙;你要做传感器数据采集、低功耗待机设备、或者对接市面上的智能硬件,走BLE。还有一点,手机上两类蓝牙是并行存在的,Android SDK同时支持两套API,互不干扰但不通用。我见过不少人拿BLE的API去连经典蓝牙串口模块,折腾半天连不上,就是因为协议本身就不是一回事。
1.2 需求分析:先明确通信对象和数据结构
选型之前,先回答自己三个问题:跟谁通信、传什么数据、数据量多大。这三个答案基本决定了你的技术路线。
第一个问题,跟谁通信。如果对方是你自己画的硬件板子,优先看板子蓝牙模块的类型。市面上HC-05、HC-06之类的基本是经典蓝牙串口模块,直接用BluetoothSocket最省事。如果是CC2541、nRF52832这种BLE模组,就得走GATT。如果是买来的现成设备(手环、体脂秤),一般厂商SDK文档里会写清楚Service UUID和Characteristic UUID,照着对接就行。
第二个问题,传什么数据。简单命令控制(比如开灯、关机)和结构化数据(比如血压值、心率曲线、GPS轨迹)通信方式差异很大。前者可能一条指令就搞定,write一个byte数组过去就行;后者要处理分包、组包、校验和、应答机制。我早期做的一个心电贴项目,硬件端每包只发20字节,一次完整心电波形要收200多包,得自己在应用层做序号缓存和重组,这跟蓝牙本身没关系,但必须在设计阶段就想好。
第三个问题,数据量多大。经典蓝牙的RFCOMM通道实测能到几十KB/s甚至百KB/s级别,BLE在Android上的实际吞吐量受限于MTU和连接间隔,默认20字节一包,就算把MTU协商到512,稳定速率也就几KB/s。传文件、传音频基本别指望BLE,老老实实经典蓝牙。
把这三件事想清楚,再回头选API和架构,你会发现代码写起来顺很多。硬件的活儿不好返工,软件方案也一样,前期多花半小时画个通信协议表,后面能省一周的调试时间。
2. 开发环境准备与基础配置
2.1 项目配置与SDK版本适配
不管用Android Studio的新版本还是老版本,蓝牙开发的第一步都是把Gradle配置和AndroidManifest弄对,这一步出问题往往是最隐蔽的。
先说SDK版本。如果你用的是Android Studio新版本,默认的compileSdk和targetSdk基本指向Android 13、14甚至15,这时候蓝牙权限的变化必须处理清楚。Android 12(API 31)是个分水岭,从这版开始,原来粗暴的三个权限(BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION)被拆成了更细分的运行时权限。Android 12以下只需要在Manifest里声明:
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />Android 12及以上还需要额外声明:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />注意,BLUETOOTH_SCAN和BLUETOOTH_CONNECT在Android 12+是运行时权限,光声明不行,得在代码里动态申请,跟申请定位权限一个套路。还有一个特别容易漏的地方:经典蓝牙搜索设备依然需要定位权限。这个不是Android故意刁难,而是因为蓝牙扫描可以用来推断用户位置,系统层面强制要求。哪怕你只在室内连个蓝牙音箱,不申请定位权限就是搜不到设备。Android 10以下还需要手动打开定位开关,Android 12之后系统做了优化,只开蓝牙也能扫描到部分设备,但保险起见定位权限该申请还是申请。
Gradle里还有个细节建议加上,防止老设备兼容问题:
android { compileSdk 34 defaultConfig { minSdk 21 targetSdk 34 } }2.2 Android Studio环境与真机调试注意事项
好多初学者卡在环境这关。Android Studio的下载安装其实没什么玄学,官网下载对应系统的安装包,一路下一步就行。需要注意的几点:SDK Manager里把Android SDK Platform、Build-Tools和Platform-Tools装上,特别是platform-tools,里面有adb,后面无线调试、抓日志全靠它。汉化的话,Android Studio自带的插件市场搜Chinese Language Pack,装上重启就是中文界面,不影响功能。
蓝牙开发跟普通App开发最大的不同是:模拟器基本没用。Android模拟器默认不支持蓝牙,就算部分镜像声称支持,底层也是虚拟设备,跟真机行为差异很大。所以从一开始就要准备好真机,最好有两台不同品牌的手机,因为各家厂商对蓝牙协议栈的封装和兼容性真的有区别。我遇到过同一套代码,小米手机连接正常,华为手机搜不到设备,最后发现是厂商在系统层面对扫描回调做了频率限制。
真机调试建议直接用USB数据线连接,开发者选项里打开USB调试。如果嫌线麻烦,Android Studio支持无线调试——前提是手机和电脑在同一局域网,Android 11及以上可以在开发者选项里直接开启无线调试,用adb pair扫码配对,之后就能甩开数据线了。这个在蓝牙调试场景下特别实用,因为有时候插着线会影响握持,而且一些OTG外设会跟USB调试抢通道。
打开USB调试后,建议顺手把"不锁定屏幕"和"保持唤醒"打开,蓝牙调试经常要长时间盯着日志,屏幕一锁就得重新解锁,很烦。另外一定要会用Logcat的过滤功能,按包名过滤日志是基本功,蓝牙相关日志我习惯统一打上BT_TAG,方便筛出来看。
3. 核心功能实现:从搜索到连接
3.1 经典蓝牙的搜索、配对与Socket通信
经典蓝牙的整条链路分三步走:搜索设备、配对绑定、建立Socket连接。每步都有各自的坑。
搜索设备的核心类是BluetoothAdapter,拿到适配器后调用startDiscovery()开始扫描,通过注册BroadcastReceiver接收ACTION_FOUND广播获取设备列表。关键代码如下:
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); if (adapter == null) { // 设备不支持蓝牙 return; } if (!adapter.isEnabled()) { Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT); } // 注册广播接收器 IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND); registerReceiver(receiver, filter); adapter.startDiscovery(); private final BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (BluetoothDevice.ACTION_FOUND.equals(action)) { BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); if (device != null) { // device.getName() 可能为null,注意判空 // device.getAddress() 例如 00:11:22:AA:BB:CC } } } };这里有几个经典坑位。第一,startDiscovery()是异步的,扫描过程大概持续12秒,期间不能同时发起Socket连接,必须先cancelDiscovery()再连,否则会连接失败。第二,搜索结果里会混入一些不可见或者无名字的设备,界面展示要过滤。第三,重复扫描要先取消上一次,不然系统会报"discovery already started"。
蓝牙设备拿到手之后,下一步就是配对。如果你用的是createBond(),系统会弹配对框。但很多硬件模块(比如HC-05)是固定pin码,1234或者0000。对这类设备,有些系统版本下createBond()后还需要处理ACTION_BOND_STATE_CHANGED广播,等状态变成BOND_BONDED再继续。配对状态广播:
IntentFilter filter2 = new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED); registerReceiver(mPairReceiver, filter2); private final BroadcastReceiver mPairReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); int state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1); if (state == BluetoothDevice.BOND_BONDED) { // 配对完成,可以建立Socket连接 } } };配对成功后就是建立Socket通信。经典蓝牙默认的RFCOMM通道UUID是00001101-0000-1000-8000-00805F9B34FB,这个UUID对应SPP串口协议。几乎所有的蓝牙串口模块都认这个UUID,可以直接用。建立连接的代码:
BluetoothDevice device = adapter.getRemoteDevice(address); BluetoothSocket socket = device.createRfcommSocketToServiceRecord( UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")); // 连接前取消扫描,重要 adapter.cancelDiscovery(); socket.connect(); // 这个方法会阻塞,必须在子线程里执行connect()是阻塞调用,放主线程直接ANR,必须丢到子线程。建立连接后拿到InputStream和OutputStream,就能读写数据了。读数据也要单独开线程循环读,因为read()会一直阻塞等待数据到达。
3.2 低功耗蓝牙的连接与GATT操作流程
BLE的开发模式和经典蓝牙完全不同,代码结构上更接近异步回调风格。核心抽象是BluetoothGatt和BluetoothGattCallback。手机作为中心设备连接外设的流程是:connectGatt()->onConnectionStateChange()->discoverServices()->onServicesDiscovered()-> 拿到Service和Characteristic -> 读写或者订阅通知。
先看最基础的连接:
BluetoothDevice device = adapter.getRemoteDevice(address); BluetoothGattCallback gattCallback = new BluetoothGattCallback() { @Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState == BluetoothProfile.STATE_CONNECTED) { // 连接成功,开始发现服务 gatt.discoverServices(); } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { // 断开连接,注意处理自动重连逻辑 } } @Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { // 遍历服务,找到目标Service和Characteristic for (BluetoothGattService service : gatt.getServices()) { // service.getUuid() } } } }; BluetoothGatt gatt = device.connectGatt(context, false, gattCallback);这段代码看起来简单,但实际开发中要额外处理的细节非常多。
第一个是connectGatt的第二个参数autoConnect。如果你传true,系统会一直尝试自动重连,包括设备不在范围内的时候,这个在某些场景很坑——比如列表页不小心点了一下,就会一直后台重连。通常建议传false,自己控制连接时机。
第二个是连接超时问题。connectGatt返回后系统会异步建立连接,但官方没有提供超时回调。实际连接可能因为设备不在广播、距离太远、或者刚断连还处于冷却期而一直卡住。我做法是写一个10秒的Handler超时检测,10秒内没收到STATE_CONNECTED就主动调gatt.disconnect()和gatt.close(),然后提示用户。不close的话,这个gatt对象会一直占着系统资源,下次连接还会出各种诡异问题。
第三个是服务发现。有的设备广播的Service和实际支持的Service不一致,或者固件有点小bug,偶尔会出现onServicesDiscovered一直不回调的情况。稳妥做法是在onConnectionStateChange里连接成功之后延迟一小会儿(100-300ms)再调discoverServices(),给协议栈一点缓冲时间。
拿到Characteristic之后,有三类常见操作。第一种是读,调gatt.readCharacteristic(),结果通过onCharacteristicRead回调返回。第二种是写,调gatt.writeCharacteristic(),注意写入的时候如果characteristic的property里带WRITE_TYPE_NO_RESPONSE,可以不等待回包,速度快但不可靠;带WRITE_TYPE_DEFAULT则要求设备返回写确认。第三种是订阅通知,也是BLE里最常用的方式,通过setCharacteristicNotification(characteristic, true)开启,同时还要给该characteristic的descriptor(一般是CCCD,UUID为00002902-0000-1000-8000-00805f9b34fb)写入ENABLE_NOTIFICATION_VALUE(0x0001),两个步骤缺一不可:
gatt.setCharacteristicNotification(characteristic, true); BluetoothGattDescriptor descriptor = characteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.writeDescriptor(descriptor);很多新手只调了setCharacteristicNotification就等着收数据,结果onCharacteristicChanged死活不触发,就是这个descriptor没写。这个步骤坑了我整整一个下午,后来看厂商SDK源码才发现。
3.3 UUID与数据分包:BLE开发绕不开的细节
BLE开发里UUID的重要性怎么强调都不过分。Service UUID标识设备提供的功能域,Characteristic UUID标识具体数据项。跟硬件对接时,厂商文档里一般会给一个类似0000FFF0-0000-1000-8000-00805F9B34FB的Service,下面挂几个FFF1、FFF2这样的Characteristic。每个Characteristic有读、写、通知、指示等属性,实际能力要读int properties = characteristic.getProperties()去判断,或者直接看文档。
数据分包是BLE数据交互的物理限制。在不协商MTU的情况下,一个write或者一个notification最多携带20字节。如果需要传大于20字节的数据,就得自己定协议分包。常见的做法是:包头2字节表示总包数和当前包序号,1字节表示数据域长度,后续接payload。比如一条校验指令:
// 分包发送示例:每包最多18字节有效载荷 public List<byte[]> splitPacket(byte[] data) { List<byte[]> packets = new ArrayList<>(); int totalLen = data.length; int packetCount = (totalLen + 17) / 18; // 向上取整 for (int i = 0; i < packetCount; i++) { int len = Math.min(18, totalLen - i * 18); byte[] packet = new byte[len + 4]; packet[0] = (byte) 0xAA; // 包头 packet[1] = (byte) packetCount; // 总包数 packet[2] = (byte) (i + 1); // 当前序号,从1开始 packet[3] = (byte) len; // 数据长度 System.arraycopy(data, i * 18, packet, 4, len); packets.add(packet); } return packets; }接收端就要做逆操作:buffer一个完整帧,解析包头、判断序号是否连续、把payload按序塞回去。这个协议看起来简单,但在低质量蓝牙环境下会出现漏包、错序、重复包,严谨的做法是在应用层加校验和和超时重传。这个就属于通信协议的范畴了,有机会单独写一篇。
4. 实操过程与完整工程搭建
4.1 一个最小可用的BLE连接管理器
说了这么多理论,直接给一个能跑的最小方案。我一般在项目里会封装一个BleManager单例,把权限检查、连接、发现服务、读写、通知封装成统一的对外接口,业务层只关心设备地址和回调。这样整个项目里不会到处散落BluetoothGatt的裸调用。
public class BleManager { private static volatile BleManager instance; private BluetoothGatt gatt; private BluetoothGattCharacteristic targetCharacteristic; private BleCallback callback; public static BleManager getInstance() { if (instance == null) { synchronized (BleManager.class) { if (instance == null) { instance = new BleManager(); } } } return instance; } private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() { @Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState == BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices(); callback.onConnected(); } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { callback.onDisconnected(); close(); } } @Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { BluetoothGattService service = gatt.getService(SERVICE_UUID); if (service != null) { targetCharacteristic = service.getCharacteristic(CHAR_UUID); enableNotification(targetCharacteristic); } } } @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { byte[] data = characteristic.getValue(); callback.onDataReceived(data); } }; public void connect(Context context, String address, BleCallback cb) { this.callback = cb; BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address); gatt = device.connectGatt(context, false, gattCallback); } public void write(byte[] data) { if (gatt == null || targetCharacteristic == null) return; targetCharacteristic.setValue(data); gatt.writeCharacteristic(targetCharacteristic); } private void enableNotification(BluetoothGattCharacteristic characteristic) { gatt.setCharacteristicNotification(characteristic, true); BluetoothGattDescriptor descriptor = characteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); if (descriptor != null) { descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.writeDescriptor(descriptor); } } public void close() { if (gatt != null) { gatt.disconnect(); gatt.close(); gatt = null; } } public interface BleCallback { void onConnected(); void onDisconnected(); void onDataReceived(byte[] data); } }这只是骨架,实际项目还要加错误码回调、超时机制、重连策略。但核心的链路就是这个。要注意的是这个封装里callback的调用都在Binder线程,回UI要自己post到主线程,别直接在回调里更新UI。另外生命周期管理很重要,页面销毁时记得调close()释放gatt,否则会导致蓝牙服务因资源泄漏出现异常。
4.2 经典蓝牙Socket通信的线程模型
经典蓝牙的通信模型更像传统的Socket编程,数据传输是流式的。连接的建立和数据的读写都不能在主线程,通常做法是维护一个独立的ConnectedThread,持有socket和输入输出流:
private class ConnectedThread extends Thread { private final BluetoothSocket socket; private final InputStream inputStream; private final OutputStream outputStream; public ConnectedThread(BluetoothSocket socket) { this.socket = socket; try { inputStream = socket.getInputStream(); outputStream = socket.getOutputStream(); } catch (IOException e) { throw new RuntimeException(e); } } @Override public void run() { byte[] buffer = new byte[1024]; int bytes; while (true) { try { bytes = inputStream.read(buffer); // 处理读取到的数据,注意可能断包,需要自行协议解析 } catch (IOException e) { // 连接断开,线程退出 break; } } } public void write(byte[] bytes) { try { outputStream.write(bytes); } catch (IOException e) { // 发送失败处理 } } }这里有个我在实际中踩过的坑:经典蓝牙串口通信经常出现一包数据被拆成多次read返回的情况,尤其数据量大时。比如硬件一次发送100字节,这边可能先读到30字节,再读到60字节,再读到10字节。所以inputStream.read()之后不能直接把buffer当一帧数据用,得自己维护一个累积缓冲区,按照实际协议去切帧。这就回到了设计阶段说的"通信协议先行"——没有帧头、长度、校验这些字段,底层数据再怎么拼都拼不明白。
4.3 动态权限申请与Android 12适配
权限这块必须写进代码里,光靠Manifest声明不够。Android动态权限申请的标准流程是用ActivityCompat.requestPermissions,处理回调后判断是否授权。蓝牙相关的权限两个版本都要照顾:Android 12以下定位权限决定能不能扫到设备,Android 12以上则是BLUETOOTH_CONNECT和BLUETOOTH_SCAN。
private void checkPermissions(Activity activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { requestPermissions(activity, new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }, REQUEST_PERMISSION_CODE); } else { requestPermissions(activity, new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_PERMISSION_CODE); } }注意Android 12以下如果targetSdk指向31及以上,系统会认为你适配了新权限模型,此时BLUETOOTH和BLUETOOTH_ADMIN自动失效,但不影响你声明它们,属于兼容写法。还有一种情况是用户拒绝权限后勾选了"不再询问",这时候requestPermissions不会弹窗,回调直接返回拒绝。遇到这种情况应该引导用户到App设置页手动开启,这个跳转代码建议封装成工具,很多场景都要用。
权限适配完之后还有个小细节:部分国产ROM在权限管理里做了自定义处理,比如MIUI默认拦截后台定位权限、某些机型需要在设置里单独打开"附近设备"权限。这些厂商行为没有统一标准,只能靠多机型适配测试积累经验。我的做法是权限申请失败时打印完整的权限列表状态,方便排查是不是被系统拦截了。
5. 常见问题与排查思路
5.1 设备搜索不到:先查权限再查广播
"搜索不到设备"是蓝牙开发提问率最高的问题。按我的排查顺序来:
第一步,检查手机蓝牙是否真的打开了。BluetoothAdapter.isEnabled()返回false说明没开,有时候App里看着是开的,实际系统层因为省电策略关了,先手动开关一次蓝牙再试。
第二步,检查权限。Android 12以下,定位权限缺失或定位开关没开,搜索永远没结果;Android 12以上,BLUETOOTH_SCAN和BLUETOOTH_CONNECT必须都已经授权,并且Android 13以上的机型建议把NEARBY_WIFI_DEVICES这类附近设备权限一并检查。
第三步,检查广播接收器是否注册成功。ACTION_FOUND广播是隐式广播,必须用代码动态注册,在Manifest里静态注册是收不到的。注册了一定要在onDestroy里反注册,否则会内存泄漏。
第四步,检查设备本身。有些蓝牙设备广播间隔很长,Android的扫描窗口可能错过。可以等10秒以上再看结果,或者尝试关闭再打开设备电源,让设备重新进入广播状态。
如果以上都查了还是不行,试试用系统自带的蓝牙设置页能不能搜到设备。系统能搜到但App搜不到,那是代码问题;系统也搜不到,那是设备或环境问题,比如设备在配对列表里需要先删除原绑定记录,或者设备只允许已配对设备连接。
5.2 连接不稳定与频繁断开
连接不稳定不像搜索不到那么明显,但更让人抓狂。我归纳了几个高频原因。
第一个是连接间隔(Connection Interval)问题。BLE设备请求了一个很短的连接间隔来保证传输速率,但Android系统在某些场景下会因为调度不及时导致超时断链。这在旧款手机上尤其明显,比如某些低端机型普遍更容易断。我们项目里定位到问题后,在硬件端把连接间隔从7.5ms调整为15ms,断链率立刻降了一个量级。如果是跟成品设备对接没法改连接参数,可以考虑在App里加监听onConnectionStateChange的自动重连逻辑,但要设置重连次数上限,避免无限重试耗电。
第二个是gatt实例没有正确close。很多开发者在断开连接后只调了disconnect()忘了close(),或者反过来直接close()导致连接状态回调混乱。正确流程是断开时disconnect()后等onConnectionStateChange回调了STATE_DISCONNECTED再close(),这样系统资源能正确释放。调试时可以在onConnectionStateChange里打完整日志,确认状态机是正常流转的。
第三个是手机锁屏或灭屏后,系统为了省电挂起蓝牙调度。这个问题比较难从App层面完全规避,只能是尽量持有唤醒锁(WakeLock),或者在onConnectionStateChange里监听灭屏广播,在屏幕关闭前主动发一包保活数据。实测对部分设备有效,但治标不治本。真要搞长时间稳定的数据传输,建议硬件端用BLE的Indication代替Notification,Indication是确认型的,丢包率会小很多。
第四个是并发冲突。多个gatt同时操作同一个设备,比如一边在onCharacteristicChanged回处理数据,一边又对同一个characteristic发起了write,部分协议栈会直接崩溃或断开。规范做法是内部维护一个操作队列,writeDescriptor、writeCharacteristic、readCharacteristic之间保证串行执行。之前我在一个项目里踩过这个坑:开启通知的同时立即发一条write指令,结果设备直接掉了,后来改成write完成后再发下一条,就稳定多了。
5.3 数据错乱与粘包问题
数据收到但解析不对,这个问题比连接问题更隐蔽。BLE按包收发,看似天然区分了消息边界,实际上如果你在一次onCharacteristicChanged里收到的不完整,或者对方把多条指令合成一条发过来,同样会出现"粘包"。经典蓝牙的流式传输就更是如此,边界完全依赖应用层协议。
解这个问题的核心思路是buffer + 状态机解析。定义一个接收缓冲区,把每次收到的字节append进去,然后循环查找一帧完整的协议——根据帧头和长度字段判断当前buffer里有没有完整帧,有就切出来交给上层处理,没有就继续等下一包。伪代码示意:
private ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private void onRawData(byte[] data) { buffer.write(data, 0, data.length); byte[] all = buffer.toByteArray(); int parsed = 0; while (parsed + HEADER_LEN <= all.length) { // 找到帧头,解析长度字段 int frameLen = parseFrameLength(all, parsed); if (frameLen < 0 || parsed + frameLen > all.length) { break; // 数据不够一帧,等待下一次 } byte[] frame = Arrays.copyOfRange(all, parsed, parsed + frameLen); handleFrame(frame); parsed += frameLen; } // 把剩余未解析的数据保留 buffer.reset(); buffer.write(all, parsed, all.length - parsed); }这里的关键是协议里必须有可靠的帧结束标志或者长度字段,否则永远切不对。另外CRC校验一定要做,蓝牙链路虽然自带纠错,但在强干扰环境下错误帧仍然会出现,应用层做一层CRC能挡掉绝大多数脏数据。我见过因为没做校验,把心电数据里的错误字节当成正常数据展示,导致波形上出现毛刺,排查了半天才发现是校验问题。
6. 性能优化与兼容性经验
6.1 用日志和工具定位问题
蓝牙开发调试比普通App调试难在它是跟硬件交互,很多问题只有在真机+真实设备上才能复现。我的调试习惯是,把所有关键节点都打上结构化日志:扫描开始、扫描结束、连接发起、连接回调、服务发现、通知开启、每条收发的数据。日志的tag统一,配合Logcat的过滤,基本能拼出完整的时间线。出问题时把日志导出来,先看状态机走没走对,再定位是哪个环节断的。
Android自带的开发者选项里有个"蓝牙数据包日志"功能,开启后能通过bugreport抓取蓝牙协议栈的HCI日志,这个对分析底层断链原因非常有用,尤其当你怀疑是协议栈问题而不是App问题时。抓到的日志可以用Wireshark的hcidump插件解析,能看到L2CAP层的连接参数和错误码,这个进阶技能关键时候能救命。
6.2 多机型兼容性经验总结
蓝牙开发绕不开设备碎片化。不同厂商对蓝牙协议栈的修改不一样,直接表现就是同一个功能在不同手机上行为差异明显。我总结了几条实用的经验:
第一,主流的扫码方式不止一种,部分老设备不支持新的扫描过滤API,直接用老的startLeScan反而更稳。Android 12以上系统已经废弃了startLeScan,必须用BluetoothLeScanner,但底层仍然会兼容执行,所以在低版本设备上不用刻意区分。
第二,厂商对ACTION_FOUND广播的发送频率有限制,有的ROM会合并或延迟广播,导致设备列表出现延迟。处理办法是不要完全依赖广播做实时列表刷新,可以用adapter.getBondedDevices()先加载已配对设备,搜索作为增量补充。
第三,国产ROM的省电策略影响很大。有些手机在后台会冻结App的蓝牙扫描或GATT连接,前台正常后台掉线。如果业务需要后台保持连接,必须引导用户把App加入电池优化白名单。这个在Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS页面里操作,代码里跳过去让用户手动加。
第四,也是我认为最核心的一条:永远不要把业务逻辑和蓝牙协议栈的API调用混在一起。中间抽象一层repository或者manager,上层业务只依赖回调接口。这样出现兼容性问题时,你只需要改manager层的适配代码,业务层纹丝不动。我后期所有蓝牙项目都强制这个架构,维护成本直线下降。
6.3 项目继续深挖的方向
写完一个能跑通的蓝牙模块只是开始。如果后续产品真的要走量,还有很多扩展方向值得投入:OTA固件升级需要实现基于BLE的DFU流程,这是量产设备逃不掉的;安全配对需要研究LE Secure Connections和MITM防护,涉及敏感数据传输时必须做;后台保活和系统级连接管理要研究厂商的蓝牙中间层API。这些每一个都是大坑,够写好几篇长文。好在基础链路相通,把前面这些基础打牢,遇到这些问题至少有迹可循。
我个人在实际操作中体会最深的一点是:蓝牙开发七分靠协议理解,三分靠代码。协议搞清楚,代码怎么写都是顺的;协议没搞清,代码再花哨也是白搭。所以强烈建议各位在动手写代码前,先把设备的文档吃透——Service有哪些、Characteristic属性是什么、数据格式怎么定义、有没有特殊时序要求。这些信息哪怕问厂商、翻规格书、甚至用蓝牙调试助手手动抓到,也要先摸清。也别嫌麻烦,前期梳理得越细,后期联调越省时间。
最后再分享一个小技巧:办公室里常备一个蓝牙调试助手App,就是那种可以手动扫描、连GATT、收原始数据的工具。联调遇到问题时,先用它手动连一次,能确认设备本身是否正常,再回来看App代码,这样可以快速把问题隔离到"设备端"还是"App端",能省下大把翻代码的时间。这个习惯我用了好几年,屡试不爽。