接手老项目改造的时候最头疼的往往不是业务逻辑而是那些依赖系统级资源的代码怎么从 Activity 安全地挪进 Fragment。蓝牙通信就是典型代表。早几年大家习惯把蓝牙扫、连、传的全套逻辑直接写在 Activity 里可一到 App 模块化、页面 Fragment 化之后原来那套Activity 活着我就活着的写法就处处翻车。蓝牙秤的称重页面、设备固件升级向导、配网流程这类场景几乎都会遇到一个特别具体的问题在 Android Studio 里用 Fragment 实现蓝牙通信和 Activity 相比到底差在哪为什么明明在 Activity 里跑得好好的逻辑挪到 Fragment 里就各种崩、各种连不上、各种内存泄漏这篇内容就是想把这事的完整链路理清楚权限适配、BluetoothAdapter 初始化、设备扫描、配对连接、数据收发、生命周期绑定再加上我这些年实际踩过的坑。不管你是刚接触蓝牙开发的新手还是准备把老代码做 Fragment 化改造的老手看完应该都能少走不少弯路。1. 为什么非要在 Fragment 里做蓝牙Activity 方案的三个痛点1.1 单 Activity 多 Fragment 已经是默认架构现在的新项目十有八九是单 Activity 多 Fragment页面切换靠组件化路由或 Navigation 管理。蓝牙通信这种能力一旦需要嵌入某个业务页面比如一个扫码之后自动连接蓝牙设备的页面那么这段逻辑就不可能绕开 Fragment。有人会说那我单独封装一个 BluetoothHelper 类在 Activity 里调用不就行了问题在于很多页面本身就是 Fragment你并没有一个自己的 Activity 可以去挂。强行让宿主 Activity 去代理蓝牙逻辑代码会变得非常拧巴Fragment 要回调 ActivityActivity 又要再转发回 Fragment中间还夹着 ViewModel 的数据流链路一长状态同步就容易出问题。我在某公司做过一个蓝牙秤的项目首页和购物车页都需要调用同一个称重模块。首页是 Fragment购物车也是 Fragment。如果把蓝牙连接放在 Activity 里两个 Fragment 都要通过 Activity 中转拿数据回调接口会越写越臃肿。更要命的是页面 A 连接了蓝牙跳转到页面 B 时连接要不要断开每个页面都自己重连一遍这些都是实际业务里绕不开的决策点必须在 Fragment 层面才能灵活处理。1.2 Fragment 带来的页面复用和回调解耦Fragment 天然适合做页面级复用。同一个蓝牙控制面板既可以嵌在设备详情页也可以作为一个独立的底部弹窗页面弹出。这种复用需求在 Activity 里实现往往要靠 intent 传参 启动新页面页面间通信还得靠静态变量或者全局单例非常粗糙。而在 Fragment 里蓝牙状态可以被 ViewModel 或者 Fragment Result API 管理页面 A 发起连接、页面 B 消费结果整个过程不用关心宿主 Activity 是谁。数据回传方面Activity 场景常用的 startActivityForResult 在 Fragment 里也有对应版本但实际做蓝牙开发时我更推荐用共享 ViewModel 或者回调接口。因为蓝牙连接是持续性的实时数据流不是一次请求一次结果的模式Result API 并不适合承载持续不断的数据推送。真正实用的是把蓝牙数据源封装成 LiveData 或 FlowFragment 只负责订阅和展示。这套东西只有在 Fragment 架构下才能玩得开换句话说不是我们要在 Fragment 里做蓝牙而是架构演进到这个阶段蓝牙代码就必须具备 Fragment 级别的生命周期感知能力。1.3 生命周期复杂度最容易被忽略的一堵墙Activity 的生命周期其实相对简单onCreate 到 onDestroy中间基本是一条直线。Fragment 就麻烦得多它有 onCreate、onCreateView、onViewCreated、onStart、onResume、onPause、onStop、onDestroyView、onDestroy 整整九层。蓝牙这种需要长时间占用系统资源的操作放在哪一层开始、在哪一层结束直接影响连接的稳定性。举个最简单的例子很多人习惯在 onDestroy 里关闭蓝牙连接这在 Activity 里没毛病。但在 Fragment 里onDestroyView 先于 onDestroy 执行而且 View 销毁之后 Fragment 实例可能还活着比如被加入返回栈。如果连接数据要实时刷新到界面你在 onDestroyView 之后还去更新 View就会直接碰上空指针如果你非要等到 onDestroy 才关闭连接页面退到后台但 Fragment 还在栈里的时候蓝牙却还一直连着白白耗电。这些都是撑起整篇文章的核心矛盾蓝牙通信的状态机必须和 Fragment 的生命周期状态机对齐。2. 蓝牙通信的地基权限适配与 BluetoothAdapter 初始化2.1 Android 各版本的权限差异一张表说清楚蓝牙开发的第一个拦路虎永远是权限。不同 Android 版本对蓝牙权限的要求完全不一样如果沿用旧项目的 AndroidManifest在 Android 12 以上设备上很可能拿不到扫描结果或者连接失败。我整理了一个对比表建议直接存下来能力Android 6 ~ 11Android 12备注蓝牙开关BLUETOOTH BLUETOOTH_ADMIN普通权限BLUETOOTH_CONNECT运行时权限12 需要动态申请扫描设备ACCESS_FINE_LOCATION运行时权限BLUETOOTH_SCAN运行时权限12 不再依赖定位权限连接设备无需额外权限BLUETOOTH_CONNECT运行时权限12 必须动态申请广播外围设备无需额外权限BLUETOOTH_ADVERTISE运行时权限用到外围模式才需要Android 12 之前扫描蓝牙设备必须要定位权限因为扫描结果会泄露用户位置信息。Android 12 开始引入了细分权限把扫描、连接、广播三个能力拆开了并且只要在 manifest 里给 BLUETOOTH_SCAN 声明了neverForLocation标志系统就不会再强制关联定位权限。这里有个容易踩的坑如果项目 targetSdkVersion 升到了 31 以上但你仍然沿用老权限声明运行时会直接安全异常崩溃这就是很多人升级完系统之后蓝牙功能突然失效的原因。2.2 Manifest 声明和运行时动态申请如果你的应用 targetSdk 是 31 及以上一份兼容新老系统的权限声明应该长这样。Android 11 及以下需要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 android:usesPermissionFlagsneverForLocation tools:targetApis / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /注意neverForLocation这个标志很关键它明确告诉系统你不会用蓝牙扫描结果去推导位置这样系统就不会强制要求定位权限。但请想清楚再写这个标志如果业务上真的需要根据附近蓝牙设备做室内定位比如通过 iBeacon 判断用户在哪家店那这个标志绝对不能加。动态申请权限的代码在 Fragment 里写和 Activity 区别不大用requestPermissions即可private fun checkBluetoothPermissions() { val requiredPermissions if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ) } else { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) } val needRequest requiredPermissions.any { ContextCompat.checkSelfPermission(requireContext(), it) ! PackageManager.PERMISSION_GRANTED } if (needRequest) { requestPermissions(requiredPermissions, REQUEST_CODE_BLUETOOTH_PERMISSION) } }2.3 获取 BluetoothAdapter 的正确姿势获取 BluetoothAdapter 的方式有两种。一种是老的BluetoothAdapter.getDefaultAdapter()简单粗暴但容易出现空指针因为不支持蓝牙的设备会返回 null。另一种是通过 BluetoothManager 获取这也是官方推荐的写法private var bluetoothAdapter: BluetoothAdapter? null override fun onAttach(context: Context) { super.onAttach(context) val bluetoothManager context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager bluetoothAdapter bluetoothManager?.adapter }这里要注意两个细节。第一建议在 onAttach 里赋值不要在 onCreateView 里获取因为getSystemService依赖的是 Activity 的 Context而 onAttach 阶段 Fragment 已经和 Context 绑定了早点获取可以避免后续生命周期变化带来空指针。第二判断设备是否支持蓝牙时不要只判 null还要检查 adapter 是否为 null 之后的返回值比如有些模拟器会返回一个不可用的 adapter真正调用 enable() 时会闪退。2.4 蓝牙开关状态的监听与 Fragment 联动蓝牙没打开的情况下后续一切操作都没有意义。所以要在 Fragment 里监听蓝牙开关状态变化实时更新界面提示。监听方式是注册一个ACTION_STATE_CHANGED广播private val bluetoothStateReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val state intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) when (state) { BluetoothAdapter.STATE_ON - { // 蓝牙已开启刷新设备列表 startDiscovery() } BluetoothAdapter.STATE_OFF - { // 蓝牙已关闭清空列表并提示 deviceList.clear() adapter.notifyDataSetChanged() connectionStatus.postValue(蓝牙未开启) } } } }这个广播的注册和注销必须跟随 Fragment 的可见性生命周期我一般放在 onStart/onStop 或者 onResume/onPause 里。注册时机不对会导致两个问题页面没显示时收到广播去刷新一个不存在的 View或者页面销毁后广播接收器还挂着引发内存泄漏。后面我会专门讲生命周期这一块这里是第一个伏笔。3. Fragment 中的设备发现与配对从扫描回调到列表刷新3.1 startDiscovery 方案广播接收器全流程从 Android 7 之后系统把部分蓝牙协议栈从用户空间移到了系统空间startDiscovery 的行为也在不同厂商 ROM 上出现了差异但它仍然是可选方案里最通用的一种。扫描流程的本质是调用BluetoothAdapter.startDiscovery()系统会持续广播两类系统事件发现新设备、扫描结束。我们只需要在 Fragment 中注册一个 BroadcastReceiver 来接收这两类事件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 { addOrUpdateDevice(it) } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { isScanning false // 通知界面停止刷新动画 } } } }Android 13 开始getParcelableExtra带上了泛型版本老写法虽然还能编译但已经被标记废弃。这个细节一般没人提醒但如果你把 targetSdk 升到 33 以上某些机型上按老写法取回来的 device 可能是 null列表就永远刷不出来。3.2 Fragment 中的广播注册与扫描状态管理广播注册我放在 onStart 里注销放在 onStop 里这样能保证 Fragment 可见时才接收事件。具体代码override fun onStart() { super.onStart() val filter IntentFilter().apply { addAction(BluetoothDevice.ACTION_FOUND) addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) addAction(BluetoothAdapter.ACTION_STATE_CHANGED) } requireContext().registerReceiver(discoveryReceiver, filter) } override fun onStop() { super.onStop() requireContext().unregisterReceiver(discoveryReceiver) }扫描状态管理上我强烈建议在 Fragment 里维护一个isScanning标志位而不是每次都裸调startDiscovery()。因为扫描是耗电大户如果用户快速地在两个 Fragment 之间切换可能发生上一个页面还在扫描、下一个页面又开始扫描的叠加情况这时候系统会直接忽略第二次调用但界面显示的是扫描中结果就是永远等不到结果。一个简单的保护private fun startDiscovery() { if (isScanning) return if (bluetoothAdapter?.isDiscovering true) { bluetoothAdapter?.cancelDiscovery() } val started bluetoothAdapter?.startDiscovery() ?: false if (started) { isScanning true // 刷新界面 } }3.3 配对处理被动配对与主动 createBond 的边界扫描到的设备如果之前没有配对过直接连接通常是不行的。蓝牙配对有两种触发方式一种是系统弹配对框就是经典的配对请求对话框另一种是应用主动调createBond()发起配对。大多数自动化业务流程里系统弹窗会打断用户操作所以实际项目中我更常用主动配对。private fun pairDevice(device: BluetoothDevice) { if (device.bondState BluetoothDevice.BOND_BONDED) { connectToDevice(device) return } if (device.bondState BluetoothDevice.BOND_BONDING) { // 正在配对中等待回调 return } val result device.createBond() if (!result) { // 配对失败提示用户 } }配对状态变化同样通过广播监听action 是BluetoothDevice.ACTION_BOND_STATE_CHANGED。要注意配对弹窗在某些 ROM 上即使调了 createBond 也可能弹出这不是 bug是部分厂商的策略。另外配对请求可能带上 PIN 码或者配对确认普通蓝牙设备一般不需要手动输入但如果你对接的是那些要求固定 PIN 的工业蓝牙模块就得监听ACTION_PAIRING_REQUEST并调用setPin()处理if (intent.action BluetoothDevice.ACTION_PAIRING_REQUEST) { val device intent.getParcelableExtraBluetoothDevice(BluetoothDevice.EXTRA_DEVICE) if (device ! null needFixedPin(device.address)) { val pin 1234 device.setPin(pin.toByteArray()) device.setPairingConfirmation(true) } }这块在 Fragment 里实现和 Activity 一样唯一要注意的是如果在 Fragment 里注册了配对广播注销时机同样要对齐可见性生命周期否则用户停留在页面弹出配对框时广播接收器已经被系统回收配对就会卡死。4. 建立连接与数据收发SPP Socket 与 BLE 的取舍4.1 经典蓝牙 SPP 和低功耗 BLE怎么选做蓝牙通信之前先想清楚一个问题你对接的设备是经典蓝牙SPP还是 BLE。两者的协议栈、API、功耗模型完全不同。SPP 基于 RFCOMM适合传输连续的数据流比如蓝牙打印机、蓝牙秤、工业串口模块BLE 基于 GATT适合低功耗、小数据量、间歇性通信的场景比如温湿度传感器、防丢器、心率带。两者不能混用如果你用 SPP 的 Socket 去连一个只支持 BLE 的设备结果必然是连接失败没有任何回旋余地。维度经典蓝牙 SPP低功耗 BLE底层通道RFCOMMGATT数据特点流式、连续面向连接、分包功耗较高极低连接方式BluetoothSocketBluetoothGatt典型设备蓝牙秤、打印机、串口模块手环、传感器、BeaconFragment 适配难度中较高回调线程复杂4.2 SPP 连接线程connect 绝不能放在主线程SPP 连接的代码核心是这样的先拿到设备用createRfcommSocketToServiceRecord创建一个 Socket然后connect()。这个 connect 是一个阻塞调用而且通常需要几秒必须在子线程执行否则主线程直接卡死然后 ANR 弹窗教你做人。private fun connectToDevice(device: BluetoothDevice) { connectionThread thread { val uuid UUID.fromString(00001101-0000-1000-8000-00805F9B34FB) var socket: BluetoothSocket? null try { socket device.createRfcommSocketToServiceRecord(uuid) socket.connect() // 连接成功启动数据读取循环 startReadLoop(socket) } catch (e: IOException) { e.printStackTrace() // 连接失败抛到主线程通知界面 } } }这里的 UUID 是 SPP 服务标准 UUID几乎所有蓝牙串口模块都支持它。如果换成别的设备比如某些私有协议模块UUID 可能不同需要和设备厂商确认。还有一个常见写法是createInsecureRfcommSocket它跳过加密配对部分调试场景更快但安全性差生产环境不建议。4.3 数据读取循环InputStream.read 的阻塞特性连接建立之后数据收发是持续性的。读取数据要开一个独立循环线程因为InputStream.read()是阻塞的没有数据时它会一直挂着。代码如下private fun startReadLoop(socket: BluetoothSocket) { readThread thread { val input socket.inputStream val buffer ByteArray(1024) try { while (socket.isConnected) { val size input.read(buffer) if (size 0) { val data buffer.copyOf(size) // 解析数据通过 LiveData 或回调抛给 Fragment dataReceivedLiveData.postValue(data) } } } catch (e: IOException) { // 连接断开 } } }注意postValue和setValue的区别readLoop 在子线程必须用 postValue 才能在主线程安全地更新 LiveData。很多人在 Fragment 里第一次遇到连接成功了但 UI 不更新的问题十有八九就是忘了这一条。4.4 BLE 方案GATT 回调与 Fragment 生命周期冲突如果对接的是 BLE 设备流程就完全不同。BLE 不走 Socket而是通过BluetoothGatt和一系列回调。连接设备返回的BluetoothGatt实例是全局状态回调方法onConnectionStateChange、onCharacteristicChanged都跑在 binder 线程上。这里最容易出问题的是Fragment 已经被销毁了但 Gatt 回调还在触发然后我们去更新 Fragment 的 View直接空指针。private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { gatt.close() } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val value characteristic.value // 这里处于 binder 线程必须 postValue 或抛主线程 } }我的经验是BLE 的每个回调入口都做一个Fragment 是否存活 是否可见的双重判断否则你永远不知道下一个崩溃来自哪个回调。5. 生命周期绑定与资源释放Fragment 比 Activity 更严格5.1 注册与注销的配对原则Fragment 里面使用系统资源有一条铁律任何在 onStart 或 onResume 里注册的东西必须在 onStop 或 onPause 里注销任何在 onAttach 里获取的长期资源必须在 onDetach 里清理。与蓝牙直接相关的四个资源是广播接收器、BluetoothSocket、BluetoothGatt、读取线程。我通常在 Fragment 里维护一套状态机和清理函数override fun onStop() { super.onStop() // 页面不可见时停止扫描 bluetoothAdapter?.cancelDiscovery() // 注销广播 requireContext().unregisterReceiver(discoveryReceiver) } override fun onDestroyView() { super.onDestroyView() // 释放读取线程引用防止回调空 View readThread null // 如果 Fragment 即将销毁彻底关闭连接 if (!requireActivity().isChangingConfigurations) { closeConnection() } }这里的isChangingConfigurations判断非常重要。屏幕旋转时 Activity 会重新创建对应的 Fragment 也会重新走一遍生命周期但用户并没有离开页面蓝牙连接其实可以继续保留。如果不加这个判断转个屏连接就断了体验非常差。5.2 内存泄漏为什么不能长期持有 Activity 引用蓝牙回调线程里如果想更新 UI第一反应是拿 Fragment 的 View 或者 Activity 引用。但子线程存活时间可能超过 Fragment 的存活时间。比如读取线程正在 while 循环里阻塞用户却已经按了返回键销毁了 Fragment如果读取线程里面还持有activity的强引用整个 Activity 就泄漏了。这不是危言耸听我用 LeakCanary 在真实项目里抓到过好几次。规避方式很简单回调里不要直接引用 Activity 或 View改用 LiveData 或者 WeakReference。数据到达后LiveData 会自动判断有没有观察者存活没有观察者时数据只是被丢弃不会引发泄漏。5.3 View 与连接状态同步页面都关了还要不要收数据这个问题很多人没认真想过用户退出了蓝牙控制页面但蓝牙连接要不要立即断开我的答案是分情况。如果是短连接场景比如扫码连接、称重一次就完事那退出页面就断开释放系统资源如果是长连接场景比如设备状态监控页面用户希望退到后台还能继续记录数据那就应该把蓝牙连接提升到 Application 级别或者放进一个全局的 ViewModel让连接生命周期和进程生命周期绑定而不是和某个 Fragment 绑定。前者实现简单后者架构复杂我在实际项目里一般会在 Fragment 的 onDestroyView 里问一句当前连接是否需要跨页面保留如果是就把 Socket/Gatt 引用转移给上层持有并清除 Fragment 里的所有回调监听。这样页面重建后可以重新订阅同一个数据源连接不会中断。5.4 页面重建后的状态恢复Navigation 组件或者 Fragment 返回栈导致页面重建时View 没了但 Fragment 实例在连接可能还活着。此时 Fragment 的 onViewCreated 会再次执行需要在这儿把当前连接状态重新渲染到新 View 上。比较好的做法是维护一个 BluetoothConnectionViewModel蓝牙状态都放里面Fragment 只做订阅class BluetoothViewModel : ViewModel() { val connectionStatus MutableLiveDataConnectionStatus() val receivedData MutableLiveDataByteArray() }这样不管 Fragment 怎么重建只要 ViewModel 还在UI 状态就能自动恢复。尤其是 Navigation 返回上一页再回来页面能瞬间显示已连接而不是重新扫一遍体验差距非常明显。6. 实测踩坑记录从已连接到秒断之间的那些坑6.1 扫描期间发起连接结果永远连不上第一次做的时候我犯过这个错进入页面自动扫描用户在列表里点了一个设备我直接调 connect。结果连接大概率失败或者表现成连上就断开。后来查文档才发现系统要求在调用connect()前必须停止startDiscovery()因为扫描过程会占用蓝牙协议栈的资源连接请求会被视为冲突甚至直接拒绝。这是一个必须先 cancelDiscovery 再连接的问题。private fun onDeviceClicked(device: BluetoothDevice) { bluetoothAdapter?.cancelDiscovery() // 等待一小段让协议栈稳定再发起连接 Handler(Looper.getMainLooper()).postDelayed({ connectToDevice(device) }, 200) }后加的 200 毫秒延时是我踩了几次坑之后总结的个别 ROM 在 cancelDiscovery 之后立刻 connect 仍会失败稍微等一下就好了。这个细节官方文档不会写但实测非常有效。6.2 Android 12 权限弹窗与扫描结果的纠缠targetSdk 升到 31 之后在 Fragment 里申请蓝牙权限用户首次拒绝后第二次再点扫描系统可能不会弹窗而是直接回调 denied。很多人在 Activity 里处理委托还好一旦在 Fragment 里自己处理 onRequestPermissionsResult容易漏掉shouldShowRequestPermissionRationale的判断结果就是用户永远没法授权。我的做法是在申请前先检查是否已经被永久拒绝被拒之后直接用 Snackbar 引导用户去系统设置页手动打开。6.3 Fragment 销毁但 Socket 还活着关不掉的幽灵连接一次项目上线后发现一个怪现象用户退出蓝牙控制页后设备上的指示灯一直常亮说明连接并没有断开。排查后发现问题出在closeConnection()里Socket 的 close 方法被调用但读取线程还阻塞在input.read()上read 抛 IOException 之后线程异常退出但连接关闭流程却走到了错误分支。后来我把关闭逻辑改成先关 socket、再中断线程、最后才清理回调private fun closeConnection() { try { bluetoothSocket?.let { socket - if (socket.isConnected) { socket.close() } } } catch (e: IOException) { e.printStackTrace() } readThread?.interrupt() bluetoothSocket null readThread null }顺序不能反。先关 socketread 方法会立刻抛异常线程自然退出再中断线程是为了防止极端情况下 read 没反应。至于 Socket 引用本身记得置空否则 Fragment 重建时旧连接对象还在新连接又会创建新的 socket两个引用叠加资源就泄漏了。6.4 蓝牙打开状态延时刚打开蓝牙立刻扫描的坑用户从蓝牙已关闭点击打开开关之后立刻调用startDiscovery()在一些手机上会直接返回 false扫描没启动。原因是蓝牙协议栈的 STATE_ON 广播发出时底层 RFCOMM 服务可能还没有完全就绪。我的处理方法是维护一个蓝牙准备就绪的布尔值在收到 STATE_ON 广播后延迟 500 毫秒再执行扫描或者连接BluetoothAdapter.STATE_ON - { Handler(Looper.getMainLooper()).postDelayed({ startDiscovery() }, 500) }这个延时也适用于用户在设置页打开蓝牙后返回 App 自动重连的场景。跳过这一步你可能会遇到 30% 概率的首次连接失败而且失败原因极其隐蔽日志里只有一条read failed, socket might closed。6.5 相同地址设备的缓存问题列表里出现两个同样的设备扫描结果里可能会反复出现同一个设备尤其是那些广播频率不稳定的模块。我在 addOrUpdateDevice 里用设备地址作为主键存在就更新信号强度不存在才新增。地址是device.address虽然 Android 6 之后应用无法直接访问 Wi-Fi Mac 地址但蓝牙地址在扫描结果里仍然可见做去重足够用。不要用设备名称做去重名称常常是空的甚至不同设备会用同一个默认名。最后补一句操作层面的总结做了这么多年蓝牙开发我个人的习惯是把 Fragment 里的蓝牙代码拆成三层数据层负责 socket 和流的读写状态层用 LiveData 暴露连接状态与数据UI 层只做订阅和渲染。这样 Fragment 的生命周期只管 UI 层的事状态层天然不受页面销毁影响。最后再分享一个小技巧调试蓝牙连接时别只依赖 Android 的日志观察设备端的反馈更直观。像蓝牙秤这类设备连接成功后通常会有蜂鸣或者指示灯变化如果 App 日志显示已连接但设备没反应多半是你用的 UUID 和模块固件不匹配换标准 SPP UUID 试试多数情况下问题就解决了。