news 2026/11/5 23:00:31

Fragment场景下蓝牙通信实战:权限适配、生命周期与连接管理全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Fragment场景下蓝牙通信实战:权限适配、生命周期与连接管理全解析

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读写所有动作,内部自行管理线程,不绑Fragment;
  • UI接入层:Fragment里通过ViewModel持有管理器引用,注册观察者拿状态和数据。

这样设计之后,Fragment的销毁不影响蓝牙连接的底层运行,重建后的Fragment能够迅速恢复状态。ViewModel帮我解决了配置变更时的数据存活问题,连接层单例则保证了蓝牙连接不会因为界面重建而中断。

2.3 项目里最终决定的通信流程

整个流程我梳理成了下面这条链路,后续所有代码都是围绕这条链路组织的:

权限检查 -> 蓝牙开关检查 -> 扫描设备 -> 用户选择设备 -> 配对(如需) -> 建立Socket连接 -> 启动读写线程 -> 数据收发 -> 断开连接

实际操作中还需要在每一步之间插入状态判断。比如扫描前要确认适配器存在且已打开,连接前要确认设备没有超出范围,数据收发时要保证流没有被意外关闭。把状态机理顺之后,后面所有逻辑都变得清晰,调试起来也方便很多。

3. 权限与环境准备:版本差异是最大的坑

3.1 权限申请要按API级别分层处理

蓝牙权限这块,Android版本不同,要求完全不同。早期版本只要求BLUETOOTH和BLUETOOTH_ADMIN,从Android 6.0(API 23)开始,扫描相关的权限被归到了危险权限里,需要运行时申请,主要是ACCESS_FINE_LOCATION。从Android 12(API 31)开始又是一轮大改:新增了BLUETOOTH_SCAN和BLUETOOTH_CONNECT,老的BLUETOOTH权限反而变成了安装时普通权限。

写进Manifest的权限声明建议直接兼容全版本,不要图省事只写一套:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

说几个细节。ACCESS_FINE_LOCATION在Android 12以下负责定位权限,在Android 12以上传统蓝牙配对连接有时也还需要。BLUETOOTH_SCAN声明里加android:usesPermissionFlags="neverForLocation"是为了声明应用不会用蓝牙扫描结果去推算位置,不加的话审核时容易被判定为需要位置权限。targetApi那块,如果项目还没完全适配到Android 12,最好在Manifest里加上tools:targetApi="s",避免老设备的兼容问题。

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现在已经废弃,要换成ActivityResultLauncher:

private 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以上,扫描还需要注意两个新的运行时Flag:BLUETOOTH_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中的ViewModel,ViewModel在Activity重建时不会销毁,但Fragment只是在View级别重建时ViewModel也能活下来。这种情况下,从ViewModel里拿到蓝牙管理器,然后重新注册观察者。这样就解决了旋转屏幕后连接还保持的问题。

实现的时候,核心是把蓝牙管理器作为ViewModel的成员:

class BluetoothViewModel : ViewModel() { val bluetoothManager = BluetoothManager.getInstance() val connectionState = MutableLiveData<BluetoothState>() val receivedData = MutableLiveData<String>() }

这么做有一个额外好处:多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。所以断开连接的正确顺序是:

  1. 设置断开标志位;
  2. 中断读线程(interrupt);
  3. 关闭socket,此时读线程的read()会抛异常,线程内部捕获后自行退出;
  4. 清空发送队列;
  5. 发通知更新UI状态。

这里还要提醒一个实际中容易踩的问题:Android的BluetoothSocket在close之后,某些设备上inputStream.read()不会第一时间抛异常,可能要过几百毫秒甚至一秒多。所以即使你调用了close(),也不要马上断言线程已经结束,应该给线程留一点时间自行收尾。

我踩过的坑是:在Fragment的onDestroy里调了close(),紧接着立刻做了状态置空和UI更新,结果读线程下一秒把最后残留的数据发到了一个新的界面状态上,造成了一次罕见的状态错乱。后来在close()之后加了一个短延迟,问题消失。

6. 常见问题与排查思路实录

6.1 扫描列表为空,永远看不到设备

问题出现时,先按顺序排查:

  1. 蓝牙权限有没有在运行时拿到?Android 12以上只申请了定位权限是不够的;
  2. 位置服务有没有开?Android 6到11之间,扫描蓝牙需要定位服务处于开启状态;
  3. 目标设备有没有处于可发现模式?很多硬件模块默认关闭广播;
  4. 扫描接收器有没有在onResume里重新注册?Fragment页面切换后容易漏注册;
  5. 有没有被系统杀掉?蓝牙扫描期间应用切到后台,扫描可能被系统主动取消。

