做Android蓝牙开发这块断断续续也折腾了四五年了。从最开始用经典蓝牙连单片机到后面转低功耗蓝牙做穿戴设备数据采集中间踩过的坑比代码行数还多。很多刚入门的同学一上来就搜怎么搜索设备、怎么配对结果写完发现手机连不上、数据收不到、一会儿就断连然后就开始怀疑人生。这篇东西我把这些年摸出来的路子、踩过的坑、以及能用得上的代码模块整理一遍照着做能少走不少弯路。不管你是要做蓝牙聊天、连血压计、接手柄还是跟自己的硬件板子通信核心思路都很一致。1. 蓝牙开发整体思路与方案选型1.1 经典蓝牙与低功耗蓝牙的本质区别动手写代码之前先得搞清楚你面对的是哪一套协议。Android下的蓝牙开发大体分成两条线经典蓝牙Bluetooth Classic简称BR/EDR和低功耗蓝牙Bluetooth Low Energy简称BLE。这俩虽然都叫蓝牙但工作方式、连接流程、数据交互方式完全不一样选错了后面全是坑。经典蓝牙走的是RFCOMM串口协议一次连接建立后是一条稳定的双向数据通道适合传输数据量大、持续传输的场景。典型例子比如蓝牙音箱的音频流、蓝牙手柄的按键数据、老式蓝牙串口模块跟单片机的通信。它的特点是连接稳定、吞吐量大、功耗高而且配对过程涉及pin码确认用户体验上会多一步操作。低功耗蓝牙走的是GATTGeneric 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 12API 31是个分水岭从这版开始原来粗暴的三个权限BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION被拆成了更细分的运行时权限。Android 12以下只需要在Manifest里声明uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /Android 12及以上还需要额外声明uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.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一般是CCCDUUID为00002902-0000-1000-8000-00805f9b34fb写入ENABLE_NOTIFICATION_VALUE0x0001两个步骤缺一不可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 Listbyte[] splitPacket(byte[] data) { Listbyte[] 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代替NotificationIndication是确认型的丢包率会小很多。第四个是并发冲突。多个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端能省下大把翻代码的时间。这个习惯我用了好几年屡试不爽。