1. 为什么从传统蓝牙聊天室切入Android蓝牙开发很多人一上来就想搞BLE低功耗蓝牙觉得传统蓝牙已经是过时技术。但我在实际项目里带过不少新人发现一个规律直接啃BLE协议栈的人十有八九会在GATT回调的异步泥潭里迷失方向。传统蓝牙Classic Bluetooth的Socket模型反而更接近大家熟悉的TCP网络编程思维用它来建立蓝牙开发的整体认知框架效率高得多。这个聊天室Demo的核心价值不在于做一个能用的聊天工具而在于通过它把蓝牙开发中最基础也最容易出问题的几个环节全部跑通设备发现、配对绑定、RFCOMM通道建立、双向数据流读写、连接状态管理。这些环节在BLE里同样存在只是换了一套API和协议层。你把传统蓝牙这套流程吃透了后面切到BLE只是换个接口的事。适合谁来参考这篇内容如果你已经写过Android的基础界面对Activity生命周期、广播接收器、后台线程这些概念不陌生但还没碰过蓝牙相关的API那这个聊天室的实现路径就是为你准备的。我会把每个环节的选型理由、参数含义、容易踩的坑都拆开讲代码可以直接抄作业但更重要的是理解为什么这么写。传统蓝牙聊天室的本质是什么两台Android设备各自创建一个蓝牙服务端和一个蓝牙客户端通过RFCOMM协议建立一条类似TCP的可靠传输通道然后在这条通道上跑自定义的聊天消息协议。听起来简单但Android的蓝牙API在权限管理、线程调度、设备兼容性上埋了不少雷下面逐个拆解。2. 蓝牙权限与适配从Android 6到Android 12的完整避坑路线2.1 权限声明的版本差异Android的蓝牙权限模型在几个大版本里改过好几次这是新手最容易翻车的地方。你在网上搜到的代码可能只声明了BLUETOOTH和BLUETOOTH_ADMIN在Android 12以上的设备上直接崩溃。先看完整的权限清单按版本分层!-- Android 11及以下 -- 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 12及以上 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE /这里有几个关键点需要解释清楚。BLUETOOTH和BLUETOOTH_ADMIN是普通权限安装即授予不需要运行时申请。但ACCESS_FINE_LOCATION是危险权限从Android 6.0开始必须运行时申请。为什么扫描蓝牙设备需要定位权限因为蓝牙扫描结果可以推断出用户的地理位置这是系统层面的隐私保护策略。Android 12引入的BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE是新的运行时权限把之前混在定位权限里的蓝牙操作独立出来了。neverForLocation这个标志告诉系统我的应用不会用蓝牙扫描结果来推断位置这样在Android 12上就不需要再申请定位权限了。2.2 运行时权限申请的实操写法我见过太多项目在onCreate里直接调requestPermissions然后回调里不管三七二十一就开始扫描。正确的做法是按版本分支处理private static final int REQUEST_BT_PERMISSIONS 1001; private void requestBluetoothPermissions() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { // Android 12 String[] perms { Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE }; if (!hasPermissions(perms)) { requestPermissions(perms, REQUEST_BT_PERMISSIONS); } else { initBluetooth(); } } else if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { // Android 6.0 - 11 if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { requestPermissions( new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_BT_PERMISSIONS); } else { initBluetooth(); } } else { // Android 6.0以下安装即授予 initBluetooth(); } }注意在Android 12上如果你只申请了BLUETOOTH_SCAN但没有加neverForLocation标志系统仍然会要求定位权限。这个标志必须写在Manifest里运行时申请是改不了的。2.3 蓝牙适配器的获取与状态检查拿到权限之后第一步是获取BluetoothAdapter。这个对象是整个传统蓝牙开发的入口代表本机的蓝牙硬件。BluetoothManager bluetoothManager (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); BluetoothAdapter bluetoothAdapter bluetoothManager.getAdapter(); if (bluetoothAdapter null) { // 设备不支持蓝牙直接提示用户 Toast.makeText(this, 本设备不支持蓝牙, Toast.LENGTH_SHORT).show(); finish(); return; } if (!bluetoothAdapter.isEnabled()) { // 蓝牙未开启拉起系统设置页 Intent enableIntent new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableIntent, REQUEST_ENABLE_BT); }这里有个细节BluetoothAdapter.getDefaultAdapter()在API 18之后已经被废弃推荐用BluetoothManager来获取。虽然老方法还能用但在某些定制ROM上会返回null用BluetoothManager更稳妥。3. RFCOMM通道的建立UUID选择与Socket连接逻辑3.1 为什么是RFCOMM而不是L2CAP传统蓝牙支持两种传输协议L2CAP和RFCOMM。L2CAP是底层协议面向数据包需要自己处理分片和重组RFCOMM是在L2CAP之上模拟串口通信的协议提供面向连接的可靠字节流跟TCP的语义非常接近。对于聊天室这种场景RFCOMM是天然的选择。你不需要关心数据包怎么分片、怎么保证顺序Socket的输入输出流直接读写就行。Android的BluetoothSocket底层就是RFCOMM的实现API设计也跟java.net.Socket高度相似。3.2 UUID的选取原则RFCOMM通道用一个UUID来标识服务。两台设备必须使用相同的UUID才能建立连接。这个UUID可以是标准UUID也可以自己生成一个。// 自定义UUID随便生成一个就行只要两端一致 private static final UUID MY_UUID UUID.fromString(fa87c0d0-afac-11de-8a39-0800200c9a66);我一般建议自己生成一个UUID不要用那些公开的标准UUID。原因很简单如果你的应用和另一个使用相同标准UUID的应用同时运行系统可能会把连接路由到错误的服务上。用UUID.randomUUID()生成一个然后硬编码到两端代码里这是最省事的做法。3.3 服务端与客户端的Socket创建服务端用listenUsingRfcommWithServiceRecord创建监听Socketprivate class AcceptThread extends Thread { private final BluetoothServerSocket serverSocket; private volatile boolean running true; public AcceptThread(BluetoothAdapter adapter) { BluetoothServerSocket tmp null; try { // 第一个参数是服务名会显示在系统蓝牙设置里 tmp adapter.listenUsingRfcommWithServiceRecord( BluetoothChat, MY_UUID); } catch (IOException e) { Log.e(TAG, 创建服务端Socket失败, e); } serverSocket tmp; } public void run() { BluetoothSocket socket; while (running) { try { // accept()会阻塞直到有客户端连接或调用cancel() socket serverSocket.accept(); } catch (IOException e) { break; } if (socket ! null) { // 连接成功交给管理类处理 connected(socket, socket.getRemoteDevice()); try { serverSocket.close(); } catch (IOException e) { Log.e(TAG, 关闭服务端Socket失败, e); } break; } } } public void cancel() { running false; try { serverSocket.close(); } catch (IOException e) { Log.e(TAG, cancel时关闭失败, e); } } }客户端用createRfcommSocketToServiceRecord创建连接Socketprivate class ConnectThread extends Thread { private final BluetoothSocket socket; private final BluetoothDevice device; public ConnectThread(BluetoothDevice device) { this.device device; BluetoothSocket tmp null; try { tmp device.createRfcommSocketToServiceRecord(MY_UUID); } catch (IOException e) { Log.e(TAG, 创建客户端Socket失败, e); } socket tmp; } public void run() { // 连接前先取消扫描扫描会严重拖慢连接速度 bluetoothAdapter.cancelDiscovery(); try { socket.connect(); } catch (IOException e) { // 连接失败关闭Socket并通知UI try { socket.close(); } catch (IOException e2) { Log.e(TAG, 关闭失败Socket出错, e2); } connectionFailed(); return; } // 连接成功重置ConnectThread引用 synchronized (BluetoothChatService.this) { connectThread null; } connected(socket, device); } public void cancel() { try { socket.close(); } catch (IOException e) { Log.e(TAG, cancel时关闭失败, e); } } }提示cancelDiscovery()这一步绝对不能省。蓝牙扫描和连接共用射频资源扫描期间连接成功率极低而且速度慢得让人抓狂。我实测过不取消扫描的情况下连接成功率大概只有三成左右。3.4 连接失败后的重试策略createRfcommSocketToServiceRecord创建的Socket在某些设备上第一次连接会失败这是已知的兼容性问题。一个常见的workaround是用反射调用隐藏的createRfcommSocket方法指定通道号1// 备用方案仅在标准方式失败时使用 try { Method m device.getClass().getMethod( createRfcommSocket, new Class[]{int.class}); socket (BluetoothSocket) m.invoke(device, 1); } catch (Exception e) { Log.e(TAG, 反射创建Socket也失败了, e); }这个方案不是万能的但在我经手的设备里大概能解决七八成的首次连接失败问题。注意这是非公开API不同厂商的ROM可能行为不一致只能作为兜底方案。4. 连接管理线程模型读写流与状态同步4.1 ConnectedThread的设计思路连接建立之后需要启动一个专门的线程来管理数据读写。这个线程持有BluetoothSocket的输入输出流循环读取数据并回调给UI层。private class ConnectedThread extends Thread { private final BluetoothSocket socket; private final InputStream inStream; private final OutputStream outStream; private volatile boolean running true; public ConnectedThread(BluetoothSocket socket) { this.socket socket; InputStream tmpIn null; OutputStream tmpOut null; try { tmpIn socket.getInputStream(); tmpOut socket.getOutputStream(); } catch (IOException e) { Log.e(TAG, 获取流失败, e); } inStream tmpIn; outStream tmpOut; } public void run() { byte[] buffer new byte[1024]; int bytes; while (running) { try { // read()会阻塞直到有数据或连接断开 bytes inStream.read(buffer); if (bytes 0) { byte[] data new byte[bytes]; System.arraycopy(buffer, 0, data, 0, bytes); // 回调给UI层处理 handler.obtainMessage( MESSAGE_READ, bytes, -1, data).sendToTarget(); } } catch (IOException e) { Log.e(TAG, 连接断开, e); connectionLost(); break; } } } public void write(byte[] data) { try { outStream.write(data); // 通知UI层消息已发送 handler.obtainMessage( MESSAGE_WRITE, -1, -1, data).sendToTarget(); } catch (IOException e) { Log.e(TAG, 写入失败, e); } } public void cancel() { running false; try { socket.close(); } catch (IOException e) { Log.e(TAG, 关闭Socket失败, e); } } }这里有几个设计决策值得说明。第一read()是阻塞调用所以必须放在独立线程里不能跑在主线程上。第二缓冲区大小设为1024字节这是RFCOMM单次传输的常见上限设太大没意义设太小会增加系统调用次数。第三每次读取后都新建一个byte数组来传递数据避免后续读取覆盖掉之前的内容。4.2 线程安全的状态管理BluetoothChatService需要管理多个线程的引用并且这些引用会被不同线程访问所以状态同步必须做好。public class BluetoothChatService { // 连接状态常量 public static final int STATE_NONE 0; public static final int STATE_LISTEN 1; public static final int STATE_CONNECTING 2; public static final int STATE_CONNECTED 3; private int state STATE_NONE; private ConnectThread connectThread; private ConnectedThread connectedThread; private AcceptThread acceptThread; // 所有状态变更方法都加synchronized public synchronized void start() { // 取消所有已有线程 if (connectThread ! null) { connectThread.cancel(); connectThread null; } if (connectedThread ! null) { connectedThread.cancel(); connectedThread null; } if (acceptThread null) { acceptThread new AcceptThread(bluetoothAdapter); acceptThread.start(); } setState(STATE_LISTEN); } public synchronized void connect(BluetoothDevice device) { // 连接前先清理 if (state STATE_CONNECTING) { if (connectThread ! null) { connectThread.cancel(); connectThread null; } } if (connectedThread ! null) { connectedThread.cancel(); connectedThread null; } connectThread new ConnectThread(device); connectThread.start(); setState(STATE_CONNECTING); } public synchronized void connected(BluetoothSocket socket, BluetoothDevice device) { // 连接成功后清理其他线程 if (connectThread ! null) { connectThread.cancel(); connectThread null; } if (connectedThread ! null) { connectedThread.cancel(); connectedThread null; } if (acceptThread ! null) { acceptThread.cancel(); acceptThread null; } connectedThread new ConnectedThread(socket); connectedThread.start(); setState(STATE_CONNECTED); } private synchronized void setState(int newState) { state newState; // 通知UI层状态变化 handler.obtainMessage(MESSAGE_STATE_CHANGE, newState, -1).sendToTarget(); } }注意synchronized加在方法上锁的是BluetoothChatService实例。所有会修改线程引用的方法都必须加锁否则可能出现一个线程正在取消Socket另一个线程同时在创建新Socket的情况导致状态错乱。4.3 消息协议的简单设计聊天室的消息格式不需要太复杂但至少要能区分消息类型。我一般用一个字节的头部来标识// 消息类型定义 private static final byte MSG_TYPE_TEXT 0x01; private static final byte MSG_TYPE_EXIT 0x02; // 发送文本消息 public void sendTextMessage(String text) { byte[] textBytes text.getBytes(StandardCharsets.UTF_8); byte[] packet new byte[textBytes.length 1]; packet[0] MSG_TYPE_TEXT; System.arraycopy(textBytes, 0, packet, 1, textBytes.length); connectedThread.write(packet); } // 接收端解析 private void handleReceivedData(byte[] data) { if (data.length 1) return; byte type data[0]; switch (type) { case MSG_TYPE_TEXT: String text new String(data, 1, data.length - 1, StandardCharsets.UTF_8); // 更新UI显示 break; case MSG_TYPE_EXIT: // 对方主动断开 break; } }用UTF-8编码是必须的中文消息用默认编码在某些设备上会乱码。头部加一个字节的类型标识后续要扩展图片、文件传输也方便。5. 设备发现与列表展示的实操细节5.1 已配对设备与未配对设备的区分BluetoothAdapter.getBondedDevices()返回的是已经配对过的设备列表。这些设备可以直接连接不需要再走配对流程。未配对的设备需要通过扫描发现然后发起配对请求。// 获取已配对设备 SetBluetoothDevice bondedDevices bluetoothAdapter.getBondedDevices(); for (BluetoothDevice device : bondedDevices) { // 需要BLUETOOTH_CONNECT权限才能获取名称 String name device.getName(); String address device.getAddress(); // 添加到列表 }注意在Android 12上device.getName()和device.getAddress()都需要BLUETOOTH_CONNECT权限没授权的话会抛SecurityException。5.2 广播接收器的注册与过滤扫描结果通过广播接收器回调需要注册ACTION_FOUND、ACTION_DISCOVERY_FINISHED等几个Actionprivate final BroadcastReceiver receiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (BluetoothDevice.ACTION_FOUND.equals(action)) { BluetoothDevice device intent.getParcelableExtra( BluetoothDevice.EXTRA_DEVICE); // 跳过已配对的避免重复添加 if (device.getBondState() ! BluetoothDevice.BOND_BONDED) { // 添加到未配对列表 } } else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) { // 扫描结束更新UI状态 } } }; // 注册 IntentFilter filter new IntentFilter(); filter.addAction(BluetoothDevice.ACTION_FOUND); filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); registerReceiver(receiver, filter);扫描本身是个耗电操作startDiscovery()之后大概持续12秒左右会自动停止。不要频繁调用startDiscovery()如果正在扫描中再调一次系统会忽略第二次请求。5.3 配对流程的触发与监听点击未配对设备时发起配对请求// 需要BLUETOOTH_CONNECT权限 device.createBond();配对结果通过ACTION_BOND_STATE_CHANGED广播回调if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) { BluetoothDevice device intent.getParcelableExtra( BluetoothDevice.EXTRA_DEVICE); int bondState intent.getIntExtra( BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR); if (bondState BluetoothDevice.BOND_BONDED) { // 配对成功可以发起连接了 } else if (bondState BluetoothDevice.BOND_NONE) { // 配对失败或被取消 } }配对过程中系统会弹出配对码确认对话框这个界面是系统级的应用无法定制。用户确认后配对完成之后就可以直接连接了。6. 那些文档里不会写的踩坑记录6.1 连接建立后立即发送数据导致丢失这个问题我踩过好几次。socket.connect()返回成功之后底层RFCOMM通道可能还没有完全就绪如果立刻调用write()发送数据对方可能收不到。解决方案是在连接成功后加一个短暂的延迟或者等对方先发一个握手消息。我一般会在connected()回调里延迟200毫秒再允许发送handler.postDelayed(() - { // 允许发送消息 isReady true; }, 200);200毫秒是个经验值大部分设备够用了。如果你追求更稳妥可以设计一个应用层的握手协议连接建立后客户端先发一个握手包服务端收到后回复确认双方都确认后才开始正常通信。6.2 多设备同时连接时的UUID冲突如果你的应用需要同时连接多个设备每个连接必须用不同的UUID。我见过一个项目用同一个UUID创建了三个客户端Socket结果只有第一个能连上后面两个全部超时。正确的做法是为每个连接生成独立的UUID或者用同一个UUID但确保服务端能区分不同的连接请求。RFCOMM的通道号是有限的同时连接数一般不要超过7个。6.3 后台线程未正确终止导致的内存泄漏AcceptThread的accept()和ConnectedThread的read()都是阻塞调用如果Activity销毁时没有正确取消这些线程它们会一直挂着持有Socket和Context的引用造成内存泄漏。在onDestroy()里必须调用stop()Override protected void onDestroy() { super.onDestroy(); if (chatService ! null) { chatService.stop(); } if (receiver ! null) { unregisterReceiver(receiver); } }stop()方法里要依次取消所有线程并关闭Socket。注意cancel()方法里关闭Socket会触发阻塞调用的IOException线程会自然退出不需要额外干预。6.4 不同厂商ROM的兼容性差异国产ROM在蓝牙权限和后台限制上有很多定制。我遇到过几种典型情况问题现象可能原因应对方案扫描不到任何设备定位权限未授予或定位服务未开启检查权限提示用户开启定位配对后连接超时ROM限制了后台蓝牙操作引导用户将应用加入后台白名单连接成功但收不到数据ROM的省电策略冻结了后台线程使用前台服务保持进程活跃名称显示为null权限不足或设备未广播名称检查BLUETOOTH_CONNECT权限这些问题的共同点是代码逻辑没问题但系统层面做了限制。排查的时候先用系统自带的蓝牙功能验证硬件是否正常然后再看应用层的日志。6.5 消息边界问题RFCOMM是字节流协议不保证每次read()返回的正好是一条完整消息。如果发送方连续快速发送多条消息接收方可能一次读到多条消息的拼接也可能一条消息分两次读到。解决这个问题有两种思路。第一种是在消息头部加长度字段接收方根据长度来切分。第二种是用分隔符比如每条消息以换行符结尾接收方按行读取。我倾向于第一种更可靠// 消息格式[4字节长度][1字节类型][N字节内容] public void writeMessage(byte type, byte[] content) { ByteBuffer buffer ByteBuffer.allocate(5 content.length); buffer.putInt(content.length 1); // 长度包含类型字节 buffer.put(type); buffer.put(content); connectedThread.write(buffer.array()); }接收端维护一个缓冲区每次读取后尝试解析完整消息不完整就等下次数据。7. 从聊天室Demo到实际项目的扩展方向这个聊天室跑通之后你手里就有了一套可用的蓝牙通信框架。往实际项目上靠有几个方向可以扩展。第一个方向是文件传输。在消息协议里加一个文件类型把文件分片传输接收端重组。关键点是分片大小要合适太小了传输效率低太大了容易丢包。我一般用4KB左右的分片配合确认重传机制。第二个方向是多人聊天。传统蓝牙的RFCOMM是点对点连接要做多人聊天需要实现一个星型拓扑一个设备作为服务端同时监听多个客户端连接服务端负责消息转发。每个客户端连接对应一个ConnectedThread服务端维护一个连接列表收到消息后遍历列表转发给其他客户端。第三个方向是切换到BLE。传统蓝牙的配对流程对用户来说还是有点重BLE的广播和扫描机制更适合物联网场景。但BLE的GATT协议栈比RFCOMM复杂得多建议先用传统蓝牙把连接管理、线程模型、消息协议这些通用问题解决掉再迁移到BLE上。我在实际项目里发现蓝牙开发最耗时间的不是写代码而是排查各种设备兼容性问题。同一套代码在不同品牌、不同系统版本的设备上表现可能完全不一样。所以日志一定要打全每个关键节点都要有日志输出出问题的时候才能快速定位是权限问题、连接问题还是数据问题。