
1. 写在前面为什么在Fragment里写蓝牙比Activity麻烦得多几个月前我接了一个模拟项目X需求是把设备配网流程改成Fragment弹层交互。方案里有一个环节是通过蓝牙和硬件模块通信本来觉得蓝牙这块我之前在Activity里写过很多次闭着眼都能写。结果真上手才发现这套逻辑一旦搬进Fragment坑比想象中多得多生命周期错位、DialogFragment弹权限回调丢失、Fragment重建导致蓝牙连接对象失效、甚至getActivity()空指针崩溃。这篇文章把我最终跑通的方案完整记录下来重点讲Fragment场景下蓝牙通信从权限适配、设备扫描、配对连接到数据收发全流程怎么组织以及那些不踩一遍根本发现不了的细节。文章面向有Android基础、想在Fragment里跑通蓝牙通信的开发者。如果你还没写过蓝牙建议先过一遍官方文档再来看这篇文章如果你已经在Activity里写过蓝牙但没在Fragment里试过那这篇文章应该能帮你省掉不少调试时间。核心内容围绕三件事展开一是怎么把蓝牙模块封装得和Fragment生命周期解耦二是权限适配、扫描、配对、连接、读写这些关键环节在Fragment语境下各有什么雷区三是稳定复现后的几个高频问题和排查思路。2. 技术选型与整体设计思路2.1 经典蓝牙还是BLE先想清楚蓝牙通信在Android上粗略分两条技术路线传统蓝牙Classic Bluetooth走SPP串口协议和低功耗蓝牙BLE走GATT协议。我这个项目里硬件模块是把经典蓝牙当成串口透传用所以必须在传统蓝牙这条线上实现。如果你对接的是智能手环、温度计这类低功耗设备就得走BLE那套GATT Service/Characteristic的流程两者在代码组织上有很大的区别后面讲到的很多细节不能混用。怎么判断选哪条线简单粗暴的方式是看设备协议和通信模型设备端如果有明确的串口服务UUID最典型的是SPP的00001101-0000-1000-8000-00805F9B34FB基本就是传统蓝牙如果设备定义了Service、Characteristic、Notify等一堆GATT概念那必然是BLE。选错方向的话最常见的现象就是扫描列表里永远看不到目标设备或者连接时一直报错。2.2 Fragment场景下的架构思路连接层必须独立于界面在Activity里写蓝牙的常见写法是把BluetoothAdapter、连接线程、读写线程直接做成Activity的成员变量Activity销毁跟着销毁。但Fragment不同Fragment有独立的生命周期而且经常因为页面切换、屏幕旋转、FragmentTransaction的replace/add反复销毁重建。如果蓝牙连接对象直接挂在Fragment上你会遇到一个非常头痛的局面——用户刚配对好设备屏幕一旋转连接断了一切要从头来。我的做法是把蓝牙通信封装成一个单例管理器核心是三个层次数据模型层定义设备对象、连接状态、收发消息回调等纯Java模型不依赖任何UI类蓝牙管理器层负责适配器获取、设备扫描、配对、连接、IO读写所有动作内部自行管理线程不绑FragmentUI接入层Fragment里通过ViewModel持有管理器引用注册观察者拿状态和数据。这样设计之后Fragment的销毁不影响蓝牙连接的底层运行重建后的Fragment能够迅速恢复状态。ViewModel帮我解决了配置变更时的数据存活问题连接层单例则保证了蓝牙连接不会因为界面重建而中断。2.3 项目里最终决定的通信流程整个流程我梳理成了下面这条链路后续所有代码都是围绕这条链路组织的权限检查 - 蓝牙开关检查 - 扫描设备 - 用户选择设备 - 配对(如需) - 建立Socket连接 - 启动读写线程 - 数据收发 - 断开连接实际操作中还需要在每一步之间插入状态判断。比如扫描前要确认适配器存在且已打开连接前要确认设备没有超出范围数据收发时要保证流没有被意外关闭。把状态机理顺之后后面所有逻辑都变得清晰调试起来也方便很多。3. 权限与环境准备版本差异是最大的坑3.1 权限申请要按API级别分层处理蓝牙权限这块Android版本不同要求完全不同。早期版本只要求BLUETOOTH和BLUETOOTH_ADMIN从Android 6.0API 23开始扫描相关的权限被归到了危险权限里需要运行时申请主要是ACCESS_FINE_LOCATION。从Android 12API 31开始又是一轮大改新增了BLUETOOTH_SCAN和BLUETOOTH_CONNECT老的BLUETOOTH权限反而变成了安装时普通权限。写进Manifest的权限声明建议直接兼容全版本不要图省事只写一套uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation tools:targetApis / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /说几个细节。ACCESS_FINE_LOCATION在Android 12以下负责定位权限在Android 12以上传统蓝牙配对连接有时也还需要。BLUETOOTH_SCAN声明里加android:usesPermissionFlagsneverForLocation是为了声明应用不会用蓝牙扫描结果去推算位置不加的话审核时容易被判定为需要位置权限。targetApi那块如果项目还没完全适配到Android 12最好在Manifest里加上tools:targetApis避免老设备的兼容问题。3.2 Fragment中动态权限的正确写法在Activity里申请权限标准做法是用registerForActivityResult或者重写onRequestPermissionsResult。到了Fragment里情况复杂一些因为Fragment可以嵌套、可以处于非活跃状态回调可能丢失。如果你的minSdk是23以上我强烈建议直接用Fragment继承的registerForActivityResult这是Android官方推荐的方式生命周期感知和回调都处理得比较干净private val permissionLauncher registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result - val allGranted result.values.all { it } if (allGranted) { startBluetoothWork() } else { showPermissionDeniedTip() } } private fun checkPermissions() { val required if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ) } else if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) } else { emptyArray() } if (required.isNotEmpty() required.any { ContextCompat.checkSelfPermission(requireContext(), it) ! PackageManager.PERMISSION_GRANTED } ) { permissionLauncher.launch(required) } else { startBluetoothWork() } }这里要特别注意Android 12以上扫码不一定能直接使用我在Android 12设备上实测只申请BLUETOOTH_SCAN有时还是不够因为连接动作需要BLUETOOTH_CONNECT。两个一起申请一次搞定避免来回被系统打断。如果你需要在权限回调里去更新UI或者做Fragment切换在使用requireActivity()或getActivity()之前一定要判空否则Fragment已经detach时拿到的Activity是空的直接崩溃。这是Fragment开发老生常谈的坑但到了蓝牙这种多步骤场景里特别容易踩因为用户可能在整个流程中间切走应用。3.3 蓝牙开关检查权限拿到了不代表蓝牙已经打开。每次检查蓝牙适配器状态最稳妥的方式是注册BroadcastReceiver监听BluetoothAdapter.ACTION_STATE_CHANGED然后触发打开fun ensureBluetoothEnabled(adapter: BluetoothAdapter) { if (!adapter.isEnabled) { val intent Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(intent, REQUEST_ENABLE_BT) } }在Fragment里startActivityForResult现在已经废弃要换成ActivityResultLauncherprivate val enableBtLauncher registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result - if (result.resultCode Activity.RESULT_OK) { onBluetoothEnabled() } else { showNotice(需要开启蓝牙才能继续) } }启动蓝牙这个过程是异步的——用户点开关之后系统可能会花几秒甚至十几秒才真正把蓝牙打开。如果你在onResume里立刻去执行扫描大概率扫描不到任何设备因为在蓝牙真正启动完成前底层还没有就绪。正确的做法是监听开关状态变化等isEnabled变成true再触发扫描。4. 扫描、配对与连接的关键实现4.1 设备扫描从开始到结束要成对出现扫描逻辑本身不复杂调用BluetoothAdapter的startDiscovery()就行。但有几个容易被忽视的细节第一扫描的结果通过BroadcastReceiver接收必须在Fragment可见时注册、不可见时注销。这涉及到接收器的注册方式建议动态注册并绑定到Fragment的onStart/onStop避免在Fragment后台时还白耗电、白占资源。第二startDiscovery()本身是异步的扫描过程里用户如果切走页面需要在onStop里主动取消。不取消的后果是系统蓝牙一直在扫描耗电不说还可能影响后续连接。第三扫描出来的设备质量参差不齐一定要过滤设备必须满足getBluetoothClass()是蓝牙设备而非蓝牙耳机如果想过滤耳机并且要有有效的name和MAC地址。有些设备会扫描出多个Profile导致列表重复这时最好用Map按MAC地址去重。Android 12以上扫描还需要注意两个新的运行时FlagBLUETOOTH_SCAN和BLUETOOTH_CONNECT都要真正拿到权限才能调startDiscovery()。我遇到过权限列表里显示已授予但实际调用时一直抛SecurityException的情况后来发现是Manifest里某个子模块的权限没同步导致的。4.2 配对不要轻易调createBond很多教程一上来就让你调bluetoothDevice.createBond()但这在Fragment场景里很容易出问题createBond是异步的结果会通过广播返回。如果Fragment正在销毁或者用户快速点击了多次配对会出现各种异常。实际上传统蓝牙的SPP连接在大部分情况下不需要先手动配对。直接用BluetoothSocket的connect()系统会自动弹出配对请求对话框。只有在设备已经处于未配对状态且系统没能自动弹出配对框时才需要代码干预。我最终的做法是在连接前检查device.getBondState()如果对方是未配对状态就直接发起连接看系统的反应如果连接抛出了异常提示需要配对再回退到createBond流程。这样能避免大量无意义的配对请求也能防止用户被频繁弹窗打扰。如果确实需要主动触发createBond记得在Fragment的onDestroy里执行if (device.bondState ! BluetoothDevice.BOND_BONDED) { device.createBond() }有一种场景比较特殊——某些国产硬件模块的SPP服务必须在配对完成后才能连接不配对直接connect会一直超时。这时就必须在扫描列表里做一次配对再连接。判断依据是connect失败的错误信息如果是“Invalid argument”或者“Service discovery failed”多半是没配对导致的服务查找失败。4.3 建立连接UUID选择与超时控制传统蓝牙连接是客户端创建一个BluetoothSocket然后调用connect()这个调用阻塞。在实际操作中这个connect()能卡十几秒甚至几十秒不返回所以必须在子线程执行而且最好加上超时控制。超时控制最实用的方式不是搞一个CountDownLatch而是直接用socket.connect()跑在Executor里用Future.get(timeout)的方式val future executor.submit { socket.connect() } try { future.get(10, TimeUnit.SECONDS) // 连接成功 } catch (e: TimeoutException) { socket.close() // 连接超时更新状态 }超时时间我建议10秒。太短的话有些老设备根本没来得及完成服务发现流程太长的话用户等着很难受。如果硬件端信号稳定、距离近可以适当缩减到8秒。UUID的选择上串口类型的SPP服务统一用private val SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB)如果你的硬件端不是标准的SPP而是定义了自定义UUID那需要和硬件工程师确认后再替换。否则连不上时排查方向很容易跑偏。查UUID这个事我建议在写代码之前先做不要写完了连接失败才回头查那会浪费很多时间。4.4 Fragment里最容易翻车的点页面重建后怎么恢复连接前面说了连接层要独立于界面那具体在代码上怎么做我用的是AndroidX中的ViewModelViewModel在Activity重建时不会销毁但Fragment只是在View级别重建时ViewModel也能活下来。这种情况下从ViewModel里拿到蓝牙管理器然后重新注册观察者。这样就解决了旋转屏幕后连接还保持的问题。实现的时候核心是把蓝牙管理器作为ViewModel的成员class BluetoothViewModel : ViewModel() { val bluetoothManager BluetoothManager.getInstance() val connectionState MutableLiveDataBluetoothState() val receivedData MutableLiveDataString() }这么做有一个额外好处多Fragment共享同一个蓝牙连接状态时只需要各自注册观察者不用做任何额外同步。我这次项目里配网Fragment弹层和主设置页都需要看到连接状态用ViewModel共享之后代码简洁了不少。反模式也要提醒一个千万不要把蓝牙线程的Handler写在Fragment的成员变量里。页面一旦销毁重建Handler带着旧Fragment引用更新UI时直接操作了被销毁的Fragment崩溃是迟早的事。要么用LiveData传递消息要么用WeakReference持有Fragment实例。5. 数据收发与线程模型5.1 串口通信的数据格式设计蓝牙串口通信本质上是双向字节流。数据格式如果事先没有约定开发过程中就会频繁出现“发是发出去了但对方收不到”或者“收上来一堆乱码”这种问题。我这次项目里硬件端采用的方案很简单帧头长度数据校验。帧头是固定字节0xAA 0x55就是方便接收方快速识别起始位置长度字段1个字节表示后续有效数据长度数据部分是业务内容最后的校验是累加和校验所有字节加和取低8位。这种结构在嵌入式领域非常常见兼容性也好。代码层面上需要实现一个简单的接收缓冲区分帧逻辑。因为串口数据不会按应用层协议一次到位可能是半个包也可能一次收两个包。这个处理是蓝牙通信里最容易被人忽视但其实最核心的部分。5.2 读写线程不要在一个线程里又读又写很多人第一次写蓝牙时用一个线程while循环里同时处理输入输出结果经常莫名其妙卡死。问题在于输入流的read()会一直阻塞等待数据根本没有机会执行写操作或者写操作被阻塞的read()连带卡住。标准做法是分成两个独立线程读线程专门负责inputStream.read()把收到的数据放入buffer触发数据回调写线程维护一个发送队列要发数据时向队列投递写线程从队列取数据写outputStream。class ReadThread(private val inputStream: InputStream) : Thread() { override fun run() { val buffer ByteArray(1024) while (!isInterrupted) { val len inputStream.read(buffer) if (len 0) { val message buffer.copyOf(len) MessageCenter.postData(message) } } } }写线程的实现如果用最简单的HandlerThread加Handler队列可以减少很多并发锁问题。这里有一个坑sendMessage的时候如果有大量数据一次性进来要控制队列长度防止内存无限增长。5.3 断开连接时要留清理时间断开连接不是简单调用close()就完事。如果读线程还阻塞在read()上close()之后这个线程并不会主动退出它得等系统抛出IOException。所以断开连接的正确顺序是设置断开标志位中断读线程interrupt关闭socket此时读线程的read()会抛异常线程内部捕获后自行退出清空发送队列发通知更新UI状态。这里还要提醒一个实际中容易踩的问题Android的BluetoothSocket在close之后某些设备上inputStream.read()不会第一时间抛异常可能要过几百毫秒甚至一秒多。所以即使你调用了close()也不要马上断言线程已经结束应该给线程留一点时间自行收尾。我踩过的坑是在Fragment的onDestroy里调了close()紧接着立刻做了状态置空和UI更新结果读线程下一秒把最后残留的数据发到了一个新的界面状态上造成了一次罕见的状态错乱。后来在close()之后加了一个短延迟问题消失。6. 常见问题与排查思路实录6.1 扫描列表为空永远看不到设备问题出现时先按顺序排查蓝牙权限有没有在运行时拿到Android 12以上只申请了定位权限是不够的位置服务有没有开Android 6到11之间扫描蓝牙需要定位服务处于开启状态目标设备有没有处于可发现模式很多硬件模块默认关闭广播扫描接收器有没有在onResume里重新注册Fragment页面切换后容易漏注册有没有被系统杀掉蓝牙扫描期间应用切到后台扫描可能被系统主动取消。其中第4个问题最常见很多人在Fragment的onCreate里注册接收器然后后面页面重建时onDestroy里注销但onResume里忘了重新注册。导致的现象是扫描列表一直是之前缓存的结果新设备永远刷不出来。6.2 连接时一直转圈然后失败连接失败的排查顺序对方设备是否在可连接范围内蓝牙有效距离一般是10米左右隔一堵墙可能就完蛋MAC地址和UUID是否匹配设备换了固件之后UUID可能变化不要相信写死的常量有没有配对成功某些模块必须先配对再连接是不是同时在连接多个蓝牙设备部分低端蓝牙模块不支持并发连接多个连接请求会互相冲突。我遇到过最诡异的一种情况是同样的代码在测试机上连得好好的换到另外一台手机上连不上后来发现问题出在手机本身的蓝牙缓存上。这种时候可以把目标设备移除重新配对或者做一次蓝牙状态重置。6.3 连接成功但收发数据丢包或乱码这种问题大多出在数据帧设计或者读写线程上。先打开蓝牙HCI抓包日志看看实际链路层收发了什么字节再和代码里发的字节比对一下。如果是多字节乱序多半是token在并发读写下被污染了如果是丢包看看是否在写线程里做了flush()。还有一个常见原因波特率不匹配。硬件串口通信时双方波特率必须一致虽然Android蓝牙用的是固定波特率但实际上部分模块的串口芯片有配置寄存器如果硬件端配置成了9600而代码默认是115200数据就会出现大量CRC错误表现出来就是乱码或丢包。6.4 Fragment销毁后还在收数据这是ViewModel没用好导致的状态泄漏。蓝牙Manager的数据回调如果直接更新旧的Fragment实例而那个Fragment已经detach轻则更新无效重则崩溃。解决思路是回调只更新LiveDataFragment只订阅LiveData这样处于STOPPED状态的Fragment不会做UI更新Layout重建后再订阅即可恢复。如果你用的不是LiveData而是传统回调接口一定要在Fragment的onViewCreated里设置回调、onDestroyView里清空回调引用避免内存泄漏。7. 一点个人体会这套方案做完之后我最大的感受是在Fragment里做蓝牙通信代码本身的难度其实和Activity相差不大真正的复杂度全部集中在生命周期管理和状态一致性上。无论是权限回调、扫描接收器、连接线程、还是数据回调每一环都得和Fragment的onStart/onStop/onDestroyView做好联动。只要把“连接层独立、UI层订阅状态”这个思路贯彻到底后续再遇到其他异步硬件通信需求比如NFC、USB串口都能省很多事。如果你拿到的项目将来也需要在多个页面共享同一个蓝牙连接这个单例管理器的思路可以直接复用。最稳定、最省心的路径永远是状态用ViewModel托管、数据用LiveData分发、底层线程和生命周期彻底解耦。最后再分享一个小技巧调试蓝牙过程中用系统自带的Bluetooth HCI抓包日志开发者选项里打开能省掉特别多冤枉路很多时候你以为是代码问题其实硬件端根本没回应数据包。