资讯详情

资讯详情

Fragment中实现蓝牙通信:权限、生命周期与Socket完整解析

把蓝牙通信塞进 Fragment这事我一开始觉得没什么难度。Activity 里怎么写Fragment 里就怎么写嘛。等真把代码挪过去才发现完全不是一回事权限弹窗回来了 Fragment 已经销毁、扫描广播注册了老不注销、Socket 连上了界面却黑屏光这几类问题就够折腾两天。这篇文章就围绕“在 Fragment 里实现蓝牙通信”这个场景把权限、生命周期、搜索、配对、Socket 收发和常见异常完整捋一遍。代码可以直接抄重点是你得知道每一行为什么这么写。适合正在做设备联调、忙了大半天还没跑通的人。1. 为什么在Fragment里写蓝牙通信会状态错乱1.1 页面结构决定需求单Activity架构下的必然选择现在的项目基本都是单 Activity 多 Fragment 的架构蓝牙设置页通常不是独立页面而是嵌在“设备管理”“智能配网”这一类页面里。Fragment 旁边要放设备列表、连接状态、收发数据的控件数据还要和页面其他区域联动。如果蓝牙流程全部放到 Activity 层子 Fragment 想刷新状态就得通过接口回调或者共享 ViewModel代码绕一大圈不说还容易把 Activity 变成一堆回调的垃圾桶。反过来如果为蓝牙单独开一个 Activity用户一离开就得重新同步状态体验是断裂的。所以在 Fragment 里直接实现蓝牙通信是贴合实际业务结构的做法。问题只在于Fragment 的生命周期模型比 Activity 复杂得多onStart、onResume、onPause、onStop、onDestroyView、onDestroy每一个节点都可能因为页面切换、折叠、返回栈操作被触发。很多人照着 Activity 的代码抄最后发现广播重复注册、权限回调不触发、连接成功却无法更新 UI根源都是没有把生命周期这个点考虑进去。1.2 生命周期错位的根源蓝牙回调永远比页面状态慢半拍蓝牙通信从行为上看到处都是异步操作。startDiscovery()发出之后扫描结果通过系统广播回调socket.connect()是阻塞等待InputStream.read()在没有数据时会一直卡住。这些回调什么时候回来谁也没法保证。但 Fragment 是随时可能“消失”的。用户可能正在扫描突然点了底部 TabFragment 被 replace也可能 Activity 因为内存不足被系统杀掉Fragment 还留在返回栈里甚至 ViewPager2 预加载时相邻页面根本没有进入 resumed 状态。回调回来后如果代码还在操作已经销毁的 View就会得到 null 引用或者直接抛IllegalStateException: Can not perform this action after onSaveInstanceState。另一个极端是 Fragment 已经退出了但你忘了注销广播或关闭 Socket导致它跑到后台还占着蓝牙天线。耗电是小事更麻烦的是下次再进来想扫描或连接时系统还是认为你处于占用状态各种操作都起不来。所以必须先建立一个原则所有需要上下文、UI 更新、资源释放的操作都要在 Fragment 生命周期状态允许时再加一道检查。别嫌麻烦这是 Fragment 版蓝牙通信和 Activity 版最大的差异。2. 蓝牙权限与BluetoothAdapter开工前的两个关键准备2.1 权限清单到底怎么配Android 12前后的两套写法蓝牙权限在 Android 12 之前和之后完全是两套东西。旧版本只需要BLUETOOTH、BLUETOOTH_ADMIN和定位相关权限Android 12 及以上新增了BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE其中扫描权限还可以声明neverForLocation来表示“我不拿扫描结果推断位置”。在AndroidManifest.xml里可以这样声明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.ACCESS_COARSE_LOCATION / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE /注意maxSdkVersion30的作用是让旧权限只在 Android 11 及以下系统生效避免 Android 12 上出现新旧两套权限重复弹窗的怪问题。BLUETOOTH_SCAN加neverForLocation的前提是你的应用确实不会根据蓝牙扫描结果推断用户位置如果你只是做一个工具类 App直接加就行这样也可以少申请一个定位权限。2.2 动态权限请求用Activity Result API别再用旧的requestPermissions在 Fragment 里申请动态权限强烈建议用registerForActivityResult。它天然感知生命周期不会出现“权限结果回来了Fragment 已经不在 resumed 状态”的崩溃。回调注册放在 Fragment 字段初始化时不要在点击事件里临时创建。private val permissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result - val denied result.entries.filter { !it.value }.map { it.key } if (denied.isEmpty()) { onPermissionGranted() } else { showToast(需要授权${denied.joinToString()}) } } private fun requireBluetoothPermissions() { val permissions mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { if (ContextCompat.checkSelfPermission( requireContext(), Manifest.permission.BLUETOOTH_SCAN ) ! PackageManager.PERMISSION_GRANTED ) { permissions.add(Manifest.permission.BLUETOOTH_SCAN) } if (ContextCompat.checkSelfPermission( requireContext(), Manifest.permission.BLUETOOTH_CONNECT ) ! PackageManager.PERMISSION_GRANTED ) { permissions.add(Manifest.permission.BLUETOOTH_CONNECT) } } else { if (ContextCompat.checkSelfPermission( requireContext(), Manifest.permission.ACCESS_FINE_LOCATION ) ! PackageManager.PERMISSION_GRANTED ) { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } } if (permissions.isNotEmpty()) { permissionLauncher.launch(permissions.toTypedArray()) } else { onPermissionGranted() } }这里有几个点要说明。第一为什么 Fragment 里不直接用ActivityCompat.requestPermissions(requireActivity(), ...)因为一旦宿主 Activity 还在但 Fragment 已经 detach回调结果到 Fragment 时会出问题。registerForActivityResult内部会帮你做生命周期绑定。第二Android 12 上BLUETOOTH_SCAN和BLUETOOTH_CONNECT是相互独立的运行时权限用户可能只给其中一个所以一定要分开检查不能扫到设备就默认能连接。2.3 BluetoothAdapter获取与蓝牙开关检测BluetoothAdapter是传统蓝牙通信的总入口。获取时先判空因为确实有设备不带蓝牙模块。推荐这样封装private val bluetoothAdapter: BluetoothAdapter? get() BluetoothAdapter.getDefaultAdapter() private fun ensureBluetoothEnabled(): Boolean { val adapter bluetoothAdapter ?: run { showToast(当前设备不支持蓝牙) return false } if (adapter.isEnabled) return true enableBluetoothLauncher.launch( Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) ) return false } private val enableBluetoothLauncher registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result - if (result.resultCode Activity.RESULT_OK) { startScan() } else { showToast(用户拒绝开启蓝牙) } }不要直接调用adapter.enable()强开蓝牙这种方式在某些系统上不会出现确认弹窗用户完全无感知地就被打开蓝牙上架审核时也容易因为“未经用户同意修改系统设置”被拒。用ACTION_REQUEST_ENABLE让系统弹窗用户确认后再继续流程体验和安全都比较稳。3. Fragment里的蓝牙通信完整实现扫描、配对与Socket收发3.1 广播接收器的注册和注销跟着生命周期走BluetoothDevice.ACTION_FOUND和BluetoothAdapter.ACTION_DISCOVERY_FINISHED是通过动态广播回调的。动态广播必须在onResume注册、onPause注销。我把这段放在 Fragment 生命周期的对应方法里private val discoveryReceiver object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { BluetoothDevice.ACTION_FOUND - { val device: BluetoothDevice? if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra( BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java ) } else { Suppress(DEPRECATION) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) } device?.let { addDevice(it) } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { isScanning false updateScanState(false) } } } } override fun onResume() { super.onResume() val filter IntentFilter().apply { addAction(BluetoothDevice.ACTION_FOUND) addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) } ContextCompat.registerReceiver( requireContext(), discoveryReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED ) } override fun onPause() { super.onPause() requireContext().unregisterReceiver(discoveryReceiver) }为什么必须onResume/onPause而不是onCreate/onDestroy因为 Fragment 经常处在“View 已经被销毁但 Fragment 还活着”的状态。如果只在onDestroy注销页面一切后台receiver 还在收广播等切换回来时又注册一份就会出现重复回调。用onResume/onPause可以保证可见时才接收广播不可见时立即释放这是 Fragment 版广播注册的默认套路。3.2 扫描设备先cancelDiscovery再startDiscovery扫描的触发代码看起来简单但很多人第一步就栽了。启动新扫描之前必须先取消正在进行的扫描private fun startScan() { if (!hasPermissions()) { requireBluetoothPermissions() return } val adapter bluetoothAdapter ?: return if (!adapter.isEnabled) { ensureBluetoothEnabled() return } if (isScanning) return if (adapter.isDiscovering) { adapter.cancelDiscovery() } deviceList.clear() foundAddresses.clear() deviceAdapter.notifyDataSetChanged() adapter.startDiscovery() isScanning true updateScanState(true) }cancelDiscovery()不是可有可无的。蓝牙扫描期间系统很可能还在处理上次扫描的队列直接再startDiscovery()结果可能就是扫了很久一个设备都出不来。实测中这个现象非常普遍。扫描结果去重时建议用设备地址作为唯一键不要用设备名称。很多设备名称是空的同一台设备可能重复上报。示例里我用一个foundAddresses集合来做去重private val foundAddresses HashSetString() private fun addDevice(device: BluetoothDevice) { if (!foundAddresses.add(device.address)) return deviceList.add(device) deviceAdapter.notifyItemInserted(deviceList.size - 1) }扫描到的设备如果是未配对的需要先device.createBond()发起配对。这个方法返回的只是“配对请求是否成功发出”不是“配对是否成功”。配对结果要通过BluetoothDevice.ACTION_BOND_STATE_CHANGED广播监听在BOND_BONDED状态后再去连接。大多数需要 SPP 通信的串口模块、蓝牙开发板在配对完成后就可以进入 Socket 连接了。3.3 Socket客户端连接、发送与接收连接操作必须在子线程执行。socket.connect()是一个阻塞方法在主线程调用会直接 ANR。下面这段代码我用一个单线程 Executor 来承载所有蓝牙耗时操作private val SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB) private val executor Executors.newSingleThreadExecutor() private val mainHandler Handler(Looper.getMainLooper()) private fun connectDevice(device: BluetoothDevice) { if (!isAdded) return val adapter bluetoothAdapter ?: return if (adapter.isDiscovering) { adapter.cancelDiscovery() } connectionState STATE_CONNECTING executor.execute { var socket: BluetoothSocket? null try { socket device.createRfcommSocketToServiceRecord(SPP_UUID) socket?.connect() bluetoothSocket socket connectionState STATE_CONNECTED mainHandler.post { if (isAdded) { updateUI(已连接${device.name}) } } startReadLoop(socket) } catch (e: IOException) { connectionState STATE_IDLE mainHandler.post { if (isAdded) { updateUI(连接失败${e.message}) } } } } }这里反复出现isAdded检查是因为从子线程mainHandler.post回主线程时Fragment 可能已经在返回栈中被销毁。不用isAdded包一层更新RecyclerView或findViewById都会直接崩溃。SPP_UUID是蓝牙串口服务的标准 UUID。很多 HC-05、HC-06、JDY-31 这类模块都认这个值。如果设备文档明确给了自定义 UUID就用设备厂商提供的值如果你想做两个 Android App 之间的通信客户端和服务端用同一个随机 UUID 就行不一定非要用 SPP。读写数据也是耗时的。读循环尤其要放在独立线程private fun startReadLoop(socket: BluetoothSocket) { executor.execute { try { val input socket.inputStream val buffer ByteArray(1024) while (connectionState STATE_CONNECTED) { val count input.read(buffer) if (count 0) { val message String(buffer, 0, count, Charsets.UTF_8) mainHandler.post { if (isAdded) { onMessageReceived(message) } } } } } catch (e: IOException) { mainHandler.post { if (isAdded) { updateUI(连接已断开${e.message}) } } } finally { closeSocket(socket) } } } private fun sendMessage(content: String) { val socket bluetoothSocket ?: return executor.execute { try { socket.outputStream.write(content.toByteArray(Charsets.UTF_8)) socket.outputStream.flush() } catch (e: IOException) { mainHandler.post { if (isAdded) { updateUI(发送失败${e.message}) } } } } }为什么 read 的 buffer 用 1024这个不是固定参数按我的经验串口设备单次数据包往往不超过 1024 字节太大反而积压太小容易拆包。如果你传输的是图片或文件建议自己设计分包协议buffer 适当调大到 4096同时加上消息头和长度字段避免粘包拆包问题。3.4 Socket服务端accept监听与线程管理如果你想实现两个 Android 设备直接通信或者自己做固件端测试就需要服务端监听。服务端流程是先拿到BluetoothServerSocket然后accept()等待客户端连接。同样必须放子线程。private fun startServer() { executor.execute { var serverSocket: BluetoothServerSocket? null try { serverSocket bluetoothAdapter ?.listenUsingRfcommWithServiceRecord(MyAppServer, SPP_UUID) while (connectionState ! STATE_CONNECTED) { val socket serverSocket?.accept() ?: continue bluetoothSocket socket connectionState STATE_CONNECTED mainHandler.post { if (isAdded) { updateUI(有设备接入) } } startReadLoop(socket) break } } catch (e: IOException) { mainHandler.post { if (isAdded) { updateUI(监听失败${e.message}) } } } finally { try { serverSocket?.close() } catch (_: IOException) { } } } }服务端的核心难点不是accept()本身而是什么时候关闭serverSocket。如果 Fragment 退出了但accept()还在阻塞这个连接会一直占着资源。比较稳妥的做法是在 Fragment 的onDestroy里统一关闭让阻塞的accept()立即抛出异常退出循环。下面是我用来做资源清理的方法override fun onDestroy() { super.onDestroy() bluetoothAdapter?.cancelDiscovery() closeSocket(bluetoothSocket) executor.shutdownNow() }注意不要过早调用shutdownNow()。如果你把startReadLoop和发送消息都丢在同一个 Executor 里shutdownNow()之后就无法再执行发送任务。所以要么准备两个 Executor一个专门做连接和读一个做发送要么在真正销毁前保证其他任务已经结束。简单场景下用单线程 Executor 问题不大但心里要清楚这个限制。3.5 一张图理清传统蓝牙与BLE的选择这里要提醒一下。标题里的“蓝牙通信”如果指的是低功耗蓝牙 BLE比如心率带、智能灯泡、Mesh 设备这类只走 GATT 协议的设备那本文这套BluetoothSocket流程是用不了的。BLE 的扫描走的是BluetoothLeScanner连接走的是BluetoothGatt数据交互靠 Service/Characteristic不靠输入输出流。判断标准很简单看设备文档里有没有提到 SPP 串口服务或者厂商是否给你 UUID 去建立 RFCOMM Socket。提到串口透传、SPP、HC-05、HC-06基本就是经典蓝牙提到 GATT、Service、Characteristic、Notify、Write就是 BLE。千万别拿 SPP 的代码去连 BLE 设备白折腾。4. 连接失败、扫不到设备的排查顺序与速查表4.1 权限明明给了但就是扫描不到设备扫描结果是空列表是最常见的问题也是最容易误判的问题。首先要确认系统定位服务有没有打开。Android 系统会把蓝牙扫描视为一种“可能会推断位置的操作”即使你的 App 已经拿到定位权限如果系统一级的定位开关是关闭的很多手机上扫描结果也会是空的。其次要检查是否真的取消了上一次扫描。如果你在一个页面里多次调用startDiscovery()但在第二次之前没有cancelDiscovery()第二次的扫描请求可能被系统忽略。日志里看不到任何错误只是设备列表不动。最后建议打印一下ACTION_DISCOVERY_FINISHED是否正常回调。如果一直没有FINISHED说明扫描根本没有开始如果几十秒后收到了FINISHED但列表为空问题大概率在定位开关或权限层。4.2 连接秒断、卡在connect()、UUID不匹配连接阶段最常见的报错是read failed, socket might closed和Service discovery failed。前者通常是连接建立后立刻断掉常见原因是设备端串口波特率、流控参数和 App 不一致或者设备只支持 SPP 但某些模块需要先进入 AT 配置模式。后者多半是 UUID 不对。两端必须使用完全相同的 UUID并且服务端的listenUsingRfcommWithServiceRecord与客户端的createRfcommSocketToServiceRecord要配对。还有一个经常被忽略的操作连接之前一定要cancelDiscovery()。蓝牙系统的发现过程会占用天线资源带着发现状态去connect()大概率会连接超时。这也是我在connectDevice()方法里第一件事就取消发现的原因。4.3 Fragment销毁了连接还活着问题现象是页面退出后设备端仍然显示“已连接”甚至下次再打开页面连不上同一台设备。原因很简单Socket 没有在onDestroy里关闭。BluetoothSocket不会像 HTTP 连接那样自动回收它在系统底层持有蓝牙文件描述符即使 Java 对象被 GC底层连接也不会立刻释放。我建议的处理规则是onDestroyView只注销广播和停止扫描不要动 Socket因为 Fragment 切换到后台再回来时View 重建但连接业务还在onDestroy里关闭 Socket 和 Executor因为 Fragment 已经彻底退出。如果你的业务希望 Fragment 销毁后连接仍然保留给别的页面用那就不能像上面这样写而应该把 Socket 对象移到 ViewModel 或者单独的数据仓库类中Fragment 只负责订阅状态。4.4 高频问题排查速查表症状可能原因处理办法扫描列表一直为空系统定位服务未开启打开系统定位开关重新进入页面扫描列表为空且权限已授予startDiscovery()前没cancelDiscovery()添加adapter.cancelDiscovery()Android 12 上运行时直接崩溃缺少BLUETOOTH_SCAN或BLUETOOTH_CONNECT按系统版本分别申请权限Service discovery failedUUID 不匹配或设备不在发现状态核对设备官方 UUID关闭发现后连接read failed, socket might closed连接被设备断开或波特率不符检查串口参数增加重连机制页面销毁后设备端仍显示已连接Fragment 销毁时未关闭 Socket在onDestroy中close()主线程卡死connect()/accept()在主线程调用全部放到子线程执行广播回调重复执行receiver 多次注册未注销在onPause注销onResume注册排查问题时我的习惯是先看 Logcat 里有没有SecurityException有就是权限层问题没有再看扫描回调ACTION_FOUND是否触发不触发就是发现流程问题触发但连不上才需要怀疑 Socket 层。把问题按时间轴拆开很快就能定位到是系统权限、扫描还是连接层的问题。最后再分享一个小技巧。Fragment 里做蓝牙通信别把连接状态放在 Activity 的普通变量里建议用一个MutableStateFlow放在 ViewModel 中。Fragment 只负责收集状态、刷新 UI。这样页面旋转、切后台、被清理后重建都能恢复到正确的界面。实测下来这一条能省掉一半以上的崩溃问题。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →