Android蓝牙聊天源码剖析:从SPP通信到权限适配 📅 发布时间:2026/9/15 20:14:56 👁 浏览次数: 简介面向Android开发者的蓝牙聊天应用源码项目围绕蓝牙适配器、蓝牙套接字与广播接收者等核心类完整演示权限声明、设备扫描与配对、建立连接、双向数据流传输和断开释放等关键流程可帮助读者解决从零实现蓝牙即时通信时常见的适配与连接问题。压缩包共52个文件大小约4.14MB主要包含Java源码、XML布局及清单文件、class字节码、APK安装包、界面预览图与说明文档既有可直接参考的代码工程也有可安装运行的成品APK便于对照学习和二次开发。目前已有191人学习下载。借助源码注释、说明文档和界面截图读者能快速理解蓝牙聊天的整体架构与设备发现、状态监听的实现思路进而扩展聊天界面、消息协议或异常处理实用性较强。1. 一个 zip 里的蓝牙聊天应用它解决的问题和源码的适用场景「android 蓝牙聊天的应用源码.zip」这个名字看起来像是早年流传下来的课设包但它背后是一整套非常完整的蓝牙通信范式经典蓝牙 SPP 协议、设备发现、配对、Socket 长连接、读线程与主线程的通信。在 IM 框架和云信令普及之前这种应用几乎是所有人接触 Android 网络通信的第一个完整项目今天搜到它的人要么是学生找项目参考要么是想把一个旧工程跑在现代 Android Studio 上。这个 zip 能解决的问题很集中在没有服务器的情况下让两台 Android 设备直接通过蓝牙交换文本消息。它适合谁一句话讲适合想搞清楚 Android 经典蓝牙 API 到底怎么串联的人这套代码里值得看的不是界面而是从BluetoothAdapter到BluetoothSocket的完整链路。本文会把这个链路拆开从 SPP 的原理、Android 12 权限分水岭到具体代码实现、参数调优、常见运行陷阱和工程改造方法一次讲完。2. 蓝牙聊天的技术底座SPP 协议、UUID 与 Android 蓝牙权限的新旧分水岭2.1 经典蓝牙与 BLE聊天应用该选哪条路Android 的蓝牙能力分成两条线经典蓝牙BR/EDR和低功耗蓝牙BLE/Bluetooth Low Energy。蓝牙聊天这种需要持续、双向、稳定传输文本的场景常见做法是走经典蓝牙的 SPPSerial Port Profile也就是串口模拟协议。SPP 的设计目标就是让两个设备像通过串口线连接一样应用层拿到的是一条字节流读写两端完全对称。BLE 则完全不同它是围绕 GATT 服务、特征值和通知机制设计的面向的是心率计、传感器这类小数据量周期性上报场景kadar 双向聊天要自己定义服务端和客户端的 characteristic复杂度高出一个量级而且每包数据长度受 MTU 限制。所以zip 里的源码只要标注的是「蓝牙聊天」几乎可以断定核心实现是BluetoothSocketSPP通道不是 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 6API 23之前只需要在 Manifest 里声明BLUETOOTH和BLUETOOTH_ADMIN两个权限安装时自动授予Android 6 到 Android 11 之间情况也还算温和蓝牙相关权限依然是安装时授予唯一需要注意的是设备扫描需要位置权限因为蓝牙扫描结果可以反推用户位置真正的分水岭是 Android 12API 31引入的新权限模型。下表整理了权限的演进关系这是判断一份源码包「需要改多少」的核心依据Android 版本需要的 Manifest 权限运行时动态申请要求说明Android 6 ~ 11BLUETOOTH、BLUETOOTH_ADMIN、定位权限位置权限ACCESS_FINE_LOCATION需动态申请扫描设备视为位置信息收集Android 12BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_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必须是两端一致的标准 UUIDAPP_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:usesPermissionFlagsneverForLocation系统会在权限对话框里显示「位置信息 » 不会被使用」并且允许用户授予精确位置之外的权限。如果不加这个标记扫描蓝牙设备依然需要位置权限配合这在老代码里不会体现属于迁移到新系统时必须面对的差异。在 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阻塞的也只是sendThreadUI 线程不受影响。要注意的是ConnectedThread.write之前必须判空因为连接断开后mmOutStream是 null直接调会触发 NPE这是旧 zip 里仅次于权限崩溃的第二大崩溃点。4.4 蓝牙聊天 vs BLE 心率监测源码选择的边界在搜索热词里看到「android ble开发实战 心率监测app」把它跟蓝牙聊天源码放一起比较边界一下子就清楚了。心率监测 app 的技术栈里不可能出现listenUsingRfcommWithServiceRecord它用的是BluetoothGatt、onCharacteristicRead、onCharacteristicChanged这一整套 BLE API而且它在应用层感知的是「数据包」不是「字节流」。如果看到一份源码包含了这按钮说是蓝牙聊天项目却写着BluetoothGattCallback那基本可以断定是文档没改代码是照抄心率 demo 的。反过来如果想真正做一个蓝牙聊天项目也不要试图把 SPP 源码改成 BLE——那等于推翻整套架构还不如直接找一份基于 GATT 的 IM demo。5. 源码包验证与工程迁移把 zip 从「能读」改造成「能跑」5.1 解压后先看三个文件再决定要不要导入 Android Studio很多用户下完 zip 直接双击打开发现里面缺gradle/wrapper/目录以为文件损坏。其实判定一份蓝牙聊天源码是否值得导入只需要先看三个文件AndroidManifest.xml里的权限声明、build.gradle里的minSdkVersion和targetSdkVersion、还有一个核心的 Service 类名字通常叫BluetoothChatService或ChatController。看这三个文件的意义在于源码能不能在现代环境跑起来基本在打开 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 里有没有声明BLUETOOTH和BLUETOOTH_ADMIN两个经典权限因为新版 Android Studio 创建项目时默认的 manifest 模板不会自动带蓝牙权限。5.3 借 logcat 验证连接是否真正建立蓝牙聊天应用最麻烦的一点是连接成功与否在界面上经常不显眼。这时要用 Android Studio 的 Logcat 过滤器来确认链路状态。在 Logcat 里加一个过滤条件关键字用BluetoothSocket和AcceptThread然后把 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 级别警告比人工检查可靠得多。本文还有配套的精品资源点击获取