Android蓝牙聊天源码剖析:从SPP通信到权限适配
2026/9/15 20:14:45 网站建设 项目流程

简介:面向Android开发者的蓝牙聊天应用源码项目,围绕蓝牙适配器、蓝牙套接字与广播接收者等核心类,完整演示权限声明、设备扫描与配对、建立连接、双向数据流传输和断开释放等关键流程,可帮助读者解决从零实现蓝牙即时通信时常见的适配与连接问题。压缩包共52个文件,大小约4.14MB,主要包含Java源码、XML布局及清单文件、class字节码、APK安装包、界面预览图与说明文档,既有可直接参考的代码工程,也有可安装运行的成品APK,便于对照学习和二次开发。目前已有191人学习下载。借助源码注释、说明文档和界面截图,读者能快速理解蓝牙聊天的整体架构与设备发现、状态监听的实现思路,进而扩展聊天界面、消息协议或异常处理,实用性较强。

1. 一个 zip 里的蓝牙聊天应用:它解决的问题和源码的适用场景

「android 蓝牙聊天的应用源码.zip」,这个名字看起来像是早年流传下来的课设包,但它背后是一整套非常完整的蓝牙通信范式:经典蓝牙 SPP 协议、设备发现、配对、Socket 长连接、读线程与主线程的通信。在 IM 框架和云信令普及之前,这种应用几乎是所有人接触 Android 网络通信的第一个完整项目,今天搜到它的人,要么是学生找项目参考,要么是想把一个旧工程跑在现代 Android Studio 上。这个 zip 能解决的问题很集中:在没有服务器的情况下,让两台 Android 设备直接通过蓝牙交换文本消息。它适合谁,一句话讲,适合想搞清楚 Android 经典蓝牙 API 到底怎么串联的人;这套代码里值得看的不是界面,而是从BluetoothAdapterBluetoothSocket的完整链路。本文会把这个链路拆开,从 SPP 的原理、Android 12 权限分水岭,到具体代码实现、参数调优、常见运行陷阱和工程改造方法,一次讲完。

2. 蓝牙聊天的技术底座:SPP 协议、UUID 与 Android 蓝牙权限的新旧分水岭

2.1 经典蓝牙与 BLE:聊天应用该选哪条路

Android 的蓝牙能力分成两条线:经典蓝牙(BR/EDR)和低功耗蓝牙(BLE/Bluetooth Low Energy)。蓝牙聊天这种需要持续、双向、稳定传输文本的场景,常见做法是走经典蓝牙的 SPP(Serial Port Profile),也就是串口模拟协议。SPP 的设计目标就是让两个设备像通过串口线连接一样,应用层拿到的是一条字节流,读写两端完全对称。BLE 则完全不同,它是围绕 GATT 服务、特征值和通知机制设计的,面向的是心率计、传感器这类小数据量周期性上报场景,kadar 双向聊天要自己定义服务端和客户端的 characteristic,复杂度高出一个量级,而且每包数据长度受 MTU 限制。所以,zip 里的源码只要标注的是「蓝牙聊天」,几乎可以断定:核心实现是BluetoothSocket+SPP通道,不是 BLE。这也是一个鉴别源码质量的信号——如果一个蓝牙聊天项目里出现大量 GATT 回调,那它大概率是拿 BLE 的心率 demo 强改的,不该作为首选参考。

2.2 UUID 在蓝牙聊天里到底干什么

UUID 是理解这套代码最关键的一个参数。在两台手机建立 SPP 连接前,服务端要调用listenUsingRfcommWithServiceRecord(String name, UUID uuid)注册一个服务,客户端用同一个 UUID 去发起createRfcommSocketToServiceRecord(uuid)。这个 UUID 实质上相当于服务的识别码,或者说是一个「暗号」,两端暗号对得上,才能完成连接。Android 官方示例里通常使用00001101-0000-1000-8000-00805F9B34FB,这个值是 Bluetooth Base UUID 加上 SPP 服务的 16 位短 UUID0x1101拼出来的,代表「串口服务」。旧源码包里最常见的问题就是把 UUID 写死成一个UUID.randomUUID()生成的字符串,然后从网上抄来抄去,导致服务端和客户端各持一个不同的 UUID,永远配对不上。UUID 不需要加密,不需要保密,它是连接契约的一部分,不是安全凭证。

