ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Android经典蓝牙BluetoothSocket通信:原理、坑与实战方案

Android经典蓝牙BluetoothSocket通信:原理、坑与实战方案 1. 为什么到现在还在用BluetoothSocket它解决的是哪一类通信问题很多人一提到Android蓝牙开发第一反应就是BLE低功耗蓝牙。这不奇怪智能手环、Beacon设备、体脂秤这些IoT设备几乎全是BLE方案网上教程也一抓一大把。但你真去做车机互联、蓝牙打印机、串口透传模块、老式心率带这类设备时会发现它们压根不走GATT那套而是建立在RFCOMM协议之上的经典蓝牙通信。这时候Android官方给的标准答案就是BluetoothSocket。BluetoothSocket的本质是一条基于RFCOMM的虚拟串口通道。你可以把RFCOMM想成一根看不见的串口线一端连着本机的蓝牙适配器另一端连着远端设备的某个服务数据在这条通道上像流水一样双向流动。和BLE那种连接-发现服务-订阅特征值的复杂模型完全不同RFCOMM一旦建立了socket连接后面的事情就简单了——拿到InputStream和OutputStream像读写文件一样收发数据即可。这也是为什么很多工业级蓝牙模块、单片机透传模块都优先走SPP串口配置协议因为对于MCU来说一个串口中断处理比解析ATT协议栈要省太多资源。BluetoothSocket适合的场景有几个共同点数据量大、实时性要求高、两端设备都是全功率蓝牙而非低功耗设备。比如给手持终端推送几百K的配置文件、和蓝牙打印机传输位图数据、和车机交换多媒体控制流——这些场景用BLE要么带宽不够要么协议太绕。BLE的吞吐量常规在几十KB/s而RFCOMM的理论带宽接近2.1Mbps实际稳定跑下来大概100-200KB/s更关键的是没有MTU限制和分包协商的负担代码写起来特别直接。从Android 4.x到现在的Android 14BluetoothSocket这套API的骨架基本没变过。这既是好事也是坏事好事是网上老代码依然有参考价值各种坑都被人踩过了坏事是老代码里那些过时写法比如滥用cancleDiscovery、忽视运行时权限拿到新系统上会出各种诡异问题。下面这篇文章我就按自己在多个项目里折腾这套API的实际经验从原理到代码到排查完整讲一遍。说明本文所有示例代码基于Android Studio开发环境兼容API 21到API 34使用的蓝牙权限包括BLUETOOTH、BLUETOOTH_ADMIN以及Android 12API 31之后新增的BLUETOOTH_CONNECT和BLUETOOTH_SCAN。2. 建立连接前必须弄清的三个底层机制很多人在BluetoothSocket上翻车不是代码写错而是压根没理解连接建立过程中系统做了什么。这三件事我觉得值得先搞清楚UUID的作用域、三个核心类的角色分工、以及连接请求在底层是怎么被路由的。2.1 UUID不是随便填的凭证而是服务的门牌号服务端在创建监听Socket时必须注册一个UUID客户端连接时也必须带上完全相同的UUID。初学者最容易犯的错误就是随便生成一个UUID两个端都填同一个就行了——大多数情况下这确实能跑通但一旦涉及和第三方硬件设备如串口模块、蓝牙打印机通信UUID必须匹配设备固件里烧录的服务号。比如常见的SPP服务使用的UUID是00001101-0000-1000-8000-00805F9B34FB这也是Android官方示例里Serial Port Profile服务用的那个如果你的模块只认SPP代码里填别的UUID就会报service discovery failed。为什么UUID起着这么关键的作用因为蓝牙底层的SDP协议服务发现协议就是靠UUID来标识服务的。你可以把UUID想成一台服务器上的端口号但比端口号更严谨——它是一套128位的全局唯一标识。Android的BluetoothAdapter会通过SDP查询远端设备注册了哪些UUID找到匹配项后才在这个服务上建立RFCOMM通道。UUID对不上系统根本不会把连接请求往下层传递。2.2 BluetoothAdapter、BluetoothDevice、BluetoothSocket三类对象的协作顺序这三者之间的关系如果理解不透API调用顺序就容易乱。我见过有人一上来就调用BluetoothDevice.createRfcommSocketToServiceRecord()结果device对象是自己用字符串拼出来的——这在多数设备上能成功创建socket但连接时必挂因为凭空构造的BluetoothDevice没有对应的真实蓝牙地址。正确的协作流程是通过BluetoothAdapter.getDefaultAdapter()拿到本机适配器它代表设备的蓝牙硬件负责打开蓝牙、发起扫描、获取已绑定设备列表。通过扫描回调或已配对列表拿到BluetoothDevice它代表远端的物理设备内部封装了MAC地址、名称、类型等信息。用这个device对象调用createRfcommSocketToServiceRecord(uuid)或createInsecureRfcommSocketToServiceRecord(uuid)得到BluetoothSocket实例。对socket调用connect()此时底层才会真正发起SDP查询和RFCOMM连接建立过程。这里有个浅显但容易忽略的点BluetoothSocket并不是像Java Socket那样通过new创建出来的。它必须由BluetoothDevice这个中介来生产因为只有device对象知道远端地址socket自己不知道要去连谁。所以如果你绕过了扫描和配对流程无论如何也拿不到一个可用的socket实例。2.3 底层连接流程从SDP查询到RFCOMM通道建立当客户端调用socket.connect()时系统内部会经历一个比发数据包复杂得多的过程。简化来说分三步首先蓝牙协议栈会根据socket对象里保存的远端MAC地址向该设备发起SDP请求查询目标UUID对应的服务是否存在以及该服务使用的RFCOMM通道号。其次拿到通道号后协议栈在本机与远端设备之间建立一条ACL异步无连接链路并在其上创建L2CAP连接。最后在L2CAP连接上复用RFCOMM协议分配多路复用器编号从而建立最终的串口通道。这个过程一般在100ms到3秒之间波动取决于远端设备的协议栈实现和射频环境。Android SDK没有暴露上述中间状态的API所以你无法获取正在SDP查询这类细粒度回调只能靠connect()的阻塞返回或异常来感知成功/失败。这也是很多开发者觉得蓝牙连接黑盒的原因之一——但实际上理解了这个过程之后很多疑难问题尤其是偶发性失败的排查方向就清晰了。3. 服务端与客户端完整编码流程从API调用到可用通道理论讲完就得动手。这一节我给出一套可直接跑通的最小实现服务端用listenUsingRfcommWithServiceRecord创建监听Socket客户端用createRfcommSocketToServiceRecord发起连接。这套代码我在多个设备组合上验证过适配双手机场景和手机-串口模块场景。3.1 服务端三步开启可被连接的监听服务端要做的事情是创建一个监听Socket、进入accept循环、每接受一个客户端连接就开一个线程处理收发。注意Android的蓝牙服务端和TCP Server的结构几乎一模一样唯一的区别是创建方式。// 服务端创建监听Socket private var serverSocket: BluetoothServerSocket? null fun startServer() { val adapter BluetoothAdapter.getDefaultAdapter() // SPP服务标准UUID val uuid UUID.fromString(00001101-0000-1000-8000-00805F9B34FB) serverSocket adapter.listenUsingRfcommWithServiceRecord(MyBtServer, uuid) // accept是阻塞调用必须放到子线程 thread { while (true) { val socket serverSocket?.accept() socket?.let { handleConnectedSocket(it) // 每个连接单独开线程管理 } } } } fun stopServer() { serverSocket?.close() // close后会抛异常退出accept循环 serverSocket null }这段代码里有几个关键点值得展开说说。listenUsingRfcommWithServiceRecord的第一个参数是服务名称这个名称会出现在远端设备扫描时的设备信息里但并不是连接标识你可以随便起只是建议起个能认出来的名字方便调试。第二个参数是为服务注册的UUID客户端必须用一样的UUID。这个方法会在系统SDP数据库中注册一条服务记录这样远端设备的SDP查询才能知道这台手机上的确有个叫MyBtServer的服务。accept()是一个阻塞方法返回BluetoothSocket。它必须放在子线程里否则你的UI线程会在等待连接时彻底卡死系统很快会弹ANR。同样的道理serverSocket.close()要小心使用——它会导致正在阻塞的accept()抛出IOException如果你的循环没做异常捕获线程就崩了。还有一点有的编码器讲究复用同一个serverSocket做多次accept从Android源码和实际测试看标准的做法就是一次listenUsingRfcommWithServiceRecord对应一个监听socket可以循环accept多次客户端连接但我在某些ROM上遇到过第二次accept时偶发超时后来改成每个连接创建新的监听才稳定。后面第5节我会专门讲这个问题。3.2 客户端扫描、配对、连接三步走客户端的流程稍长先打开蓝牙、扫描或从已绑定列表找到目标设备、创建socket、连接。完整示例代码如下// 客户端连接到服务端 fun connectToDevice(device: BluetoothDevice, uuid: UUID): BluetoothSocket? { var socket: BluetoothSocket? null return try { socket device.createRfcommSocketToServiceRecord(uuid) socket?.connect() socket } catch (e: IOException) { e.printStackTrace() try { socket?.close() } catch (_: IOException) {} null } }这个函数看起来很短但实际工程里围绕它有大量细节要处理。第一个细节连接前建议调用BluetoothAdapter.cancelDiscovery()。扫描操作和连接操作在蓝牙协议栈层面会争抢射频资源如果系统正在扫描设备此时发起连接很容易失败或延迟。官方文档明确建议连接前取消扫描虽然这个API在新版本上已经标记为deprecated因为Android 12之后扫描行为有变化但很多第三方ROM上仍有作用。第二个细节Android 12API 31之后createRfcommSocketToServiceRecord要求BLUETOOTH_CONNECT权限而且这是一个运行时权限。如果你的targetSdkVersion 31必须动态申请否则调用会直接抛SecurityException。同理BluetoothDevice对象的获取也需要BLUETOOTH_SCAN权限如果通过扫描获取或BLUETOOTH_CONNECT权限如果从已绑定列表获取。第三个细节不要在主线程调用connect()。和accept()一样connect()在执行SDP查询和RFCOMM建立时会阻塞数秒具体时长取决于协议栈和设备状态。Android严格模式下这会导致NetworkOnMainThreadException或者ANR。我见过有人用runOnUiThread去调connect然后还奇怪为什么闪退本质就是没理解这个阻塞模型。3.3 建立连接后的数据收发通道Stream就是串口连接一旦建立就像串口接通了一样收发数据全靠InputStream和OutputStream。一个最简的读写循环如下// 数据收发线程 fun handleConnectedSocket(socket: BluetoothSocket) { thread { val input socket.inputStream val buffer ByteArray(1024) try { while (isRunning) { val bytesRead input.read(buffer) if (bytesRead 0) { // 处理读取到的数据 processData(buffer.copyOf(bytesRead)) } } } catch (e: IOException) { // 连接断开 } finally { socket.close() } } }这里的关键设计是读必须用独立的线程因为read(buffer)是阻塞的调用线程会一直停在那个地方直到有数据或连接断开。如果你在UI线程里直接调read界面直接僵死。另一个容易踩的坑是read返回-1的情况在蓝牙socket上这表示连接已经断开——设计上建议把它当IOException一样的退出信号处理因为它之后再次调用read会扔IOException。顺便说一句OutputStream的write方法在蓝牙socket上并不保证一次write就完整通过RFCOMM层发送到对端——大包可能被拆封成多个RFCOMM分段。但对上层应用来说这个透明你只需要保证写入顺序正确即可。真正要注意的是读那边的粘包问题这个我放到第5节详细讲。4. 连接过程中最常见的六个坑及完整排查链路BluetoothSocket的坑大多集中在connect这个环节。这一节我挑自己真实踩过、印象最深的六个问题把现象、排查链路、根因、解法一条条写清楚希望能省去你几个晚上的Debug时间。4.1 权限声明全做了连接时仍然SecurityException现象app在Android 10手机上跑得好好的换到Android 12的手机上调用createRfcommSocketToServiceRecord和connect直接抛SecurityException: Need BLUETOOTH_CONNECT permission而且Manifest里明明已经声明了BLUETOOTH_CONNECT。排查这种情况九成是运行时权限没申请。Android 12把蓝牙相关权限从普通权限升级到了运行时权限意味着光在Manifest声明不够必须在代码里requestPermissions主动申请。用ADB可以快速验证当前权限状态adb shell dumpsys package 你的包名在runtime permissions一节就能看到android.permission.BLUETOOTH_CONNECT: grantedfalse。根因targetSdkVersion 31的应用在Android 12设备上必须走新的权限模型。Android文档的埋点描述写得很清楚BLUETOOTH_SCAN连接前扫描设备时需要BLUETOOTH_CONNECT执行连接操作时需要BLUETOOTH_ADVERTISE做广播时需要。三者互不相同按需申请。解决在进入蓝牙连接界面之前先判断SDK版本大于等于31的版本动态申请BLUETOOTH_CONNECT如果还要扫描就同时申请BLUETOOTH_SCAN。注意还有一个附加条件即使你只做连接而不扫描Android 12上如果系统定位开关关闭蓝牙扫描也会失效但已配对列表读取不受影响。所以一条实用经验是申请权限时把ACCESS_FINE_LOCATION一起申请了因为旧版本上扫描蓝牙设备也要求定位权限这是个历史遗留问题直到Android 12才取消。提示做好String的权限拒绝引导。如果用户拒绝了BLUETOOTH_CONNECT权限你后续掉connect就会稳定SecurityException而且这个异常不会弹系统权限请求框只会静默失败排查起来非常隐蔽。4.2 设置了相同UUID两个端都连不上SDP服务发现失败的完整链路现象手机A做服务端手机B做客户端两边填的UUID完全一致但B调用connect时抛IOException: Service discovery failed偶发情况下又成功了毫无规律。排查过程这个报错很典型是SDP查询阶段就失败了。我最初的排查方向是UUID格式确认了字符串完全相等、大小写一致、使用的是标准SPP UUID之后问题依旧。然后是看设备绑定状态如果两端做了配对bonded重新连接时系统会优先走已缓存的链路信息不完整或不正确的缓存可能导致SDP失败。我把两台手机在系统设置里忘记设备、重新配对问题没有消失。最后把服务端的listen调用从每次连接重新创建改成复用同一个serverSocket连接成功率明显上升。继续深挖发现部分ROM对同一UUID在短时间内反复listen/accept的SDP广播做了限流导致客户端查不到服务记录。根因这是Android不同厂商蓝牙协议栈的实现差异。AOSP源码里BluetoothServerSocket的accept循环设计是允许长期运行的但很多国产ROM改了底层适配层的状态机如果上一个server socket没有正常关闭新的同名服务注册可能被旧句柄干扰。另一个相关因素是服务端accept超时——如果你的服务端一直在accept而没有关闭它广播的SDP记录应该一直在。但某些设备在蓝牙休眠后会自动清掉SDP注册需要重新listen。解决把服务端的listen生命周期拉长用同一个serverSocket循环accept而不是每个连接都重建。同时确保store/restore时先关掉旧socket再建新的。客户端测如果报SDP失败先做一次取消配对再重新配对测试排除系统缓存。若仍失败给connect加一个3秒超时的重试机制方法见第6节实测能把成功率从80%拉到98%以上。4.3 connect()阻塞了30秒都没反应最后超时异常现象点连接按钮后UI线程没卡因为我放子线程了但等了快30秒才抛IOException: read failed, socket might closed or timeout, read ret: -1而且不是每次必现早上能连上下午就超时。排查链路这个场景在办公室环境特别常见。我用蓝牙扫描工具nRF Connect里的经典蓝牙看目标设备发现设备在两个信道上来回跳信号强度在-60dBm到-80dBm之间波动。进一步测试把手机放到设备旁边连接秒开。由此定位到问题出在射频环境而非代码逻辑。但我还是怀疑代码有隐患于是查了官方文档发现AOSP里连接超时时间为12秒而实际抛异常时间更长——因为SDP和RFCOMM是两个阶段每个阶段都有自己的超时阈值叠加起来就接近30秒。根因蓝牙2.4GHz频段和WiFi 2.4GHz共用办公室微波炉、无线鼠标、其他蓝牙设备都会干扰。SDP查询需要和远端设备多次交互信号质量差的时候交互次数增加或者干脆某个包丢失连接就挂住了。Android协议栈对SDP查询没有特别激进的超时机制所以表现为长时间无响应。解决两个方向。硬件层面——检查远端设备的天线朝向、距离、中间障碍物把发射功率调大部分串口模块可以AT指令调。软件层面——给connect操作实现应用层的超时控制比如用RxJava.timeout或者Coroutine.withTimeout超过5秒就主动close当前socket并提示用户重试。不要傻等系统的30秒。另外注意如果你发现端到端功耗异常大射频环境差时RFCOMM会主动进入退避机制加大重传延时进一步拖慢连接。4.4 服务端accept()一直没有返回值客户端却显示已连接现象客户端调用connect返回成功但服务端的accept()一直没有收到通知两边干瞪眼。这个问题在低版本手机上出现过一次进程重启后恢复。排查链路这个现象背后其实不是accept没返回而是接收端的线程可能已经死了。检查代码发现服务端的accept循环里每次接收到一个连接就扔给新线程处理但主循环的thread {}在协程模式里容易被取消。另一个可能如果之前有一次accept返回后让你handleConnectedSocket里抛了未捕获异常线程终止但serverSocket没关整个accept循环就停了。根因BluetoothServerSocket的accept确实只会在有新连接或socket被关闭两种情况下从阻塞中返回。如果代码路径上有任何地方提前close了serverSocket或者线程池满了导致新连接无法被处理accept就会一直阻塞。但还有一种隐蔽情况是客户端连接成功后服务端没走accept分支因为监听socket在某次异常后没重建导致两端状态不一致。解决给accept循环加异常保护任何已捕获异常都要确保serverSocket仍处于监听状态线程崩了要能自动重启监听线程。我在项目中封装了一个ConnectionManager用一个AtomicBoolean控制serverSocket生命周期监听线程意外死亡时通过UncaughtExceptionHandler触发重启。这套防护看着笨重但在车机这类需要长时间待机的环境里非常管用。4.5 连接建立了但发送数据偶尔失败或者收不到数据现象连接成功心跳正常但大文件传输时客户端发送到一半卡死服务端收不到后续数据或者传输过程中断抛出Broken pipe。排查链路这个首先要排除代码层的Stream并发问题。检查发现客户端确实只在同一个线程里write不涉及并发写。继续查服务端read的缓冲区大小——用的是1024字节的buffer读出来只是切成小块不影响接收。最后用串口模块抓包发现传输一半就出现了RFCOMM的流控暂停数据在底层排队。定位到问题发送端一直在write但接收端的InputStream读取速度跟不上底层的接收缓冲区满了RFCOMM触发流控发送端被阻塞表现就是write卡死。根因RFCOMM继承了串口的流控机制。当接收方的缓冲区满而应用层没有及时read时协议栈会发送暂停传输控制帧发送方收到后暂停发送。如果你的应用层write不检查返回值还一直往流里灌数据最终会阻塞在write方法内部。如果此时连接被断开另一侧超时closewrite抛出Broken pipe。解决一个原则发送方写入数据要尊重对端处理速度不能无限地生产。实际做法是大文件传输前先做个握手通知对端我要发N字节然后分块发送每发一块等对端确认。如果要追求吞吐可以采用滑动窗口但小项目不必做那么复杂简单的发送-等待ACK-再发送模型就足够稳定。另外把read缓冲区从1024提高到8192减少应用层read之间底层缓冲溢出的概率。4.6 安全的连接vs不安全的连接createInsecureRfcommSocket的适用边界现象用标准的createRfcommSocketToServiceRecord连接某些蓝牙串口模块总是提示配对失败或pin码错误但用nRF Connect这类工具却能连上。排查链路排查发现这些模块默认配置为免认证No Authentication它们期望客户端用不安全的socket直接连接不弹配对框。而标准socket走的是安全通道会发起认证/配对流程模块如果没在固件里配置认证密钥就会拒绝。根因Android中createRfcommSocketToServiceRecord创建的是安全连接要求加密和认证createInsecureRfcommSocketToServiceRecord创建的是不安全的连接允许跳过认证但仍有RFCOMM级的基础链路保护。很多SPP透传模块出厂默认就是insecure模式。解决遇到连串口模块失败时可以先试试insecure版本的APIsocket device.createInsecureRfcommSocketToServiceRecord(uuid)用insecure连接时注意数据明文传输的风险如果是封闭环境里的设备控制问题不大但如果传输敏感信息还是要走安全连接。另外还要注意同一条socket上不能再切换到安全模式一旦创建就要按对应的安全级别走完整个生命周期。对于需要同时兼容安全和不安全两种模块的应用代码里要做好两种socket的切换策略我一般的做法是默认尝试安全连接失败一次后立即改用insecure重试实测这个方法对老设备的兼容性非常好。5. 数据稳定传输的关键细节缓冲、粘包、心跳和线程模型连接建好只是第一步实际项目中让数据稳定传输才是大头。这一节分享一下我在文件传输、指令交互这些场景里的实践方案。5.1 粘包与半包蓝牙socket上如何设计应用层协议RFCOMM是个流式协议和TCP一样没有消息边界。假设你连续发送两条指令ATSTATUS和ATPOWER ON对端可能一次性读到ATSTATUSATPOWER ON也可能先读到半个ATST剩下的稍后到达。这就是串口编程领域经典的粘包和半包问题。解决方案是在应用层做帧封装常见做法有两种定长帧和长度前缀帧。定长帧适合指令长度固定的小型协议比如每条指令都是16字节读满16字节再解析即可。长度前缀帧更通用格式为[2字节长度][负载]每次读取时先读2个字节得到负载长度再读对应长度的字节。一个最小实现如下private fun readFrame(input: InputStream): ByteArray { val lengthBytes ByteArray(2) input.readFully(lengthBytes) // 实际要循环读取直至填满2字节 val length (lengthBytes[0].toInt() and 0xFF) shl 8 or (lengthBytes[1].toInt() and 0xFF) val data ByteArray(length) input.readFully(data) return data }关于readFully标准Java的DataInputStream提供了同名方法但它在底层会循环读取直至目标数组填满或流关闭。蓝牙socket的InputStream本身不保证一次read就能读到完整长度所以要么自己写循环要么用DataInputStream包装。5.2 读取线程模型和数据积压处理我之前见过一个项目接收端只开了一个循环read每读到一块数据就往主线程post结果主线程处理不过来导致消息积压UI上显示串行错乱。这个问题的根子是生产速度大于消费速度和5.4节的RFCOMM流控本质一样但发生在应用层。建议的做法是读取线程只负责把数据块塞进队列如ConcurrentLinkedQueue由一个专用消费者按顺序处理并且消费者处理完一条才算一条。如果你的处理逻辑涉及UI更新再用handler/协程切到主线程。这套生产者-消费者模型能避免很多隐蔽的并发问题。缓冲区大小也是经验值1024字节适合指令类小包数据如果传图片或文件建议4096或8192减少read系统调用的次数。但注意缓冲区越大并不意味着越快因为底层RFCOMM的MTU限制在那里通常是1024字节实际受双方协商影响你设8K还是会被切成若干个RFCOMM包只是read的次数少了CPU占用略降。5.3 心跳和断线检测如何区分安静和掉线蓝牙传输在应用层是检测不到对方消失了这种事件的除非你正在读写。比如手机揣兜里走远了物理连接断了但你的socket可能还认为连接存在这时去write数据会抛异常去read会阻塞很久系统默认超时很漫长。实际项目中我建议加心跳机制。一种简单方案每2秒发送一个空指令比如PING对端收到后回复PONG连续3次没回PONG就判定断线主动close socket并触发重连。心跳间隔和超时次数根据业务调整实时性要求高的设备场景可以调到1秒/5次低频省电场景可以到10秒/3次。心跳的另一个作用是维持某些模块的低功耗状态。部分蓝牙透传模块为了省电一段时间没有数据传输会主动休眠心跳能保持链路活跃。5.4 write大文件的可靠传输分块加确认传输大文件比如几百KB的固件升级包时直接把整个文件塞进OutputStrem一次性write极易触发底层缓冲溢出和超时。我的方案是先发送文件头帧包含文件名、总长度、分块数、校验方式CRC16或MD5。分块读取文件每块通常是512字节或1024字节加上序号组成一帧。每发一组比如10帧等待对端的ACKACK携带最后一个成功收到的帧序号。收到ACK后继续发下一组如果超时未收ACK则重发整个组。全部发完后发送结束帧对端校验整体文件哈希并返回结果。这个方案做起来比听起来复杂但可靠性提升非常明显。实测用20KB/s左右的稳定速率传一个500KB的OTA包成功率从时好时坏提升到连续20次无失败。如果你不想做ACK机制至少要做重传和超时否则蓝牙这种不稳定介质上的大文件传输会让你痛不欲生。5.5 接收端read循环的CPU占用优化一个简单但常被忽略的优化如果某段时间没有业务数据要发接收线程仍然在阻塞read这没问题——因为InputStream.read是阻塞的不占CPU。但如果你用了带超时的read例如input.available() 0然后read或者设置了soTimeout就会导致空转轮询CPU占用可能冲到30%以上。因此有两种模式阻塞read模式适合一直要有数据流的场景音频流、文件传输不用管CPU。超时read模式适合指令交互场景设置一个较短的超时如200ms超时后检查连接状态、做心跳再继续阻塞等。我自己的习惯是没有数据时用阻塞read让线程休息需要心跳时另开一个定时任务而不是靠read超时来触发心跳。这样最简单也最省电。6. 并发连接、断开、重连中的生命周期管理细节很多项目不止一台设备连接。车机同时连手机和OBD盒子、平板连多台打印机——这种多连接场景下socket的管理复杂度暴增。最后这节聊聊连接池、断线重连和设备连接权的问题。6.1 多设备连接时怎么管理多个socket实例BluetoothSocket本身不是线程安全的所以每个设备连接最好单独持有独立的socket、独立的读取线程、独立的处理队列。一个朴素但有效的管理模型是维护一个ConcurrentHashMapBluetoothDevice, DeviceConnectionDeviceConnection里封装socket、input/output流、读取线程的状态、最近活跃时间。每建立一个连接往map里放一个实例断开时删除。往指定设备发数据直接从这个map拿出对应连接往它的output流写入。多连接的另一个注意事项是调度顺序如果多个请求共用同一个线程池线程池大小要按连接数合理设置否则一个连接的数据量大会饿着其他连接。我的建议是每连接一个专用读线程加一个专用写线程或者在读线程中处理响应写放在调用方线程这样可以天然隔离避免一个设备拖垮全局。6.2 断线重连的时机与退避策略断线重连是个系统工程不能简单断了就重连。我踩过的一个坑是两个设备蓝牙断开后立即重连大概率失败——因为系统协议栈层面的连接清理还没完成底层链路还处于半关闭状态。如果此时疯狂重连不仅全失败还会把本机蓝牙协议栈搞得不稳定。正确的做法是给重连加一个指数退避机制第一次重连延迟1秒。第二次3秒。第三次7秒。最多延迟30秒封顶。重连成功后重置退避计数。重连时还要判断为什么断的如果收到了明确的IOException如远程关闭可以放心重连如果是本机蓝牙被系统关闭adapter状态变为STATE_OFF要等蓝牙重新打开后再重连如果是权限问题用户关掉了权限就别重连了提示用户去设置里授权。6.3 应用生命周期和连接绑定的最佳实践最后一条经验连接的生命周期要和应用组件的生命周期绑定但不能简单绑定在Activity上。因为Android的Activity会旋转旋转导致Activity重建如果socket在Activity里持有重建后旧socket对象还在底层工作新Activity拿不到引用就漏了连接导致资源泄漏。我的方案是连接管理放在一个单例的Service或者ViewModel中Activity只做UI绑定和用户交互。具体做法启动一个前台Service持有连接。Activity通过Binder或LiveData观察连接状态。Service在onDestroy时统一关闭所有socket和线程。用户退出登录或切账号时Service清空连接并回到初始状态。这套架构虽然比Activity里整个socket复杂但能支撑起长期运行的蓝牙数据采集、车载通信这类真实业务。如果你的应用只是临时发个文件、对稳定性要求不高那Activity内管理也够用但至少要做到onPause时关闭socket、onResume时重连避免悬浮窗口遮挡或切后台导致连接被系统回收。根据我自己的经验蓝牙连接能不能稳定跟代码的关系很多时候不如跟设备协议栈实现的关系大。同一个app在A手机上连接某个模块十次有九次成功换到B手机可能三次就挂一次。这时候不要怀疑是自己的代码一瞬间变烂了而是要考虑协议栈差异、RF环境、模块兼容性这些客观因素。先把日志打全连接耗时、SDP查询结果、异常堆栈再多设备覆盖测试通常比反复调试代码本身更能定位问题。这也是我想单独写这篇文章的原因——关于BluetoothSocket的旧教程很多能覆盖到连接失败后具体怎么排查的少。希望这篇能把你的调试时间省下来。
返回列表