做Android开发这些年蓝牙通信一直是我觉得最能体现“手上功夫”的模块之一。乍一看官方文档就那么几个类但真把自己关进一个联调现场你会发现权限、配对、UUID、线程、厂商定制ROM各自埋了一堆雷。这篇是系列文章的第六篇前几篇我们搭好了项目骨架、界面框架和数据持久化今天就专门把蓝牙通信这条链路整个打通从选型、权限适配到设备发现、建立连接、数据收发再到联调过程中真正踩过的坑一次性讲透。蓝牙通信适合什么场景一句话概括就是设备之间短距离、无网络依赖的本地数据交换。常见的落地场景包括给某个硬件工具写配套App、两台手机之间点对点传文件、控制一台蓝牙串口模块连接的嵌入式设备。不管你是做毕设、做公司内部工具还是自己折腾智能家居这套流程都能直接用得上。我会按自己平时开发的顺序来写先想清楚用哪种蓝牙再配置环境然后写关键代码最后把我在真机上发现的各种诡异问题整理成一份排查手册。1. 蓝牙通信方案选型先想清楚你要哪一种蓝牙1.1 经典蓝牙与低功耗蓝牙的取舍蓝牙通信在Android上其实分两大流派一个是经典蓝牙BR/EDR一个是低功耗蓝牙BLEBluetooth Low Energy。很多新手上来就直接找代码结果找到的教程一半讲的是经典蓝牙的socket通信另一半讲的是BLE的GATT回调两套API完全不是一回事照着抄必然会翻车。我建议你先用下面这张表做个快速判断维度经典蓝牙BR/EDR低功耗蓝牙BLE通信方式RFCOMM通道类似TCP socketGATT协议基于服务/特征值典型速率能到1-2Mbps级别实际几十KB/s理论2Mbps实际看MTU协商功耗较高极低适合长期待机连接过程需要先配对再建立socket先扫描再连接GATT适用场景文件传输、串口透传、持续数据流传感器上报、信标、小数据包交互开发复杂度线程模型清晰接近普通Socket编程回调嵌套多状态机复杂如果你想实现的是两台设备之间“聊天式”的数据互传比如传文本、传文件、控制外部硬件模块我强烈建议直接用经典蓝牙。它走的是RFCOMM通道底层思想跟TCP socket非常接近写起来更顺手数据吞吐也够用。如果你只是每隔几秒采集一次温湿度、心率之类的小数据或者需要设备在后台低功耗待机那才需要考虑BLE。要注意的是Android的BLE扫描方式跟经典蓝牙完全不同它在API 21之后有一套独立的扫描回调机制很多做传感器的博主也容易在这两个体系之间切换时绕晕。这个系列项目做的是通用的设备间通信工具所以我下面的内容全部基于经典蓝牙的RFCOMM方案展开。选它的另一个原因是它不需要服务端架构两台手机或者手机加开发板就能点对点跑起来调试成本最低。1.2 通信双方的角色分配谁是服务端谁是客户端蓝牙通信里最重要的一步不是写代码而是先确定两端各自的角色。一发一收一个设备充当服务端进入监听状态等待连接另一个设备充当客户端主动发起连接。这个角色关系跟TCP很像但有一个容易忽略的差异蓝牙的“服务端”并不是一个常驻进程而是指当前设备调用监听接口、等待别人连过来的那一方。一旦连接建立双方的socket就变成对等的了谁都可以往里写数据谁都可以读对方发来的数据。你不需要区分“主设备”“从设备”只需要在建立连接的瞬间把两个线程写好。角色确定后接着就要选一个UUID。UUID在蓝牙通信里相当于“服务的门牌号”它标识了你提供的是一个什么样的服务。通信双方必须使用同一个UUID才能握手成功。Android官方示例里固定用那个00001101-0000-1000-8000-00805F9B34FB这其实是蓝牙串口服务SPP的标准UUID很多串口模块都用它。在实际开发中我建议不要直接照抄官方示例里的固定值。你可以在本地随便生成一个随机UUID比如用UUID.randomUUID().toString()然后把同一个值写到两端代码里。原因很简单如果你跟附近其他同样用官方示例值的设备连在一起有可能串线。自定义UUID能保证你的服务不会被别人的设备误连接。1.3 完整通信链路的数据流向把整条链路的全貌画在脑海里之后再动手会从容很多。实现蓝牙通信整个环节依次是权限申请→适配器初始化→扫描设备→触发配对→服务端监听/客户端连接→建立socket→读写数据→关闭连接。这里有个容易被忽略的关键点蓝牙连接从开始到结束整个过程至少要协调两个线程。一个线程负责阻塞等待连接另一个线程负责建立连接后的持续读写。如果你在主线程里调用connect()方法界面基本几秒后就ANR了。所以设计代码时我习惯先把线程模型想清楚再填充API调用。2. 环境准备先把权限和适配器跑通2.1 Android 12前后的权限模型差异蓝牙权限是整个模块最容易出问题的地方。以Android 12API 31为分水岭权限模型发生了很大变化。Android 11及以下API 30及以下在清单文件里声明BLUETOOTH和BLUETOOTH_ADMIN就够了。但扫描蓝牙设备还要额外申请定位权限ACCESS_FINE_LOCATION这一步经常被人漏掉。因为Android系统认为你能扫描到周围蓝牙设备就等于间接获取了用户位置信息所以必须给用户一个明确的授权入口。Android 12及以上蓝牙权限被拆分成了两个独立的运行时权限BLUETOOTH_SCAN扫描和BLUETOOTH_CONNECT连接。如果targetSdk还在30及以下APP跑在Android 12手机上时系统会自动帮你做兼容但一旦targetSdk升到31以上没申请这两个权限一调用蓝牙API就会直接抛SecurityException。下面是我现在项目里使用的清单声明兼顾了新旧版本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 android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN /如果你确定App不依赖蓝牙做位置推断可以在BLUETOOTH_SCAN权限上加一个android:usesPermissionFlagsneverForLocation属性。加上之后在Android 12及以上系统里就不再强制要求定位权限了。但要注意targetSdk 30及以下的设备仍然需要ACCESS_FINE_LOCATION这也是为什么我保留它并限制最高版本号。2.2 动态权限申请的正确姿势有了清单声明还不够Android 6.0之后运行时权限必须动态申请。我用的是registerForActivityResult这套Activity Result API它是目前最推荐的方式代码比原来onRequestPermissionsResult回调清晰太多。private val permissionLauncher registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result - val scanGranted result[Manifest.permission.BLUETOOTH_SCAN] ?: false val connectGranted result[Manifest.permission.BLUETOOTH_CONNECT] ?: false if (scanGranted connectGranted) { initBluetooth() } else { toast(缺少蓝牙权限无法继续) } } private fun checkPermission() { val needPermissions mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) ! PackageManager.PERMISSION_GRANTED) { needPermissions.add(Manifest.permission.BLUETOOTH_SCAN) } if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ! PackageManager.PERMISSION_GRANTED) { needPermissions.add(Manifest.permission.BLUETOOTH_CONNECT) } } else { if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { needPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } } if (needPermissions.isNotEmpty()) { permissionLauncher.launch(needPermissions.toTypedArray()) } else { initBluetooth() } }申请完权限并不意味着一定搜得到设备因为某些国产定制ROM还会在系统设置里额外加一个“附近设备”开关代码里申请了权限但系统设置没打开照样返回空列表。这个我在后面的排查章节再细说。2.3 蓝牙适配器检查与开启流程权限到位后第一步是拿到本机的蓝牙适配器实例。代码很简单private val bluetoothAdapter: BluetoothAdapter? by lazy { BluetoothAdapter.getDefaultAdapter() } private fun initBluetooth() { val adapter bluetoothAdapter if (adapter null) { toast(当前设备不支持蓝牙) return } if (!adapter.isEnabled) { val enableIntent Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) enableBluetoothLauncher.launch(enableIntent) } else { startDiscovery() } }getDefaultAdapter()返回null说明设备没有蓝牙硬件这种情况在平板和模拟器上很常见。请求开启蓝牙时系统会弹出一个对话框询问用户是否允许开启不要直接在代码里调用enable()强开蓝牙系统现在也不建议这么做。有一点经验值得分享注册蓝牙状态广播ACTION_STATE_CHANGED可以帮你监听用户最终点了“允许”还是“拒绝”再决定后续逻辑是否继续。我最初忽略这个广播用户拒绝开启蓝牙后界面一直卡在“开启中”的状态非常反直觉。所以我会在初始化时就注册一个蓝牙状态接收器。3. 核心代码实现从扫描设备到建立连接3.1 设备扫描与列表展示经典蓝牙的设备发现依赖系统广播。注册一个BroadcastReceiver监听两个关键动作ACTION_FOUND发现一个设备和ACTION_DISCOVERY_FINISHED发现完毕。private val discoveryReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { BluetoothDevice.ACTION_FOUND - { val device intent.getParcelableExtraBluetoothDevice(BluetoothDevice.EXTRA_DEVICE) device ?: return val name device.name ?: 未知设备 if (!deviceList.contains(device)) { deviceList.add(device) adapter.notifyItemInserted(deviceList.size - 1) } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { toast(扫描完成共发现 ${deviceList.size} 台设备) } } } } private fun startDiscovery() { val adapter bluetoothAdapter ?: return if (adapter.isDiscovering) { adapter.cancelDiscovery() } deviceList.clear() adapter.startDiscovery() }这里有个细节BluetoothDevice.EXTRA_DEVICE在低版本里是Parcelable直接用getParcelableExtra就行。但在Android 13及以上targetSdk 33系统要求用getParcelableExtra的话要指定Class否则会有类型安全警告。我在项目里写了个兼容方法通过判断SDK版本分别处理。另外扫描过程中如果已经找到了目标设备最好是立刻调用cancelDiscovery()停止扫描。原因很好理解蓝牙在扫描过程中是以跳频方式不断切换信道搜索会占用大量射频资源不仅拖慢当前连接速度还可能影响旁边的WiFi。我实测过两台手机距离很近的情况下一边扫描一边连接连接建立速度会肉眼可见地变慢。设备列表出来后下一步是配对。调用device.createBond()会触发系统的配对对话框。大多数场景下系统对话框会给出确认码让你确认或者让你输入PIN码比如某些老式蓝牙串口模块默认PIN是1234。我们作为开发者一般不需要自己处理配对流程等系统弹窗即可。但如果你的App要自动化操作、不想人工介入可以通过监听ACTION_PAIRING_REQUEST广播来自动应答。下面是个简单的PIN码自动填充逻辑// 处理配对请求广播 if (action BluetoothDevice.ACTION_PAIRING_REQUEST) { val device intent.getParcelableExtraBluetoothDevice(BluetoothDevice.EXTRA_DEVICE) if (device ! null) { val pin 1234.toByteArray(Charsets.UTF_8) device.setPin(pin) device.setPairingConfirmation(true) } }需要注意的是setPin和setPairingConfirmation这两个方法是隐藏API在部分系统上加上反射也不一定稳定。我的原则是能用系统弹窗就尽量用系统弹窗自动配对只是兜底方案。3.2 服务端监听与主动连接两个线程搞定连接建立阶段是写法最讲究的部分。我把服务端和客户端分别封装成两个线程。先看服务端。服务端调用listenUsingRfcommWithServiceRecord打开一个监听socket然后阻塞在accept()方法上等待客户端连入private inner class AcceptThread : Thread() { private val serverSocket: BluetoothServerSocket? bluetoothAdapter?.listenUsingRfcommWithServiceRecord(SERVICE_NAME, MY_UUID) override fun run() { var socket: BluetoothSocket? null try { socket serverSocket?.accept() socket?.let { connectedThread ConnectedThread(it).also { thread - thread.start() onConnected(it.remoteDevice) } } } catch (e: IOException) { e.printStackTrace() } finally { serverSocket?.close() } } }再看客户端。客户端通过device.createRfcommSocketToServiceRecord(MY_UUID)创建socket然后调用connect()主动连接private inner class ConnectThread(device: BluetoothDevice) : Thread() { private val socket: BluetoothSocket? by lazy { device.createRfcommSocketToServiceRecord(MY_UUID) } override fun run() { // 连接前必须先取消扫描否则会大幅降低连接成功率 bluetoothAdapter?.cancelDiscovery() try { socket?.connect() connectedThread ConnectedThread(socket!!).also { it.start() } onConnected(device) } catch (e: IOException) { e.printStackTrace() runOnUiThread { toast(连接失败${e.message}) } } } }客户端连接时有个特别经典的坑如果当前另一线程正在执行startDiscovery()必须先调用cancelDiscovery()否则socket连接经常报Service discovery failed。这个坑官方文档里其实写了但极容易被忽视。还有个大坑是部分国产芯片或者老模块在连接时会走厂商私有协议导致标准createRfcommSocketToServiceRecord方式连不上。这时可以用一个隐藏接口createRfcommSocket(int channel)来兜底常见做法是通过反射调用private fun createInsecureSocket(device: BluetoothDevice): BluetoothSocket? { return try { val method device.javaClass.getMethod(createRfcommSocket, Int::class.javaPrimitiveType) method.invoke(device, 1) as BluetoothSocket } catch (e: Exception) { null } }这个方法本质上是在访问系统的隐藏API虽然很多老项目都用它但不同厂商系统对反射的限制不同概率会越来越不好用。我的建议是优先使用标准API只有确认连接失败且怀疑是RFCOMM通道问题时再尝试这种反射方案并且一定要用try-catch兜住。3.3 建立连接后的数据读写循环连接一旦建立双方手里都是一条BluetoothSocket接下来的数据收发就跟操作普通IO流一样。我写了一个ConnectedThread统一管理读写private inner class ConnectedThread(private val socket: BluetoothSocket) : Thread() { private val inputStream: InputStream socket.inputStream private val outputStream: OutputStream socket.outputStream private val writeLock Any() override fun run() { val buffer ByteArray(1024) while (!isInterrupted) { try { val bytes inputStream.read(buffer) if (bytes 0) { val message String(buffer, 0, bytes, Charsets.UTF_8) runOnUiThread { onMessageReceived(message) } } else if (bytes -1) { break } } catch (e: IOException) { e.printStackTrace() break } } } fun write(data: ByteArray) { synchronized(writeLock) { outputStream.write(data) outputStream.flush() } } }这段代码看起来简单实际使用时我需要强调两个问题。第一个问题消息边界。InputStream.read()不知道对方的消息从哪开始、到哪结束可能一次读到半条消息也可能一次黏住好几条消息。做聊天类应用时这是必踩的坑。我通常在传输协议里加一个简单的消息帧格式前4个字节表示消息长度后面跟着消息内容。接收端先读满4个字节的Int再读指定长度的内容。这样粘包半包问题就彻底解决了。第二个问题线程生命周期。read()会一直阻塞当Activity销毁时如果只顾着关socket没有把线程置为中断状态下次尝试重新连接时旧线程还占着旧socket新连接必然失败。所以我在关闭连接的工具方法里会同时做三件事关闭socket、中断线程、置空引用。write()方法里我用synchronized锁包住OutputStream的写操作。Android的蓝牙socket内部虽然对并发写有一定限制但多个线程同时调这个方法时仍然可能出问题加一个同步锁成本很低换来的是长期稳定性。4. 联调实录常见问题与排查技巧4.1 扫描不到设备先检查这五件事扫描不到设备是新手反馈最多的问题也是我在几个跨部门协作项目里反复被问到的。以下是我的排查顺序建议按这个顺序逐项检查第一运行时权限是否真正申请成功。很多人只在清单文件里声明了权限没做动态申请结果扫描直接返回空。Android 12及以上要看BLUETOOTH_SCANAndroid 11及以下要看ACCESS_FINE_LOCATION不能只看蓝牙本身的权限。第二是否注册了广播接收器。设备发现结果是通过广播返回的如果你没有动态注册BluetoothDevice.ACTION_FOUND扫描生命周期再正常也收不到任何数据。第三系统蓝牙是否真的开启了。不要以adapter.isEnabled作为唯一依据有些定制ROM在省电策略下会把蓝牙扫描静默暂停。第四扫描超时问题。蓝牙发现默认持续约12秒超过后自动停止。如果你的界面没有监听ACTION_DISCOVERY_FINISHED用户会感觉“好像一直没搜完”。我通常会让用户在扫描结束后手动再点一次刷新或者干脆做一个扫描中/完成的状态动画。第五部分国产定制ROM的系统设置里还有独立的“附近设备”权限开关。你在App里申请了权限但在系统设置里被单独关闭照样扫不到。我在某款机型上就遇到过这种诡异情况最后是让用户去设置里手动打开开关才解决。如果做了以上五步还是扫不到换一台不同品牌设备交叉测试。我在一个双机互传的调试任务中就遇到过某台手机扫描列表稳定为空另一台却能正常发现最后定位是该手机系统蓝牙服务异常重启一下就好。4.2 连接失败与UUID不匹配的经典坑连接失败时最常见的报错是Service discovery failed或read failed, socket might closed。我把原因分成三类逐个排查。第一类是UUID不一致。服务端和客户端必须使用同一个UUID。注意BluetoothServerSocket里的UUID和createRfcommSocketToServiceRecord的UUID必须一模一样。我曾经为了“看起来更通用”在客户端用了另一个UUID结果每次连接都失败查了半天才发现两端串了。第二类是同一设备上多个server socket冲突。如果你多次调用了listenUsingRfcommWithServiceRecord而没有关闭上一个监听socket系统可能只允许一个同名服务实例后续监听会失败。我记得在开发某白板投屏工具时就反复遇上重启监听后失败的情况。后来我在进入监听前先调用close()清理上次遗留的serverSocket问题才消失。第三类是连接后没有及时取消发现。这个问题我在3.2节里提过再次强调连接前调用cancelDiscovery()是降低失败率的关键。在嵌入式蓝牙模块上同时扫描和连接时模块响应不及时的概率极高。还有一类值得单独说连接成功了但读写老掉线或者发一会儿就断开。这种多见于低功耗的廉价蓝牙串口模块它们的固件对数据缓冲处理不严谨。我处理这类问题时会把单次写入的数据控制在256字节以内并且两次写入之间加一点短延迟防止模块缓冲区溢出。虽然牺牲了一点速度但稳定性明显提升。4.3 卡顿、ANR与大文件传输的内存问题蓝牙通信里的ANR大多数是因为在主线程直接做了socket操作。一个很常见的错误写法是在按钮点击事件里直接执行socket.connect()如果目标设备不在范围内connect会一直尝试到超时界面直接白屏最后弹出“无响应”。正确做法是像3.2节那样把连接逻辑放进子线程。读循环天然是阻塞的无论如何都不能放到主线程。UI刷新必须通过runOnUiThread或Handler切回主线程。大文件传输的坑则更隐蔽。很多人从输入流读数据时喜欢一次性read进一个超大byte数组比如几十MB的文件想用byteArrayOfSize(fileSize)直接装下。这在大内存手机上可以运行但遇到文件特别大时极其危险直接OOM。正确姿势是边读边写入文件val buffer ByteArray(8192) FileOutputStream(targetFile).use { fos - while (true) { val bytes inputStream.read(buffer) if (bytes 0) break fos.write(buffer, 0, bytes) // 更新进度 progress bytes runOnUiThread { updateProgress(progress) } } }经典蓝牙的实际传输速率受设备射频和天线影响普遍在50KB/s到150KB/s之间。如果你要做文件互传功能一定要给用户一个进度条和传输速率显示否则几MB的文件等上一分多钟用户早就以为卡死了。4.4 调试阶段的日志与辅助工具最后分享一个调试习惯。我在联调蓝牙时几乎都会打开蓝牙HCI日志Android的开发者选项里有一个“蓝牙HCI snoop日志”开关。打开后系统会把蓝牙HCI层收发的所有数据包记录到文件里配合日志查看工具分析定位配对、加密、连接失败这类底层问题非常有用。但要注意开启HCI日志会明显影响蓝牙传输速度平时测试完记得关掉。我在一次性能测试中发现开着HCI日志时传输速度只有几十KB/s关掉后立刻恢复到了100多KB/s。这个细节容易被忽略很容易导致你误判“代码有问题”。5. 后续优化方向与一点个人体会蓝牙通信做完了基本链路后面想再深入可以从三个方向扩展。第一是支持多设备同时连接。用HashMapString, ConnectedThread按设备地址管理多条socket收发消息时用设备地址做路由。第二是断线重连机制。用指数退避策略控制重连频率间隔从1秒翻倍到5秒、10秒、30秒杜绝死循环式重连。第三是把整个蓝牙模块抽成一个单例服务用接口把连接状态、数据回调暴露给业务层这样以后切换成BLE方案或者换一套传输协议都不需要改动UI层。讲到个人体会我在实际项目中感受最深的还是那句老话先跑通再优化。蓝牙涉及设备、系统、射频环境各种变量不要在写代码阶段纠结太过完美的协议设计。我见过很多新同事第一步就想设计一套复杂的帧格式和加密策略结果连两端都没连通过白白浪费调试时间。我自己的习惯是先用最简单的String消息把手拉通确认扫描、配对、连接、读写链路完全正常后再逐步替换成正式的二进制协议。另外一个确定性很高的经验是一定要备两台以上的真机并且尽量是不同的品牌和Android版本。模拟器对蓝牙的支持非常有限测试难度大不说很多协议细节跟真机完全不同。我在一个项目中就是靠一套老安卓机和一台新手机交叉测试才把权限适配、系统弹窗行为这些差异问题排查清楚。蓝牙通信这个模块做完之后你会发现它对程序员的锻炼是全方位的线程调度、IO流处理、系统权限、状态机管理每一样都是通用能力。即使以后这个项目结束这套代码的架构思路和分析方法也能直接迁移到其他通信场景上去。
阅读完成 · 觉得有帮助?