5、经典蓝牙实战-聊天室:创建BluetoothSocket、建立RFCOMM连接、数据读写(InputStream/OutputStream)、多线程处理、UI交互设计
好,咱们终于到了最激动人心的环节——写一个真正的蓝牙聊天室。前面几章我们把蓝牙的底层原理、扫描、配对都过了一遍,现在要把这些零散的知识点串起来,做成一个能跑、能聊、能用的App。
说实话,我第一次做蓝牙聊天室的时候,踩了不少坑。尤其是那个BluetoothSocket,稍不注意就给你抛个IOException,而且还不告诉你为什么。嗯,今天咱们就把这些坑一个一个填平。
本章核心目标:实现两台Android设备通过经典蓝牙RFCOMM协议进行双向文本通信。说白了,就是让两个手机能互相发消息,跟微信聊天差不多,只不过咱们用的是蓝牙。
5.1 创建BluetoothSocket——连接的第一步
要建立连接,首先得有个BluetoothSocket。这个对象就像一根电话线,两端各拿一头,才能通话。
创建Socket有两种方式:
- 作为客户端:调用
device.createRfcommSocketToServiceRecord(uuid) - 作为服务端:通过
BluetoothAdapter.listenUsingRfcommWithServiceRecord(name, uuid)监听,然后accept()获取
这里有个关键点——UUID。RFCOMM连接必须使用相同的UUID,否则两端对不上号。我一般用这个:
private static final UUID MY_UUID =
UUID.fromString("00001101-0000-1000-8000-00805F9B34FB");
这个UUID是蓝牙串口服务(SPP)的默认值,兼容性最好。你在网上看到的很多蓝牙聊天Demo用的都是它。
小技巧:创建Socket之前,记得先调用BluetoothAdapter.cancelDiscovery()停止扫描。我在项目中遇到过,扫描过程中创建Socket会莫名其妙变慢,甚至连接失败。官方文档也建议这么做。
5.2 建立RFCOMM连接——握手与阻塞
Socket创建好了,接下来就是建立连接。客户端调用socket.connect(),服务端调用serverSocket.accept()。
注意,这两个方法都是阻塞的。什么意思?就是调用之后,线程会卡在那里,直到连接成功或超时。所以绝对不能放在UI线程里跑,否则你的App界面会直接卡死。
我曾经犯过这个错——直接在Activity的onCreate里调connect(),结果App启动后黑屏了好几秒,然后ANR了。嗯,从那以后我再也不敢在主线程里做任何网络或蓝牙操作了。
正确的做法是:
// 客户端连接
new Thread(new Runnable() {
@Override
public void run() {
try {
BluetoothSocket socket = device.createRfcommSocketToServiceRecord(MY_UUID);
socket.connect();
// 连接成功,开始读写
} catch (IOException e) {
e.printStackTrace();
// 连接失败,处理异常
}
}
}).start();
服务端这边类似:
// 服务端监听
BluetoothServerSocket serverSocket =
bluetoothAdapter.listenUsingRfcommWithServiceRecord("ChatApp", MY_UUID);
BluetoothSocket socket = serverSocket.accept(); // 阻塞,直到有客户端连接
警告:accept()会一直阻塞,直到有连接进来。如果你想让服务端能接受多个客户端连接,需要在一个循环里反复调用accept(),每次拿到新的Socket就开一个新线程处理。不过咱们这个聊天室是一对一的,一次只处理一个连接就够了。
5.3 数据读写——InputStream和OutputStream
连接建立之后,真正的数据传输就开始了。蓝牙的数据读写,本质上就是操作两个流:
socket.getInputStream()—— 读取对方发来的数据socket.getOutputStream()—— 向对方发送数据
发送数据比较简单:
public void sendMessage(String message) {
try {
OutputStream outputStream = socket.getOutputStream();
byte[] bytes = message.getBytes("UTF-8");
outputStream.write(bytes);
outputStream.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
读取数据稍微麻烦一点,因为你需要持续监听,而且不知道对方什么时候发消息。我一般用一个独立的线程循环读取:
private class ReadThread extends Thread {
@Override
public void run() {
byte[] buffer = new byte[1024];
int bytes;
InputStream inputStream = null;
try {
inputStream = socket.getInputStream();
while (true) {
// 阻塞读取,直到有数据
bytes = inputStream.read(buffer);
if (bytes > 0) {
String message = new String(buffer, 0, bytes, "UTF-8");
// 通过Handler或LiveData发送到UI线程
handler.obtainMessage(MESSAGE_READ, message).sendToTarget();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
这里有个细节——inputStream.read()也是阻塞的。如果没有数据,它会一直等。所以不用担心CPU空转的问题。
避坑指南:我曾经遇到过一个问题——发送中文消息时,接收方显示乱码。排查了半天,发现是编码问题。发送方用了getBytes()(默认平台编码),接收方用了new String(bytes, "GBK")。两边编码不一致,自然乱码。解决方案就是统一用"UTF-8",两边保持一致。
5.4 多线程处理——别让UI线程卡住
蓝牙操作涉及三个关键线程:
| 线程 | 职责 | 注意事项 |
|---|---|---|
| UI线程(主线程) | 更新界面、显示消息 | 不能做任何阻塞操作 |
| 连接线程 | 创建Socket、建立连接 | 连接成功后启动读写线程 |
| 读写线程 | 持续读取输入流、写入输出流 | 读取是循环阻塞的,需要处理中断 |
线程间通信,我习惯用Handler。简单、直接、不容易出错。当然你也可以用LiveData或Flow,但Handler在蓝牙这种场景下更轻量。
// 在Activity中定义Handler
private Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_READ:
String message = (String) msg.obj;
// 将消息添加到聊天列表
chatAdapter.addMessage(message);
break;
case MESSAGE_WRITE:
// 显示自己发送的消息
break;
case MESSAGE_CONNECTED:
// 更新连接状态
break;
}
}
};
你想想看,如果没有多线程,所有操作都挤在主线程里,那体验得多糟糕。用户点一下"发送"按钮,界面卡住半秒钟,谁受得了?
5.5 UI交互设计——让聊天体验更自然
聊天的UI,说白了就是两个核心元素:消息列表和输入框。但要做好,还是有一些门道的。
我设计的聊天界面通常包含:
- 顶部状态栏:显示连接状态(已连接/未连接/正在连接)
- 中间消息列表:使用
RecyclerView,每条消息显示发送者、内容、时间 - 底部输入区:输入框 + 发送按钮
消息列表的适配器,我一般用ListAdapter配合DiffUtil,这样更新消息时不会闪烁:
public class MessageAdapter extends ListAdapter<Message, MessageAdapter.ViewHolder> {
public MessageAdapter() {
super(new DiffUtil.ItemCallback<Message>() {
@Override
public boolean areItemsTheSame(Message oldItem, Message newItem) {
return oldItem.getId() == newItem.getId();
}
@Override
public boolean areContentsTheSame(Message oldItem, Message newItem) {
return oldItem.getContent().equals(newItem.getContent());
}
});
}
// ... ViewHolder绑定代码
}
还有一个容易被忽略的点——键盘弹出时输入框不被遮挡。在AndroidManifest.xml中给Activity设置:
android:windowSoftInputMode="adjustResize"
这样键盘弹出时,布局会自动上移,输入框始终可见。
个人经验:消息列表最好默认滚动到底部,显示最新消息。可以在添加消息后调用recyclerView.smoothScrollToPosition(adapter.getItemCount() - 1)。但如果用户正在往上翻看历史消息,突然给他滚到底部,体验很不好。我一般加一个判断——如果用户当前在底部,才自动滚动;否则只显示一个"新消息"的提示按钮。
5.6 整体架构图
说了这么多,咱们用一张图把整个聊天室的架构理清楚:
这张图把整个聊天室分成了四层:UI层负责展示和交互,业务层处理消息和线程,连接层管理Socket和流,最下面是蓝牙硬件。两层之间通过Handler传递数据,设备之间通过RFCOMM协议通信。
5.7 完整流程总结
最后,咱们把整个聊天室的完整流程走一遍:
- 初始化:获取
BluetoothAdapter,检查蓝牙是否开启 - 设备发现:扫描附近设备,或者从已配对列表中选择
- 建立连接:客户端创建Socket并
connect(),服务端accept()等待 - 启动读写线程:连接成功后,分别启动读线程和写线程
- 发送消息:用户输入消息,通过
OutputStream.write()发送 - 接收消息:读线程循环读取
InputStream,通过Handler更新UI - 断开连接:关闭Socket,释放资源
嗯,到这里,一个完整的蓝牙聊天室就搭建好了。代码量其实不大,核心逻辑也就几百行。但要做好,细节很重要——线程管理、异常处理、UI交互,每一个环节都值得认真对待。
我在项目中做过好几个蓝牙相关的App,从最初的简单数据传输,到后来的文件分享、语音通话。每次都会遇到新的问题,但核心的Socket操作和线程模型,始终是那套东西。说白了,只要把今天讲的这些搞明白,蓝牙开发就算入门了。
最后说一句:蓝牙开发最怕的就是"能跑就行"。很多问题在开发机上测不出来,一上真机就暴露。所以一定要多测试不同品牌、不同Android版本的设备。我吃过这个亏,希望你不用再吃一遍。
公众号:蓝海资料掘金营,微信deep3321