
简介BluetoothSppPro 是一份面向 Android 蓝牙开发初学者与嵌入式调试人员的开源 SPP 工具项目源码主要用于研究经典蓝牙串口通信协议的实现方式可作为学习蓝牙连接、数据收发与设备配对流程的参考范例。压缩包共 66 个文件约 998KB以 22 个 Java 源文件与 19 个 XML 布局配置为主另含 13 个说明文本、5 张界面截图及 jar 依赖库覆盖源码、资源与工程配置等模块结构完整。目前已有 973 人学习下载说明其在蓝牙调试与教学场景中具有一定参考价值。项目包含 AndroidManifest 清单、多语言资源目录与 Eclipse 工程配置文件读者可据此梳理 SPP 通信的代码组织方式理解蓝牙适配器初始化、Socket 连接与数据流读写等关键环节并借助截图与说明文档快速定位功能入口。需注意作者声明仅供研究使用不建议用于商业项目引用文件即视为接受相关条款。1. 蓝牙 SPP 调试为什么总在真机上翻车做过 Android 蓝牙串口通信的人大概都有过这种体验在模拟器或者 Demo 板上跑得好好的换一台真机、换一个蓝牙模块连接就时断时续数据要么收不全要么干脆收不到。BluetoothSppPro 这类蓝牙 SPP 工具项目本质上就是围绕 Android 经典蓝牙Bluetooth Classic的 SPPSerial Port Profile串口协议做的一层封装把 UUID 匹配、Socket 连接、输入输出流读写、线程调度这些琐碎但容易出错的环节整理成可复用的代码。它解决的核心问题很具体让 Android 手机和单片机、工控板、蓝牙串口模块之间稳定地收发字节流。适合谁做物联网设备调试的嵌入式工程师、需要手机端配置硬件参数的 App 开发者以及想搞清楚 Android 蓝牙 SPP 到底怎么跑通的 Android 新手。这一章先把 SPP 的边界和选型理由讲清楚后面几章再动手。2. 把 SPP 连接拆开看UUID、Socket 与流读写2.1 SPP 在 Android 里的真实链路Android 经典蓝牙的 SPP 并不是一个单独的 API而是一套组合拳。设备发现阶段用BluetoothAdapter扫描拿到BluetoothDevice对象连接阶段用createRfcommSocketToServiceRecord(UUID)创建BluetoothSocket通信阶段通过getInputStream()和getOutputStream()读写字节。SPP 的标准 UUID 是00001101-0000-1000-8000-00805F9B34FB但很多廉价蓝牙串口模块比如基于 HC-05、HC-06 芯片的并不严格遵循这个 UUID有的用自定义 UUID有的甚至不广播 SPP 服务记录。这就是为什么同一份代码在不同模块上表现差异巨大。我一般会先确认三件事模块是否处于从机模式并已配对、模块的 UUID 是否可枚举、手机是否已经和模块完成系统级配对。配对和连接是两回事配对是系统层的事连接是应用层的事。很多新手把两者混为一谈结果在未配对状态下反复调用 connect自然一直失败。2.2 最小可跑通的连接代码下面这段代码是 SPP 连接的核心骨架我把它从项目里抽出来去掉业务逻辑只保留连接和读写// 获取默认蓝牙适配器 BluetoothAdapter adapter BluetoothAdapter.getDefaultAdapter(); // 根据 MAC 地址拿到设备对象MAC 需提前配对过 BluetoothDevice device adapter.getRemoteDevice(00:11:22:33:44:55); // SPP 标准 UUID部分模块需替换为厂商自定义 UUID UUID SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB); BluetoothSocket socket null; try { // 创建安全的 RFCOMM socket socket device.createRfcommSocketToServiceRecord(SPP_UUID); // 连接前必须取消扫描否则会拖慢甚至阻塞连接 adapter.cancelDiscovery(); // connect 是阻塞调用必须放在子线程 socket.connect(); InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream(); // 发送一个字节数组 out.write(new byte[]{0x01, 0x02, 0x03}); // 循环读取注意 read 返回 -1 表示对端关闭 byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { // 处理收到的数据建议拷贝出来再交给业务层 byte[] data new byte[len]; System.arraycopy(buffer, 0, data, 0, len); // handleData(data); } } catch (IOException e) { // 连接失败时关闭 socket避免资源泄漏 if (socket ! null) { try { socket.close(); } catch (IOException ignored) {} } }逻辑说明cancelDiscovery()这一步经常被忽略但它是连接成功率的关键。扫描过程中蓝牙控制器处于高负载状态此时发起 RFCOMM 连接失败率会明显上升。connect()是阻塞方法放在主线程会直接触发 ANR必须丢到子线程或线程池。read()返回 -1 表示对端主动断开这时候要跳出循环并关闭 socket否则会陷入死循环空转。参数说明UUID 字符串必须严格 32 位十六进制加连字符大小写不敏感但格式不能错。MAC 地址格式为XX:XX:XX:XX:XX:XX从BluetoothDevice.getAddress()获取。缓冲区大小 1024 是经验值SPP 单次传输通常不超过 990 字节设太大浪费内存设太小会频繁触发 read 调用。2.3 权限与线程模型Android 6.0 之后蓝牙扫描需要定位权限因为蓝牙扫描结果可以推断位置。Android 12 之后又拆出了BLUETOOTH_CONNECT、BLUETOOTH_SCAN等运行时权限。很多项目在旧版本上跑得好好的升级 targetSdk 后直接崩溃就是权限没适配。线程模型上我习惯用一个独立的HandlerThread专门跑蓝牙读写所有 socket 操作都 post 到这个线程的 Handler 上。这样做的原因是BluetoothSocket的读写不是线程安全的多线程同时操作会出各种玄学问题。连接、发送、接收、关闭全部串行化到一个线程里逻辑清晰排查也方便。// 用 HandlerThread 串行化所有蓝牙操作 HandlerThread btThread new HandlerThread(bt-spp); btThread.start(); Handler btHandler new Handler(btThread.getLooper()); btHandler.post(() - { // 在这里执行 connect / write / close });这种写法的代价是发送大文件时吞吐量受限但对串口通信这种小数据量、低频次的场景完全够用。如果确实需要高吞吐再考虑把读和写拆成两个线程但连接和关闭仍然要串行。3. 数据收发稳定性的三个关键参数3.1 读取循环的阻塞与超时InputStream.read()在没有数据时会一直阻塞这本身没问题但如果对端异常掉电socket 不会立刻感知read 会一直挂着。解决办法是设置 socket 超时或者用心跳包。BluetoothSocket本身没有公开的setSoTimeout方法但可以通过反射调用底层 socket 的setSoTimeout或者更稳妥的做法是在应用层加心跳每隔几秒发一个约定字节连续几次没收到回应就主动关闭重连。我一般会在读取循环里加一个空闲计数器配合available()判断// 非阻塞探测available 返回当前可读字节数 int available in.available(); if (available 0) { byte[] buf new byte[available]; int read in.read(buf); // 处理 buf } else { // 空闲可插入心跳逻辑 }注意available()在蓝牙流上并不总是可靠它返回的是当前缓冲区里已经到达的字节数不代表对端没在发。所以不能完全依赖它做协议解析只能用来做空闲判断。3.2 分包与粘包的处理SPP 是流式协议没有消息边界。你发两次 10 字节对端可能一次收到 20 字节也可能分三次收到。这是新手最容易踩的坑以为 write 一次read 就对应一次。正确做法是在应用层定义帧格式比如「帧头 0xAA 长度 数据 校验」接收端按状态机解析。// 简易状态机解析帧头 0xAA第二字节为长度 private int state 0; private int payloadLen 0; private ByteArrayOutputStream payload new ByteArrayOutputStream(); void parse(byte[] data) { for (byte b : data) { switch (state) { case 0: if ((b 0xFF) 0xAA) state 1; break; case 1: payloadLen b 0xFF; payload.reset(); state 2; break; case 2: payload.write(b); if (payload.size() payloadLen) { // 一帧完整数据交给业务层 // handleFrame(payload.toByteArray()); state 0; } break; } } }参数说明帧头选 0xAA 只是示例实际要选数据区出现概率低的字节。长度字段一字节最大 255如果数据更长要用两字节。校验可以用累加和或 CRC16串口场景累加和通常够用。3.3 重连策略与状态机蓝牙连接断开是常态不是异常。模块省电、距离变远、手机切后台都会导致断开。一个健壮的 SPP 工具必须有自动重连。我的做法是维护一个连接状态机IDLE - CONNECTING - CONNECTED - DISCONNECTED断开后延迟 1 到 3 秒重试连续失败超过 5 次就停止并通知用户。重连时要注意先close()旧 socket再创建新 socket。直接复用旧 socket 调 connect 在某些机型上会抛IOException: socket closed。另外重连前最好重新cancelDiscovery()因为扫描可能被其他应用触发。4. 避坑与排查那些让我熬夜的 SPP 问题4.1 连接成功但收不到数据现象connect()返回正常getOutputStream().write()也不报错但对端模块毫无反应或者对端发了数据手机端 read 一直阻塞。原因最常见的是 UUID 不匹配。手机端用标准 SPP UUID 去连一个自定义 UUID 的模块RFCOMM 通道能建立但数据通道没对上。其次是模块的波特率与单片机端不一致数据在模块内部就被丢弃了。解决先用device.getUuids()枚举设备广播的 UUID如果返回 null 或空说明模块没有 SPP 服务记录需要向厂商确认 UUID。波特率问题只能查模块手册用 AT 指令确认。4.2 传输大文件时中途断连现象发送几百 KB 的数据发到一半 socket 断开抛IOException: Broken pipe。原因SPP 的底层缓冲区有限如果发送端 write 速度远快于对端处理速度缓冲区满后继续写就会阻塞甚至断开。另外 Android 后台对蓝牙有省电限制长时间传输可能被系统掐断。解决发送端加流控每发一包等对端 ACK 再发下一包。或者降低发送频率在 write 之间加短暂 sleep。传输期间保持屏幕常亮或申请前台服务避免被系统挂起。4.3 部分机型 connect 直接抛异常现象同一份代码A 手机正常B 手机connect()立刻抛IOException: read failed, socket might closed。原因这是 Android 蓝牙栈的老问题某些厂商的 ROM 对createRfcommSocketToServiceRecord的实现有差异反射调用createRfcommSocket的隐藏方法反而能成功。解决加一个降级方案标准方法失败后尝试反射创建 socket// 降级方案反射调用隐藏的 createRfcommSocket 方法 try { Method m device.getClass().getMethod(createRfcommSocket, int.class); socket (BluetoothSocket) m.invoke(device, 1); } catch (Exception e) { // 两种方式都失败上报错误 }注意这个反射方法在不同 Android 版本上签名可能不同要做好异常捕获。4.4 权限申请后仍然扫描不到设备现象动态申请了定位权限startDiscovery()也返回 true但BroadcastReceiver收不到任何设备。原因Android 10 之后后台扫描受限如果应用不在前台扫描结果会被过滤。另外部分机型需要同时开启系统定位开关不只是授予权限。解决确保扫描时应用处于前台并引导用户打开系统定位。Android 12 以上还要检查BLUETOOTH_SCAN权限是否授予且neverForLocation标志不要乱加。4.5 关闭 socket 后线程未退出现象退出界面后后台线程还在跑日志一直打印 read 阻塞内存泄漏。原因read()阻塞时调用close()在某些机型上不会立即唤醒阻塞的 read线程会卡住。另外 HandlerThread 没有调用quitSafely()。解决关闭时先socket.close()再中断线程最后handlerThread.quitSafely()。读取循环里捕获IOException后主动退出不要依赖 close 唤醒。5. 进阶把 SPP 工具做成可复用的调试组件5.1 用接口隔离业务与传输如果只是写个 Demo把读写逻辑写在 Activity 里没问题。但要做成可复用组件必须把传输层和业务层分开。我一般定义一个SppTransport接口暴露connect()、send(byte[])、setOnDataListener()、close()四个方法内部实现连接管理、线程调度、分包解析。业务层只关心「收到一帧数据」和「发送一帧数据」不碰 socket。这样做的好处是换蓝牙模块、换协议格式只需要改传输层实现业务代码不动。测试时也可以用 Mock 实现替换真实蓝牙在模拟器上跑单元测试。5.2 日志与抓包排查蓝牙问题的后悔药蓝牙问题最难的是「不可见」。数据到底发出去没有、对端收到没有、在哪一层丢的光看代码看不出来。我的习惯是在传输层加详细日志每次 connect、write、read 都记录时间戳、字节数、十六进制内容。日志开关做成可配置调试时打开发布时关闭。更进一步可以在发送和接收路径上各加一个「镜像」回调把原始字节流同时输出到文件。这样出问题时把手机端日志和单片机端日志一对就能定位是发送丢、传输丢还是接收丢。这个习惯帮我省了无数次扯皮。5.3 一个可复用的连接管理模板下面这个模板把连接、重连、状态通知串起来可以直接拿去改public class SppManager { private BluetoothSocket socket; private InputStream in; private OutputStream out; private volatile boolean running false; private final Handler worker; private static final int MAX_RETRY 5; public SppManager(Handler worker) { this.worker worker; } public void connect(BluetoothDevice device, UUID uuid) { worker.post(() - { int retry 0; while (retry MAX_RETRY !running) { try { socket device.createRfcommSocketToServiceRecord(uuid); socket.connect(); in socket.getInputStream(); out socket.getOutputStream(); running true; // notifyConnected(); startReadLoop(); } catch (IOException e) { retry; closeQuietly(); try { Thread.sleep(2000); } catch (InterruptedException ignored) {} } } if (!running) { // notifyConnectFailed(); } }); } private void startReadLoop() { worker.post(() - { byte[] buf new byte[1024]; while (running) { try { int len in.read(buf); if (len -1) { running false; break; } byte[] data new byte[len]; System.arraycopy(buf, 0, data, 0, len); // notifyData(data); } catch (IOException e) { running false; // notifyDisconnected(); } } closeQuietly(); }); } public void send(byte[] data) { worker.post(() - { if (!running || out null) return; try { out.write(data); out.flush(); } catch (IOException e) { running false; closeQuietly(); } }); } public void close() { running false; worker.post(this::closeQuietly); } private void closeQuietly() { try { if (socket ! null) socket.close(); } catch (IOException ignored) {} socket null; in null; out null; } }逻辑说明所有操作都 post 到同一个 worker 线程保证串行。running用 volatile 修饰保证读写线程可见。重连循环里每次失败先关闭旧 socket 再 sleep避免资源堆积。读取循环里read返回 -1 或抛异常都置running false并关闭防止线程泄漏。参数说明MAX_RETRY设 5 次是经验值太少容易在信号波动时放弃太多会让用户等太久。重试间隔 2 秒太短可能模块还没复位完太长体验差。缓冲区 1024 字节对串口场景足够。5.4 验证方法用回环测试确认链路写完传输层别急着接真实设备。先做回环测试找两个 Android 手机一个作为服务端用listenUsingRfcommWithServiceRecord监听另一个作为客户端连接互相发数据。这样能排除硬件模块的干扰确认代码本身没问题。回环通了再换真实模块问题范围就缩小到模块配置和 UUID 上了。我自己的习惯是每换一个蓝牙模块先跑一遍回环测试脚本确认手机端代码没退化再去调模块参数。这个顺序能省掉大量「到底是代码问题还是模块问题」的纠结。希望帮到你。本文还有配套的精品资源点击获取