2.3 Android 6 到 Android 12:权限模型的三次变化

这是把旧源码跑在新手机上最痛的环节。蓝牙聊天应用涉及三个权限维度:蓝牙开关、蓝牙扫描、蓝牙连接与数据收发。在 Android 6(API 23)之前,只需要在 Manifest 里声明BLUETOOTHBLUETOOTH_ADMIN两个权限,安装时自动授予;Android 6 到 Android 11 之间,情况也还算温和,蓝牙相关权限依然是安装时授予,唯一需要注意的是设备扫描需要位置权限,因为蓝牙扫描结果可以反推用户位置;真正的分水岭是 Android 12(API 31)引入的新权限模型。

下表整理了权限的演进关系,这是判断一份源码包「需要改多少」的核心依据:

Android 版本需要的 Manifest 权限运行时动态申请要求说明
Android 6 ~ 11BLUETOOTHBLUETOOTH_ADMIN、定位权限位置权限(ACCESS_FINE_LOCATION)需动态申请扫描设备视为位置信息收集
Android 12+BLUETOOTH_SCANBLUETOOTH_CONNECTBLUETOOTH_ADVERTISE这三个都需要动态申请,并且目标 SDK 为 31+ 时强制老的BLUETOOTH_ADMIN在新系统上直接失效,且 manifest 里同时声明新旧权限是允许的,运行时按新权限模型走
Android 13+同上不变,且定位权限不再强制要求前提是应用声明了neverForLocation,否则扫描仍要位置权限

另外,Android 12 开始,BLUETOOTH_SCAN的授权弹窗里同时提供「仅限本次」和「每次询问」两种选项,用户在授予「仅限本次」之后重启应用,权限会被回收。这在蓝牙聊天这种需要长连接的应用里很容易造成「第一次聊天正常,第二次打开就闪退」的假象。我一般会在onResume里做权限复核,而不是只依赖启动时的单次申请。

3. 搜索、配对与 socket 通信:最小可用的蓝牙聊天流程

3.1 一个可运行的代码骨架

把 zip 里那些贴了各种水印的类剥掉,剩下真正不可替代的骨架就是这个样子。下面用 Java 写(因为旧源码包几乎都是 Java),核心流程是四个步骤:检查蓝牙并开启、扫描设备、发起连接、维护读线程。注意,这里刻意不用协程和回调地狱,而是沿用 Android 经典蓝牙 demo 的线程模型,方便对照老代码。

// BluetoothChatService.java —— 核心服务类骨架 public class BluetoothChatService { private static final String APP_NAME = "BT_CHAT_DEMO"; // SPP 标准 UUID,服务端和客户端必须一致 private static final UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); private final BluetoothAdapter mAdapter; private AcceptThread mAcceptThread; private ConnectThread mConnectThread; private ConnectedThread mConnectedThread; private int mState; public BluetoothChatService() { mAdapter = BluetoothAdapter.getDefaultAdapter(); mState = STATE_NONE; } // 服务端:开始监听,等待远端设备连接 public synchronized void start() { if (mConnectThread != null) { mConnectThread.cancel(); mConnectThread = null; } if (mAcceptThread == null) { mAcceptThread = new AcceptThread(); mAcceptThread.start(); } } // 客户端:向指定设备发起连接 public synchronized void connect(BluetoothDevice device) { mConnectThread = new ConnectThread(device); mConnectThread.start(); } }

这段代码里的AcceptThread负责在服务端监听,ConnectThread负责在客户端主动连接,ConnectedThread负责连接建立后的数据传输。三个线程是经典蓝牙连接的三个状态机,任何一份完整的蓝牙聊天源码里都能找到对应的类,只是命名可能不同(有的叫ServerThread/ClientThread)。参数说明:SPP_UUID必须是两端一致的标准 UUID,APP_NAME是注册到蓝牙协议栈里的服务名,会被远端设备在配对弹窗里看到,不建议写中文或特殊字符;mState用于在 UI 层维护当前连接状态,模式是简单的整型常量,不涉及数据库。

3.2 AcceptThread:把两台设备变成服务端与客户端

SPP 连接不像 Wi-Fi 那样有中间路由器,它需要明确谁在等、谁在找。AcceptThread就是「等」的一方。

private class AcceptThread extends Thread { private final BluetoothServerSocket mmServerSocket; public AcceptThread() { BluetoothServerSocket tmp = null; try { // 注册 SPP 服务,等待连接 tmp = mAdapter.listenUsingRfcommWithServiceRecord( APP_NAME, SPP_UUID); } catch (IOException e) { // 蓝牙关闭或 service record 注册失败 } mmServerSocket = tmp; } @Override public void run() { BluetoothSocket socket = null; while (mState != STATE_CONNECTED) { try { // 阻塞直到有设备接入 socket = mmServerSocket.accept(); } catch (IOException e) { break; } if (socket != null) { // 拿到连接后统一交给上层管理 connected(socket, socket.getRemoteDevice()); } } } public void cancel() { try { mmServerSocket.close(); } catch (IOException e) { } } }

listenUsingRfcommWithServiceRecord这个方法的执行路径是:先把应用名字注册到蓝牙协议栈,然后创建一个 RFCOMM 通道,等待对端设备的createRfcommSocketToServiceRecord请求。accept()是一个阻塞调用,一旦返回,说明对端设备已经用相同 UUID 发起过握手,两条设备之间的 SPP 通道已经建立,后面传输数据就将直接读写 socket。

3.3 ConnectedThread:读写数据的核心循环

一旦accept成功,或者客户端 connect 成功,都会进入同一个ConnectedThread,这个线程做的事情只有一件:循环读数据,然后通过 Handler 把字节流转成消息发到 UI 层。

private class ConnectedThread extends Thread { private final BluetoothSocket mmSocket; private final InputStream mmInStream; private final OutputStream mmOutStream; private final byte[] mmBuffer = new byte[1024]; public ConnectedThread(BluetoothSocket socket) { mmSocket = socket; InputStream tmpIn = null; OutputStream tmpOut = null; try { tmpIn = socket.getInputStream(); tmpOut = socket.getOutputStream(); } catch (IOException e) { } mmInStream = tmpIn; mmOutStream = tmpOut; } // 发送文本消息 public void write(byte[] bytes) { try { mmOutStream.write(bytes); } catch (IOException e) { } } @Override public void run() { int numBytes; while (true) { try { numBytes = mmInStream.read(mmBuffer); // 把收到的字节转成字符串,再通过 Handler 发到 UI String readMessage = new String(mmBuffer, 0, numBytes, "UTF-8"); // handler.obtainMessage(MSG_READ, numBytes, -1, readMessage).sendToTarget(); } catch (IOException e) { // 连接断开或流异常,退出循环 break; } } } public void cancel() { try { mmSocket.close(); } catch (IOException e) { } } }

这段代码里有三个设计值得注意。第一,read是阻塞式的,读不到数据时线程挂起,不占 CPU;第二,mmBuffer固定为 1024 字节,一次读操作最多读入 1024 字节,长消息会被拆成多次read返回,需要在 UI 层做粘包处理,这是聊天应用最容易忽视的一个问题;第三,发送端write没有加锁,如果 UI 线程连续快速发送多条消息,OutputStream.write内部虽然会保证原子性,但多条消息交错到达对端时可能拼在一起,接收方按一次read一段来显示就会出错。

4. 参数调整、线程边界与三处最容易翻车的运行细节

4.1 四个必调的运行参数

把源码包跑起来之后,真正决定聊天体验的是下面这些参数,它们在旧的 zip 里往往被写成常量或者干脆写死:

参数常见旧值推荐调整方向影响
mmBuffer大小1024改到 4096;中文用 UTF-8 时一个汉字 3 字节,1024 只能装约 340 字单次能收到的最大消息长度
每次write的粘包间隔在每条消息结尾追加\n或使用独立消息头防消息粘连
Socket 连接超时socket.connect()要设置 10 秒左右的超时避免搜索到不可达设备时一直阻塞
发送队列容量使用HandlerThread+ 队列串行发送避免连点时消息丢包

4.2 Android 12 下的权限申请最小代码

把 zip 里的鉴权逻辑替换成下面这段,能在 API 31 以上的设备上把权限问题一次解决。这段代码把新旧权限统一处理,核心思路是:先判断版本,再申请不同的权限集合,然后统一回调。

private static final int REQUEST_BLUETOOTH_PERMISSION = 100; private void requestBluetoothPermissions() { // Android 12 (API 31) 及以上的蓝牙权限集合 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE }, REQUEST_BLUETOOTH_PERMISSION); return; } // Android 11 及以下:需要位置权限来扫描设备 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_BLUETOOTH_PERMISSION); } }

这里有一个细节很容易被忽略:在 Android 12 上,BLUETOOTH_SCAN权限如果搭配neverForLocation声明(在 manifest 里给权限加android:usesPermissionFlags="neverForLocation"),系统会在权限对话框里显示「位置信息 » 不会被使用」,并且允许用户授予精确位置之外的权限。如果不加这个标记,扫描蓝牙设备依然需要位置权限配合,这在老代码里不会体现,属于迁移到新系统时必须面对的差异。

在 Android 12 上,isBluetoothEnabled的检查也要放在权限申请之后,否则getBluetoothAdapter().isEnabled()会因为MissingPermissionException直接崩溃。旧源码普遍没有这个习惯,因为老的权限模型下isEnabled()不需要任何权限就能调用。

4.3 蓝牙聊天与事件分发的关系:为什么点击「发送」之后界面卡顿

搜索热词里有一个「android 的事件分发机制」,它跟蓝牙聊天表面上不相关,但实际运行这个 zip 时,最典型的一个性能问题就是事件处理做重了。旧源码里常见的写法是:点击发送按钮后,在 UI 线程直接调用ConnectedThread.write()。如果对端设备不在连接状态,write会抛IOException,异常处理里如果还弹 Toast、改 UI,就会在主线程做了一系列文件流操作,表现为点击发送按钮后界面卡顿几百毫秒。正确的做法是让write方法从不直接暴露给 UI 线程,而是通过 Handler 把发送请求投递到独立的HandlerThread里,UI 线程只负责把字符串丢进消息池,整个链路里不产生任何阻塞。

HandlerThread sendThread = new HandlerThread("BluetoothSendThread"); sendThread.start(); Handler sendHandler = new Handler(sendThread.getLooper()); // UI 线程中调用(伪代码) btnSend.setOnClickListener(v -> { String msg = editText.getText().toString(); sendHandler.post(() -> { connectedThread.write(msg.getBytes(StandardCharsets.UTF_8)); }); });

这样处理之后,即使发送的消息很大、对端读取很慢,write阻塞的也只是sendThread,UI 线程不受影响。要注意的是ConnectedThread.write之前必须判空,因为连接断开后mmOutStream是 null,直接调会触发 NPE,这是旧 zip 里仅次于权限崩溃的第二大崩溃点。

4.4 蓝牙聊天 vs BLE 心率监测:源码选择的边界

在搜索热词里看到「android ble开发实战 心率监测app」,把它跟蓝牙聊天源码放一起比较,边界一下子就清楚了。心率监测 app 的技术栈里不可能出现listenUsingRfcommWithServiceRecord,它用的是BluetoothGattonCharacteristicReadonCharacteristicChanged这一整套 BLE API,而且它在应用层感知的是「数据包」,不是「字节流」。如果看到一份源码包含了这按钮说是蓝牙聊天项目,却写着BluetoothGattCallback,那基本可以断定是文档没改,代码是照抄心率 demo 的。反过来,如果想真正做一个蓝牙聊天项目,也不要试图把 SPP 源码改成 BLE——那等于推翻整套架构,还不如直接找一份基于 GATT 的 IM demo。

5. 源码包验证与工程迁移:把 zip 从「能读」改造成「能跑」

5.1 解压后先看三个文件,再决定要不要导入 Android Studio

很多用户下完 zip 直接双击打开,发现里面缺gradle/wrapper/目录,以为文件损坏。其实判定一份蓝牙聊天源码是否值得导入,只需要先看三个文件:AndroidManifest.xml里的权限声明、build.gradle里的minSdkVersiontargetSdkVersion、还有一个核心的 Service 类(名字通常叫BluetoothChatServiceChatController)。看这三个文件的意义在于:源码能不能在现代环境跑起来,基本在打开 Android Studio 之前就能判断出结果。

如果targetSdkVersion小于 31,基本可以确定别指望编译出来的 app 在 Android 12 以上稳定运行,除非补上动态权限申请;如果minSdkVersion是 15 之类很老的级别,那说明代码里可能有大量过时的 API 调用习惯,例如getResources().getDrawable()不传 theme,或者Activity里不处理onRequestPermissionsResult,这些在旧手机上没问题,在现代设备上会崩。另外,打开 zip 之前先确认它位于纯英文路径下,Windows 下如果目录包含中文或空格,gradlew.bat经常会无法执行,这是zip这种分发形式特有的坑,和代码本身没关系。

5.2 5.2 模拟器测试的边界:为什么建议直接用两台真机

把源码迁移到 Android Studio 后,下一步是运行验证。这里有一个关键建议:直接用两台支持蓝牙的真机,不要用模拟器。模拟器的蓝牙支持到今天依然不完整,多数镜像根本不提供BluetoothAdapter,而少部分提供的是虚拟蓝牙芯片,无法模拟真实的跨设备发现和配对。用一个真机做服务端、一个真机做客户端,是最快的验证路径。两机距离保持在 5 米范围内,避免墙体遮挡——SPP 不是 Wi-Fi,穿墙能力很弱。验证的核心指标就两个:startActivityForResult(ACTION_REQUEST_ENABLE)开启蓝牙时是否正常、两端点击扫描能否互相发现。如果看到蓝牙图标出现了但搜不到对方,优先检查 manifest 里有没有声明BLUETOOTHBLUETOOTH_ADMIN两个经典权限,因为新版 Android Studio 创建项目时,默认的 manifest 模板不会自动带蓝牙权限。

5.3 借 logcat 验证连接是否真正建立

蓝牙聊天应用最麻烦的一点是:连接成功与否在界面上经常不显眼。这时要用 Android Studio 的 Logcat 过滤器来确认链路状态。在 Logcat 里加一个过滤条件,关键字用BluetoothSocketAcceptThread,然后把 Log level 调到 Debug。看到类似accept() called的日志时,说明服务端正常进入监听状态;看到Connected!或者Socket beginConnect()这类日志,说明 RFCOMM 通道已建立。一个比较隐蔽的排查点是:有些源码包里,ConnectedThread里复用了同一个 Handler 对象处理读写消息,但 handler 创建时用的是new Handler()而不是new Handler(Looper.getMainLooper()),这会导致消息在子线程中处理,界面不刷新却也不报错,看起来就像是「连上了但没收到消息」。这个坑在旧源码里非常普遍,排查时优先看Handler的构造参数,比看读写逻辑更快。

5.4 迁移中最值得做的一次改动:数据流瘦身与 Kotlin 化

如果你打算长期维护这份源码,我会建议把整套byte[]+Handler的数据流拆成两个独立的通道:控制通道和数据通道。控制通道管理连接生命周期,数据通道只负责文本收发。这样改的好处是各模块可以单独测试,而且便于替换通信层——比如将来把 SPP 替换成 Wi-Fi Direct,只需要换掉数据通道的实现类。Kotlin 化时优先处理两个位置:ConnectedThread改为协程加上下文Dispatchers.IOAcceptThread改为callbackFlow配合await同步等待。注意listenUsingRfcommWithServiceRecord本身没有超时参数,协程化后可以用withTimeout包住整个 accept 流程,这能显著提升设备停止响应时的体验,这算是在同一个通道上补了一个长期缺失的控制项。迁移完成后,用 Android Studio 的Lint跑一遍权限和蓝牙相关检查,Lint 会直接标出所有缺失的权限声明和 API 级别警告,比人工检查可靠得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询