
简介一份基于Android平台的蓝牙聊天室完整工程代码适合Android初学者或计算机专业学生用于课程设计、毕业设计及蓝牙通信技术入门。项目支持扫描附近蓝牙设备、建立安全/非安全连接、实时收发消息并集成已配对设备管理、设备发现结果返回与运行日志显示在Android 5及以上版本可直接运行。包体共65个文件以xml界面布局、png图标素材、java核心逻辑代码为主同时包含gradle构建配置、proguard混淆规则和README说明压缩包仅184KB结构精简便于二次开发。目前已有123人学习下载。通过这份资源可拿到一套可运行的蓝牙聊天室Android工程既能对照研究蓝牙Socket通信流程、设备扫描与配对回调机制也能直接修改界面与业务逻辑快速搭建自己的蓝牙聊天应用。1. 基于 Android 的蓝牙聊天室你缺的不是蓝牙是消息收敛聊到“基于 Android 的蓝牙聊天室”大多数人的第一反应是用 BLE低功耗蓝牙来做毕竟现在手机对 Classic Bluetooth 的支持越来越隐晦Android 12 之后权限模型也改过一轮。但真把这个项目做起来你会发现聊天室的核心难点不在蓝牙链路而在多设备拓扑下的消息收敛。BLE 的广播模型适合一对多通知不适合双向聊天经典蓝牙 SPP 才是聊天室该走的路线——一台设备做服务端其余设备做客户端服务端负责消息转发。这个标题能解决的实际问题是在没有 Wi-Fi、没有蜂窝网络的场景下让一群 Android 设备通过蓝牙交换文本消息。适合做课程设计、离线通信原型、以及想搞懂 Android 蓝牙 API 全流程的开发者。2. 蓝牙聊天室的架构选型经典蓝牙 SPP 还是 BLE为什么是服务端-客户端模型2.1 不要一上来就写 UI先定蓝牙的物理模型很多人做蓝牙聊天室第一步就去布局 ListView 和气泡这是顺序错了。蓝牙通信的前提是设备发现与连接建立而连接建立方式直接决定聊天室的拓扑。经典蓝牙的点对点连接模型天然支持**服务端Server和客户端Client**两个角色服务端调用listenUsingInsecureRfcommWithServiceRecord()进入监听客户端调用createRfcommSocketToServiceRecord()发起连接。连接建立后两端各有一个BluetoothSocket底层是 RFCOMM 流通道往这个 socket 里写字节对端就能读到。BLE 不是不能做聊天但它的 GATT 服务端-客户端模型更偏向「外设-中心设备」广播间隔、MTU 协商、连接参数更新这些坑会让聊天室从「能跑」变成「看人品」。SPP 的抽象是一个全双工流和 TCP socket 的编程体验高度一致写起来就像写一个局域网内聊天工具。到这里选型已经有结论了用经典蓝牙 SPP服务端做星型拓扑中心。所有客户端只与服务端连接客户端之间不直接建链这样消息路由只需要在服务端维护一张表设备地址 → 会话线程。// 服务端开启监听UUID 必须与客户端一致 private static final UUID CHAT_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB); BluetoothAdapter adapter BluetoothAdapter.getDefaultAdapter(); BluetoothServerSocket serverSocket adapter.listenUsingInsecureRfcommWithServiceRecord(ChatRoom, CHAT_UUID);这段代码里有两个关键参数第一个参数ChatRoom是服务发现协议SDP里显示的服务名其他设备在配对列表里看到的也是它第二个参数CHAT_UUID是用来匹配服务的唯一标识SPP 的默认 UUID 是00001101-0000-1000-8000-00805F9B34FB如果你用自己的 UUID连接时必须以createRfcommSocketToServiceRecord(uuid)传入完全一致的值否则会走不到同一个服务。2.2 聊天室的线程模型一个连接一条线程消息必须串行蓝牙 socket 的读写是阻塞的。read()会一直卡住等数据write()在缓冲区满时也会阻塞。这意味着绝对不能在主线程里直接读写蓝牙 socket否则 ANR 只是时间问题。常见做法是服务端每次accept()到新连接后单独起一个ConnectedThread负责这条链路的读写客户端在connect()成功后同样起一个线程。所有线程自己只负责搬运字节流把数据交给一个公共的消息队列。聊天室的“室”字就体现在这里每个客户端把消息发给服务端服务端将消息广播给除发送者以外的所有客户端。如果服务端是八台设备客户端 A 发一条消息服务端要做的是遍历 A 之外七条连接的输出流把消息写出去。这块如果每收到一条消息就去写所有 socket高并发下会因为写阻塞导致读不到新数据线程之间互相卡死。我一般会在服务端维护一个BroadcastQueue用一个HandlerThread串行消费写每条流之前先检查 socket 是否还打开。角色蓝牙 API 方法线程职责服务端listenUsingInsecureRfcommWithServiceRecordaccept()循环 每连接一个读写线程客户端createRfcommSocketToServiceRecordconnect() 单读写线程服务端广播写所有客户端 socket 输出流串行队列防止对端写阻塞互相影响2.3 Android 12 前后的权限差异老代码跑不起来的根因如果你在网上找老例子会看到BLUETOOTH和BLUETOOTH_ADMIN两个权限那是 Android 11 (API 30) 及之前的写法。现在编译目标基本是 API 33 或 34这俩权限早就被废弃了。Android 12API 31引入了新的蓝牙权限模型分清楚了“扫描设备”和“连接已配对设备”是两件事BLUETOOTH_SCAN扫描周边设备动态权限需要运行时申请BLUETOOTH_CONNECT连接、配对、访问已经配对的设备列表动态权限BLUETOOTH_ADVERTISE让自己可被周边设备发现动态权限还有一个隐性前提如果 targetSdk 31BLUETOOTH_SCAN还需要配套位置权限Android 11 之前是必须的12 之后只在部分厂商 ROM 上要求。从实践看小米和三星的部分机型不申请定位权限也能扫描到但华为和荣耀如果不开精确定位扫描列表大概率是空的。稳妥做法是把ACCESS_FINE_LOCATION一并申请了这不是过度申请是兼容性的现实需要。权限申请的逻辑应该在进入聊天室页面时就触发不能放到按钮点击之后。因为startDiscovery()是个异步过程不等权限授权完再调会发现回调里拿到的ACTION_FOUND广播永远不来。3. 用 Android Studio 落地最小可运行的蓝牙聊天室代码3.1 工程配置与权限声明先让 APP 能拿到底层硬件在AndroidManifest.xml里除了常规权限声明还要把蓝牙设备的 feature 声明加上。uses-feature会被 Play 商店用来筛选注定无法使用蓝牙的设备但国内应用市场不太看这个字段更多是起文档作用。uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-feature android:nameandroid.hardware.bluetooth android:requiredtrue /注意第一行的neverForLocation标志它声明你的扫描结果不会用于推导物理位置。加了这个标志后部分机型会放宽对定位权限的要求但这个标志不是豁免牌只要你在代码里把扫描到的 RSSI 和 MAC 地址上报给定位服务系统依然可能拦截你的扫描。聊天室场景不需要 RSSI 做定位后面讲测距是增值功能所以加这个标志是合理的。3.2 运行时权限申请用 Activity Result API 而不是 onRequestPermissionsResult传统写法是requestPermissions()配onRequestPermissionsResult()回调里判断三种情况代码很啰嗦。现在 Android Studio 里创建的新项目默认带 Activity Result API写起来干净得多private final ActivityResultLauncherString permissionLauncher registerForActivityResult( new ActivityResultContracts.RequestPermission(), granted - { if (granted) { startScanAndConnect(); } else { Toast.makeText(this, 需要蓝牙权限才能进入聊天室, Toast.LENGTH_LONG).show(); } }); private void ensureBluetoothPermission() { if (Build.VERSION.SDK_INT 31) { boolean ok checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) PackageManager.PERMISSION_GRANTED; if (!ok) { permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT); return; } // 扫描、广播权限同样按需判断 startScanAndConnect(); } else { // 低版本走原来的权限路径 startScanAndConnect(); } }这里需要特别说明一个隐蔽问题BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个权限必须逐个申请不能合并提交。ActivityResultContracts.RequestMultiplePermissions虽然支持数组但用户拒绝其中任意一个回调里是拿一个布尔值数组你还是要逐项判断到底缺哪个权限。与其这样不如分开申请每次一个失败就提示具体缺哪项。权限都拿到后判断adapter.isEnabled()部分手机这里也会返回异常——个别国产 ROM 的蓝牙服务偶发崩溃可以在 catch 里弹窗提示重启蓝牙。3.3 扫描设备列表用 BroadcastReceiver 还是蓝牙扫描回调经典蓝牙扫描使用BroadcastReceiver监听BluetoothDevice.ACTION_FOUNDAndroid 12 以前这是唯一方式。BluetoothLeScanner那套只适用于 BLE不适用于经典蓝牙。代码框架如下private 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); short rssi intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE); if (device.getName() ! null !device.getName().isEmpty()) { DeviceItem item new DeviceItem(device.getName(), device.getAddress(), rssi); if (!adapterList.contains(item)) { adapterList.add(item); adapter.notifyDataSetChanged(); } } } } };务必注意getParcelableExtra在 API 33 之后需要带 type 参数重载写intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice.class)否则会得到一个类型转换警告甚至崩溃。扫描结果里 RSSI 的单位是 dBm负数越大代表信号越近但这与真正可用带宽没有直接关系——一堵水泥墙就能把两米的信号打到 -80dBm。显示“信号强度”时建议把它转成 0 到 100 的百分比公式Math.max(0, Math.min(100, (-rssi - 45) * 100 / 55))45 是 -45dBm 假设最佳信号100 是 -100dBm 假设断连阈值。扫描持续时间不要超过 12 秒。adapter.startDiscovery()是一个持续约 12 秒的阻塞型过程期间 RSSI 会不断变化你是拿不到一个最终值的。推荐做法是扫描到目标设备后调用adapter.cancelDiscovery()立刻停掉再走配对流程。3.4 建立 socket 连接服务端 accept 与客户端 connect 的时序服务端和客户端的建链过程存在一个时序问题服务端必须先进入 accept() 阻塞等待客户端才能 connect 成功。否则客户端会抛IOException: Connection refused。// 服务端线程 public void run() { try (BluetoothServerSocket serverSocket adapter.listenUsingInsecureRfcommWithServiceRecord(ChatRoom, CHAT_UUID)) { while (true) { BluetoothSocket socket serverSocket.accept(); if (socket ! null) { new ConnectedThread(socket).start(); } } } catch (IOException e) { Log.e(TAG, server accept failed, e); } }这里有一个很多教程不会讲的细节listenUsingInsecureRfcommWithServiceRecord和listenUsingRfcommWithServiceRecord的区别在于是否强制配对。Insecure版本指的是在已配对设备间建立加密级别较低的链路不弹配对确认框Secure版本如果对方没配对过会走系统配对流程。调试时用 Insecure 可以省掉一部分配对弹窗但你会在 Android 12 上遇到SecurityException: Need BLUETOOTH_CONNECT permission——这个权限检查是即时性的不是说之前申请过就行系统可能在某个时间点重新校验。建议在每次连接前主动调用checkSelfPermission再做一次兜底判断。客户端那边connect() 也是一个阻塞操作超过 15 秒没连上就应该放弃。用socket.connect()没有超时参数常见做法是包一层线程加标志位Thread connectThread new Thread(() - { try { socket device.createRfcommSocketToServiceRecord(CHAT_UUID); socket.connect(); runOnUiThread(() - onConnected(socket)); } catch (IOException e) { runOnUiThread(() - onConnectFailed(e)); } }); connectThread.start();另一个隐形坑同一个设备如果之前连接没有 disconnect下一次 connect 会失败因为 RFCOMM 通道没有释放。因此在 connect 之前务必做一次socket.close()兜底或者调用adapter.cancelDiscovery()后延迟 200ms再 connect——系统内部还会做一些 RFCOMM 配置太快了容易拿到errno 103。3.5 数据读写DataOutputStream 写 UTF 字符串边读边拼聊天的消息模型是短文本直接定义传输协议为「UTF-8 编码的 String」发送端和接收端都按行处理。用DataOutputStream.writeUTF()和DataInputStream.readUTF()最省事因为 writeUTF 自带两字节的 length 前缀readUTF 会先读长度再读内容天然拆包不会出现 TCP 里的黏包半包问题。public void sendMessage(String msg) { try { dataOutputStream.writeUTF(msg); dataOutputStream.flush(); } catch (IOException e) { // 这里一般是蓝牙断开需要通知 UI 层去清理 connectionLost(); } } public void listen() { try { while (true) { String received dataInputStream.readUTF(); handler.obtainMessage(MSG_RECEIVED, received).sendToTarget(); } } catch (IOException e) { connectionLost(); } }writeUTF有两个注意点一是它以修改过的 UTF-8 编码写字符串这种编码和标准 UTF-8 在U0000和代理对上有差异如果你要传输 Emoji 消息Android 的实现是没问题的但如果你有非 Android 端比如 Python 写的 PC 服务在接收就要确认那边也按writeUTF的规则解析二是writeUTF的最大长度是 65535 字节聊天消息字段超过这个长度会抛UTFDataFormatException。正常人不会发 60KB 的消息但粘贴一大段文字是有可能的建议在 UI 层限制输入长度不超过 5000 字符。注意read()返回的字节流里如果对方中途断开readUTF()会抛EOFException。这必须在connectionLost()里处理干净关闭 socket停止线程发一个广播通知 Activity 更新连接状态,再把ConnectedThread从服务端的连接表中移除。漏掉这一步后续广播消息时往已关闭的流里写数据服务端就会连环崩溃。4. 消息协议与聊天室状态同步不只是收发字符串4.1 用 JSON 封装消息类型服务端才能做逻辑分派如果聊天室只是两个人互发文本直接writeUTF(hello)就够了。一旦有多个客户端、昵称、上下线通知、历史消息就必须给消息定义结构。我一般用 JSON因为序列化和反序列化在 Android 上都有现成库跑在低端机上也不心疼。规定消息格式为{ type: text, sender: 昵称, content: 消息内容, ts: 1712100000, target: }。type至少有四种text普通消息、join上线、leave下线、history服务端同步的历史消息。sender字段建议用昵称而不是设备名因为设备名有重名风险且用户不希望在聊天室显示“Galaxy A52”这种名字。ts是 Unix 时间戳显示消息时间用也可以用System.currentTimeMillis()在接收端补上但这样不同设备的时间戳基准不同还是服务端统一盖上比较合理。public class ChatMessage { public String type; public String sender; public String content; public long ts; public String target; public static ChatMessage fromJson(String json) { return new Gson().fromJson(json, ChatMessage.class); } public String toJson() { return new Gson().toJson(this); } }Gson 在这个场景下的开销可以忽略但要注意toJson()出来的字符串里如果带换行符会变成\n转移序列接收端fromJson时能正确还原不会影响readUTF的行读取。不要在writeUTF前手动拼 JSON 字符串尤其是当你把ts用long类型传进去时误用了int会在 LOS_UP 设备上溢出。4.2 服务端转发规则单播、广播与成员管理服务端在ConnectedThread读取到一条消息后要做的事情不是傻乎乎地给所有人发而是先看type。join消息服务端把新成员的名字加入列表给新成员回一条join_ok同时向旧成员广播一条new_member通知。text消息如果target字段为空广播给所有客户端如果target非空则只发给target指定的设备地址对应的会话线程——这就是私聊功能很容易扩展。leave消息从成员表里删除广播给剩下所有人。成员表我维护成一个HashMapString, ConnectedThreadkey 是设备 MAC 地址value 是线程引用。注意建链完成时服务端就能拿到客户端的 MAC 地址但MAC 重启后会变Android 10 之后系统生成的随机 MAC 会变化所以更可靠的标识是用BluetoothDevice.getName() 首次建链时间拼接的成员 ID。另外Android 10 之后 APP 默认读不到真实 MAC拿到的地址形如02:00:00:00:00:00这种随机地址用 MAC 做逻辑 key 在调试和后续追踪上都会很别扭。广播消息时要遍历成员表每个线程写一次。这其实就是前面提到的串行消费队列的场景如果 A 设备写入阻塞B 设备的消息不能等 A 完成才发否则用户会感知到明显的卡顿。所以服务端我一般这样组织每个 ConnectedThread 内部不直接处理“广播”这个动作而是把要广播的消息交给一个共享的HandlerThreadHandler 持有所有 socket 引用逐条写出。4.3 聊天室 UI 与状态刷新别在主线程碰蓝牙UI 部分核心是RecyclerView的聊天列表和底部输入栏这个大家都熟但有两个容易忽略的状态需要同步好第一连接状态。客户端连接服务端失败、中途掉线、退出聊天室三种状态必须在 UI 上有明确区分。建议用enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED }在onConnected()和connectionLost()回调里通过Handler.post()更新一个 TextView 和按钮状态。连接中的状态必须禁止发送按钮不然用户在 connect 没完成时连点发送ConnectedThread还没建立你把消息塞给一个空引用直接崩。第二聊天列表自动滚动。新消息到达时adapter.notifyItemInserted()之后要layoutManager.scrollToPosition(itemCount - 1)滚到底部。但这个滚动动作要在handler.post()里做因为notifyItemInserted布局是异步的直接调用 scrollToPosition 可能定位到旧位置。聊天室人多时消息一多列表很长建议在onBindViewHolder里判断 message 的ts字段如果与上一条间隔超过 30 秒就插一个时间分隔条 item type否则气泡全挤在一起看不出时间线。蓝牙数据传输的特点决定 UI 不需要做“发送中”状态因为writeUTF是阻塞的它返回就说明数据已经进了蓝牙协议栈的发送缓冲区并不代表对端已经收到。现实是蓝牙链路质量好的时候延迟在几十毫秒量级你感知不到但链路拥堵时比如八台设备同时发消息服务端 HandlerThread 来不及写那你的writeUTF会卡住几百毫秒UI 线程不卡但消息发送的“回执”心理预期会破灭。因此对用户来说发送按钮一按消息进入列表就是“已发送”服务端如果收到会在广播后回一条 ack收到 ack 再改为“已送达”这个语义就清楚了。4.4 蓝牙测距RSSI 指纹可以做但别拿它当真值热词里出现了“蓝牙测距”聊天室场景里这也算一个合理的增值功能服务端根据每个客户端 socket 对端设备的 RSSI 值估算出一个粗略的“距离”。经典蓝牙的 RSSI 是个短整型单位 dBm波动比 BLE 还大因为经典蓝牙的工作频率和 PHY 调制方式决定了它更容易受多径衰落影响。公式一般用路径损耗模型// 距离估算A 为 1 米处校准 RSSIn 为环境衰减指数 public double estimateDistance(int rssi, double A, double n) { return Math.pow(10, (A - rssi) / (10 * n)); }参数取值上A 一般是 -55 到 -65实验室环境取 -55办公室人多的环境取 -65 更准n 取 2 是自由空间室内取 2.5 到 3。从实践看这个公式算出的距离只能定性用比如“靠近了”“走远了”不能拿来做精确到厘米的定位。聊天室页面里可以放一个信号强度指示条给用户一点“谁离我近”的感知而不是展示距离数字。因为真实距离数字误差随便就 50% 以上展示出来反而让用户觉得这个 APP 不专业。另一种做法是把 RSSI 指纹直接交给服务端做成员排序不用公式而是维护一张“上次广播后各成员 RSSI 变化表”看谁从 -50 掉到 -70就认为他走远了给 UI 一个offline预判。这个方案容错率更高不需要标定环境参数适合看完这篇文章想快速跑通的读者。5. 蓝牙连接失败排错HC-05 模块连不上与手机搜不到设备5.1 真机 vs HC-05 模块的区别同样是 SPP 但行为完全不同标题虽然写的是 Android 聊天室但热词里反复出现“HC05蓝牙模块连接不上”说明有不少人是在做 Android 与 HC-05 的串口透传。HC-05 的蓝牙 3.0 模块支持 SPP 协议兼容 HC-06 的从机模式扮演的角色是服务端也就是说你得让Android 端作为客户端主动去连接它而不是反过来用 Android 去 accept() 等它。HC-05 连接不上最常见的三个原因第一模块进入 AT 模式按住模块上的按键上电指示灯慢闪此时模块不回应 SPP 连接Android 端显示配对成功但连接失败第二UUID 必须用 SPP 默认的00001101-0000-1000-8000-00805F9B34FBHC-05 不会响应自定义 UUID 的服务搜索这一点和手机之间的连接不一样——手机对任意 UUID 都能协商HC-05 就会报 Service discovery failed第三HC-05 默认波特率是 9600配对密码通常是 1234如果你用的是某些教程里改过的固件密码规则可能已经变化。5.2 Android 搜不到设备的清单式排查搜不到设备是这类型项目里最耗时的环节按以下顺序排查比瞎试快得多现象排查项扫描列表为空检查ACCESS_FINE_LOCATION是否真的被授予而非只在 Manifest 里声明能搜到但连接失败确认目标设备没有在其他 APP 里被占用比如蓝牙设置页已配对但未断开手机之间连不上确认两端 UUID 完全一致包括大小写和后缀字母协议栈异常重启蓝牙或重启手机RFCOMM 通道有时会被系统卡死还有一个厂商差异小米和红米部分机型在蓝牙设置页里默认关闭了“开放检测”导致其他设备扫描不到它。这不是代码问题但聊天室里有一个小米设备当服务端其他品牌就会连不上——让用户去系统设置里把“检测到设备”开关打开这个坑能省你两个小时。5.3 Socket 掉线后的自动重连策略聊天室的中断体验往往比连接失败更影响评价。蓝牙链路受干扰、设备锁屏、距离过远都会造成 socket 意外断开。我一般在connectionLost()回调里做“指数退避重连”而不是立刻重连。理由很简单蓝牙断开一般伴随射频环境恶化立刻重连大概率还是失败而且频繁创建 socket 会耗尽系统资源。private int retryCount 0; private void handleConnectionLost() { if (retryCount 3) { stopChat(); return; } long delay (long) Math.min(1000 * Math.pow(2, retryCount), 10000); retryCount; new Handler().postDelayed(this::connect, delay); }这里注意retryCount在重连成功后要清零。重连会走一遍完整的 connect 流程因此 UI 要处在“连接中”状态,不能让用户以为已经连上却发不出消息。另外如果服务端也多线程 accept() 循环客户端重连时不需要处理旧 socket 的释放——系统会在 RFCOMM 层等一段时间自动清理但最好在客户端connect()前主动close()掉旧 socket避免连接被拒绝。5.4 华为手机蓝牙传输电脑失败Media Transfer 与 SPP 是两码事热词里有一条“华为手机蓝牙传输电脑失败”这个和聊天室项目其实没关系但为了排除混淆值得在这里说明华为手机用蓝牙向电脑传文件走的是 OPP / FTP / PBAP 协议栈而聊天室走的是 SPP / RFCOMM两者在 Android 底层是不同的 profile。所以如果你的电脑收不到华为手机传的文件那是系统蓝牙文件传输协议的问题不是聊天室代码的问题。反过来你写好聊天室客户端去连 JWBL 那类蓝牙音箱它走的是 A2DP你的 APP 也没法和它通信。拿 SPP 场景的代码去验证 A2DP 设备得到的结果必然是失败——先分清蓝牙 profile 再去排查可以省去大量无效尝试。6. 应用进阶把聊天室封装成局域网消息总线扩展消息类型聊到这一步其实你手里的蓝牙聊天室已经不只是聊天了——它本质上是一个在 RFCOMM 链路上的消息分发总线。你可以把type字段扩展成command、file、location服务端只需要在路由分发时多几个分支。我在自己的项目里做过一个控制协商把服务端变成“裁判”客户端发command: relay服务端就在所有客户端之间转发键鼠事件几个人在屋里直接用手机遥控一台没有网卡的工控机。这类玩法其实是蓝牙技术路线的应用之一把经典蓝牙 SPP 当本地通信底座避开了弱网环境下 MQTT 断线重连的折腾。做映射前建议在 JSON 消息里增加msgId字段UUID 字符串服务端收到消息先查重再转发。蓝牙链路不是 IP 网络理论上不存在重复投递问题但重连期间用户可能手动重发msgId能帮你过滤掉客户端自己产生的重复消息。还有一个保底策略服务端在内存里维护一个环形缓冲区存放最近 100 条消息新客户端上线时一次性推给它作为历史消息type: history。这功能实现起来就十几行代码但对“聊天室”体验的提升是质的后来的人能看到前面说了什么否则空降用户完全不知道大家在聊什么。验证你的聊天室是否健壮有一个方法值得做打开 Android Studio 的 Profile 工具切到 Network 面板跑 30 分钟八台设备对聊看服务端的内存曲线是否稳定。蓝牙数据量不大真正的内存压力来自消息对象反复创建——readUTF()每读一条就 new 一个 String100 条/秒在结构上没有任何压力但如果你在ConnectedThread里把每条消息又 post 到 HandlerHandler 的 Message 也有短暂积压。优化手段是复用Message对象或者改用sendToTarget()的obtainMessage这个细节在极限消息量下能明显降低 GC 频率。最后收个尾如果你只是把这个 APP 当作课程设计交差写成这样已经超出验收要求了如果你想把蓝牙聊天的思路迁移到更广的应用里不要继续往聊天室加花活而是把服务端与客户端交互方式拿过去——用标准 JSON 定义上行和下行用阻塞线程接收消息再用 Handler 把消息安全地抛到 UI 层这套骨架在任何点对点通信项目里都能复用。本文还有配套的精品资源点击获取