其中第4个问题最常见:很多人在Fragment的onCreate里注册接收器,然后后面页面重建时onDestroy里注销,但onResume里忘了重新注册。导致的现象是扫描列表一直是之前缓存的结果,新设备永远刷不出来。

6.2 连接时一直转圈然后失败

连接失败的排查顺序:

  1. 对方设备是否在可连接范围内?蓝牙有效距离一般是10米左右,隔一堵墙可能就完蛋;
  2. MAC地址和UUID是否匹配?设备换了固件之后UUID可能变化,不要相信写死的常量;
  3. 有没有配对成功?某些模块必须先配对再连接;
  4. 是不是同时在连接多个蓝牙设备?部分低端蓝牙模块不支持并发连接,多个连接请求会互相冲突。

我遇到过最诡异的一种情况是:同样的代码在测试机上连得好好的,换到另外一台手机上连不上,后来发现问题出在手机本身的蓝牙缓存上。这种时候可以把目标设备移除重新配对,或者做一次蓝牙状态重置。

6.3 连接成功,但收发数据丢包或乱码

这种问题大多出在数据帧设计或者读写线程上。先打开蓝牙HCI抓包日志看看实际链路层收发了什么字节,再和代码里发的字节比对一下。如果是多字节乱序,多半是token在并发读写下被污染了;如果是丢包,看看是否在写线程里做了flush()。

还有一个常见原因:波特率不匹配。硬件串口通信时双方波特率必须一致,虽然Android蓝牙用的是固定波特率,但实际上部分模块的串口芯片有配置寄存器,如果硬件端配置成了9600而代码默认是115200,数据就会出现大量CRC错误,表现出来就是乱码或丢包。

6.4 Fragment销毁后还在收数据

这是ViewModel没用好导致的状态泄漏。蓝牙Manager的数据回调如果直接更新旧的Fragment实例,而那个Fragment已经detach,轻则更新无效重则崩溃。解决思路是:回调只更新LiveData,Fragment只订阅LiveData,这样处于STOPPED状态的Fragment不会做UI更新,Layout重建后再订阅即可恢复。

如果你用的不是LiveData,而是传统回调接口,一定要在Fragment的onViewCreated里设置回调、onDestroyView里清空回调引用,避免内存泄漏。

7. 一点个人体会

这套方案做完之后我最大的感受是:在Fragment里做蓝牙通信,代码本身的难度其实和Activity相差不大,真正的复杂度全部集中在生命周期管理和状态一致性上。无论是权限回调、扫描接收器、连接线程、还是数据回调,每一环都得和Fragment的onStart/onStop/onDestroyView做好联动。只要把“连接层独立、UI层订阅状态”这个思路贯彻到底,后续再遇到其他异步硬件通信需求(比如NFC、USB串口)都能省很多事。

如果你拿到的项目将来也需要在多个页面共享同一个蓝牙连接,这个单例管理器的思路可以直接复用。最稳定、最省心的路径永远是:状态用ViewModel托管、数据用LiveData分发、底层线程和生命周期彻底解耦。最后再分享一个小技巧:调试蓝牙过程中,用系统自带的Bluetooth HCI抓包日志(开发者选项里打开)能省掉特别多冤枉路,很多时候你以为是代码问题,其实硬件端根本没回应数据包。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 15:27:58

双质量二自由度悬架Simulink仿真:建模、时域与频域分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 10:12:31

用Java Access Bridge读取Swing组件树:跨进程UI抓取器实现与避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 6:28:47

非隔离DC-DC双闭环PI参数整定:建模、计算与实测全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/5 18:48:54

心流不是玄学:一套可复现的深度专注实操方法

做知识型工作的人&#xff0c;最宝贵也最难求的&#xff0c;可能就是那种“一坐下就进去了”的状态。你盯着屏幕&#xff0c;思路顺畅&#xff0c;手跟不上脑子的速度&#xff0c;两三个小时一晃而过&#xff0c;连水都顾不上喝&#xff0c;结束以后整个人是满足的&#xff0c;…

作者头像 李华
网站建设 2026/10/26 7:08:31

3DContainerPacking实战:从接入到装载优化避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 0:51:51

Unity古迹探险游戏成品:含场景流、对话树、解谜与存档系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华