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。简单、直接、不容易出错。当然你也可以用LiveDataFlow,但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 整体架构图

说了这么多,咱们用一张图把整个聊天室的架构理清楚:

蓝牙聊天室整体架构 设备A(客户端/服务端) UI层(Activity + RecyclerView) 显示消息列表、输入框、连接状态 业务层(Handler + 线程管理) 处理消息收发、连接状态回调 连接层(BluetoothSocket) RFCOMM连接、InputStream/OutputStream 蓝牙硬件(Radio) 蓝牙连接 RFCOMM协议 设备B(服务端/客户端) UI层(Activity + RecyclerView) 显示消息列表、输入框、连接状态 业务层(Handler + 线程管理) 处理消息收发、连接状态回调 连接层(BluetoothSocket) RFCOMM连接、InputStream/OutputStream 蓝牙硬件(Radio)

这张图把整个聊天室分成了四层:UI层负责展示和交互,业务层处理消息和线程,连接层管理Socket和流,最下面是蓝牙硬件。两层之间通过Handler传递数据,设备之间通过RFCOMM协议通信。

5.7 完整流程总结

最后,咱们把整个聊天室的完整流程走一遍:

  1. 初始化:获取BluetoothAdapter,检查蓝牙是否开启
  2. 设备发现:扫描附近设备,或者从已配对列表中选择
  3. 建立连接:客户端创建Socket并connect(),服务端accept()等待
  4. 启动读写线程:连接成功后,分别启动读线程和写线程
  5. 发送消息:用户输入消息,通过OutputStream.write()发送
  6. 接收消息:读线程循环读取InputStream,通过Handler更新UI
  7. 断开连接:关闭Socket,释放资源

嗯,到这里,一个完整的蓝牙聊天室就搭建好了。代码量其实不大,核心逻辑也就几百行。但要做好,细节很重要——线程管理、异常处理、UI交互,每一个环节都值得认真对待。

我在项目中做过好几个蓝牙相关的App,从最初的简单数据传输,到后来的文件分享、语音通话。每次都会遇到新的问题,但核心的Socket操作和线程模型,始终是那套东西。说白了,只要把今天讲的这些搞明白,蓝牙开发就算入门了。

最后说一句:蓝牙开发最怕的就是"能跑就行"。很多问题在开发机上测不出来,一上真机就暴露。所以一定要多测试不同品牌、不同Android版本的设备。我吃过这个亏,希望你不用再吃一遍。


公众号:蓝海资料掘金营,微信deep3321