简介:BLE_SPP 安卓手机蓝牙串口助手源码面向 Android 开发者与物联网硬件调试人员,基于 Android Studio 实现手机与 BLE 设备之间的串口式数据交互,可用于智能穿戴、健康监测、智能家居等场景的通信调试与二次开发。压缩包共 1946 个文件,约 27.69MB,以 xml 布局与资源、png 图片、class 与 dex 编译产物、json 配置、jar 依赖为主,另含 java 源码、aidl 接口、gradle 构建脚本及 apk 安装包,完整保留了工程结构与构建环境。源码围绕扫描设备、连接、发现服务、读写特征值与接收通知展开,核心类包括 MainActivity、BluetoothLeService 与自定义 GattCallback,并涉及权限声明与多版本 API 适配。目前已有 1431 人学习下载,读者可据此掌握 BLE 串口通信流程、后台服务与 UI 同步机制,并借鉴连接稳定性与功耗控制的排错思路。
1. BLE_SPP 安卓手机蓝牙串口助手源码:从协议栈到可运行 APK 的完整路径
BLE_SPP 安卓手机蓝牙串口助手源码,这个标题里其实藏着两条技术路线:BLE(低功耗蓝牙)和 SPP(串口透传协议)。很多刚接触安卓蓝牙开发的同行会下意识以为这是同一个东西,结果在选型阶段就翻车——买回来的模块明明标着“蓝牙串口”,连上去却发现根本搜不到服务。问题的根源在于:经典蓝牙 SPP 和 BLE 的 GATT 通信模型完全不同,前者基于 RFCOMM 通道模拟串口,后者基于属性协议读写特征值。这套源码要解决的,就是让安卓手机同时具备两种连接能力,既能对接老式 HC-05/06 模块,也能对接新一代 BLE 透传模块,最终把收到的数据以串口助手的形式呈现出来。适合谁看?做物联网设备调试的嵌入式工程师、需要快速验证蓝牙通信链路的安卓开发者,以及想拿一套能跑的代码改造成自己工具的人。
2. 先搞清楚 BLE 和 SPP 的选型分叉:别把 GATT 当 RFCOMM 用
2.1 两种协议栈的本质差异与适用场景
经典蓝牙 SPP 走的是 RFCOMM 层,它模拟了 RS232 串口的九针行为,有明确的“连接—打开流—读写—关闭”生命周期。安卓里对应BluetoothSocket,通过createRfcommSocketToServiceRecord拿到一个类似文件流的对象,读写就是InputStream和OutputStream。这套模型简单直接,延迟低,适合持续大数据量传输,比如固件升级、日志回传。
BLE 则完全不同。它没有“流”的概念,数据被组织成服务(Service)、特征(Characteristic)、描述符(Descriptor)。你要读数据,得先发现服务,找到目标特征,然后使能通知(Notification)或指示(Indication),再在回调里收onCharacteristicChanged。写数据也要指定写类型:带响应写还是无响应写。BLE 的优势是低功耗、连接参数可调,适合电池供电的传感器,但单包有效载荷通常只有 20 字节(默认 MTU 23),吞吐量远不如 SPP。
选型时问自己三个问题:设备是电池供电还是常电?数据量是每秒几 KB 还是几十字节?模块是 HC-05 那种经典蓝牙还是 JDY-08 那种 BLE?答案清晰了,路线就不会走偏。
2.2 安卓端权限与 API 版本适配清单
安卓蓝牙权限在 API 31(Android 12)之后发生了重大变化。旧版本只需要BLUETOOTH和BLUETOOTH_ADMIN,新版本必须加上BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE,并且扫描还需要定位权限ACCESS_FINE_LOCATION。很多源码在旧手机上跑得好好的,一到 Android 13 就搜不到设备,九成是权限没配全。
下面这张表是我在实际项目里整理的权限与 API 对应关系,直接照着填AndroidManifest.xml就行:
| 权限 | 用途 | 最低 API | 运行时请求 |
|---|---|---|---|
| BLUETOOTH | 经典蓝牙基础操作 | 1 | 否 |
| BLUETOOTH_ADMIN | 发现设备、配对 | 1 | 否 |
| BLUETOOTH_SCAN | BLE 扫描 | 31 | 是 |
| BLUETOOTH_CONNECT | BLE 连接与读写 | 31 | 是 |
| ACCESS_FINE_LOCATION | 扫描时获取位置信息 | 23 | 是 |
注意:Android 12 及以上,如果只声明
BLUETOOTH_SCAN而不声明neverForLocation,系统仍会要求定位权限。若你的应用确实不需要位置信息,在BLUETOOTH_SCAN标签里加上android:usesPermissionFlags="neverForLocation"可以免去定位权限,但部分定制 ROM 仍会拦截,实测中保留定位权限最稳妥。
2.3 最小可运行工程的结构拆解
一套能跑的 BLE_SPP 串口助手源码,目录结构通常长这样:
app/src/main/java/com/example/btterminal/ ├── MainActivity.java // 主界面,设备列表与收发区 ├── BleManager.java // BLE 扫描、连接、GATT 回调 ├── SppManager.java // 经典蓝牙 RFCOMM 连接与流读写 ├── DeviceAdapter.java // 设备列表适配器 └── HexUtils.java // 十六进制与字符串转换BleManager和SppManager对外暴露统一的接口:connect(address)、send(byte[])、setOnDataReceivedListener()。这样上层 UI 不需要关心底层是 BLE 还是 SPP,切换协议只改一个枚举值。这种设计在后期加功能时优势明显——比如要加蓝牙 5.0 的 2M PHY 支持,只动BleManager即可。
3. 用 Android Studio 跑通 BLE 扫描与 GATT 连接的最小命令
3.1 初始化蓝牙适配器与扫描参数配置
先拿到BluetoothManager,再取BluetoothAdapter。如果adapter == null,说明设备不支持蓝牙;如果!adapter.isEnabled(),要拉起系统意图让用户开启。
BluetoothManager manager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); BluetoothAdapter adapter = manager.getAdapter(); if (adapter == null || !adapter.isEnabled()) { Intent enableBt = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBt, REQUEST_ENABLE_BT); return; } // 安卓 12+ 需要动态申请 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }, REQ_BT_PERM); }扫描用BluetoothLeScanner.startScan,可以传ScanFilter和ScanSettings。ScanSettings里最关键的是SCAN_MODE_LOW_LATENCY(高频率扫描,耗电但快)和SCAN_MODE_LOW_POWER(省电但慢)。调试阶段用低延迟,正式产品如果只是间歇性扫描,切回低功耗。
ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) // 0 表示立即回调,大于 0 会批量上报 .build(); List<ScanFilter> filters = new ArrayList<>(); // 如果知道目标设备的服务 UUID,可以加过滤,减少无关回调 // filters.add(new ScanFilter.Builder().setServiceUuid(new ParcelUuid(UUID.fromString("0000FFE0-0000-1000-8000-00805F9B34FB"))).build()); scanner.startScan(filters, settings, scanCallback);setReportDelay(0)是必须的,设成大于 0 的值会让回调攒一批才触发,调试时容易误判“搜不到设备”。ScanFilter不是必须的,但加上能显著降低回调频率,省电也省 CPU。
3.2 GATT 连接与特征值读写的完整回调链
扫描到设备后,用device.connectGatt(context, false, gattCallback)发起连接。第二个参数autoConnect设为false表示直接连接,设为true则会在设备可用时自动连,但首次连接速度慢很多。调试阶段一律用false。
连接成功后,onConnectionStateChange会回调STATE_CONNECTED,此时必须调用gatt.discoverServices()。服务发现完成后,onServicesDiscovered里遍历gatt.getServices(),找到目标服务和特征。
@Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { if (status != BluetoothGatt.GATT_SUCCESS) return; for (BluetoothGattService service : gatt.getServices()) { for (BluetoothGattCharacteristic ch : service.getCharacteristics()) { // 找到可写特征,用于发送数据 if ((ch.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0 || (ch.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) { writeChar = ch; } // 找到可通知特征,用于接收数据 if ((ch.getProperties() & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { notifyChar = ch; gatt.setCharacteristicNotification(ch, true); // 还要写 CCCD 描述符,否则通知不会真正开启 BluetoothGattDescriptor cccd = ch.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")); if (cccd != null) { cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.writeDescriptor(cccd); } } } } }这里有个血泪经验:setCharacteristicNotification只是让安卓端准备好接收,真正让外设发通知的是写 CCCD 描述符。很多人只调了前者,结果一直收不到数据,排查半天以为是硬件问题。CCCD 的 UUID 是固定的00002902-...,不用记,复制就行。
发送数据时,根据特征属性选择写类型:
public void send(byte[] data) { if (writeChar == null) return; writeChar.setValue(data); if ((writeChar.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) { gatt.writeCharacteristic(writeChar, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); } else { gatt.writeCharacteristic(writeChar, data, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); } }WRITE_TYPE_NO_RESPONSE不等待外设确认,速度快但可能丢包;WRITE_TYPE_DEFAULT有确认,可靠但慢。调试时先用默认写,确认链路通了再切无响应写。
3.3 经典蓝牙 SPP 的 RFCOMM 连接与流读写
SPP 这边简单得多。先通过adapter.getRemoteDevice(address)拿到设备,再用 UUID00001101-0000-1000-8000-00805F9B34FB创建 socket。
BluetoothDevice device = adapter.getRemoteDevice(address); BluetoothSocket socket = device.createRfcommSocketToServiceRecord( UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")); adapter.cancelDiscovery(); // 连接前必须取消扫描,否则连接极慢甚至失败 socket.connect(); InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream();cancelDiscovery()这行不能省。经典蓝牙在扫描时会占用射频资源,导致连接请求排队,实测不取消扫描的话连接耗时可能从 1 秒变成 10 秒以上。读写就是标准的流操作,开一个线程循环读in.read(buffer),收到数据后通过回调抛给 UI 线程。
4. 收发数据不乱码:分包、粘包与 MTU 协商的实战处理
4.1 BLE 通知分包与数据拼接策略
BLE 默认 MTU 是 23,减去 3 字节 ATT 头,单包有效载荷 20 字节。如果你发 100 字节的数据,外设会拆成 5 包发过来,每包触发一次onCharacteristicChanged。如果直接每包都当独立消息处理,就会看到“数据被截断”的现象。
常见做法是维护一个缓冲区,按协议头尾拼接。如果外设没有自定义协议,最简单的方案是协商更大的 MTU。安卓端调用gatt.requestMtu(247),回调onMtuChanged里拿到实际协商值。注意:MTU 是双方取小,外设不支持大 MTU 的话,协商结果可能还是 23。
// 发起 MTU 协商 gatt.requestMtu(247); // 回调里更新实际可用载荷 @Override public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { maxPayload = mtu - 3; // 减去 ATT 头 } }即使协商到 247,单包也只有 244 字节。超过这个长度还是要分包。我的习惯是在应用层加一个简单的长度头:前两字节表示后续数据长度,接收端按长度拼接。这样无论底层怎么分,上层都能还原完整消息。
4.2 十六进制与 ASCII 双模式显示的实现
串口助手通常要同时支持 HEX 和 ASCII 显示。HEX 模式把每个字节转成两位十六进制,ASCII 模式直接按字符编码转字符串。关键点是:ASCII 模式下遇到不可打印字符要显示成点号,否则日志里会出现乱码控制符。
public static String toHex(byte[] data) { StringBuilder sb = new StringBuilder(); for (byte b : data) { sb.append(String.format("%02X ", b)); } return sb.toString().trim(); } public static String toAscii(byte[] data) { StringBuilder sb = new StringBuilder(); for (byte b : data) { if (b >= 0x20 && b <= 0x7E) { sb.append((char) b); } else { sb.append('.'); } } return sb.toString(); }发送时也要支持 HEX 输入。用户输入01 02 AB这样的字符串,要解析成字节数组。解析时去掉空格,每两个字符转一个字节,遇到非法字符要提示而不是崩溃。
4.3 发送队列与写入节奏控制
BLE 的writeCharacteristic是异步的,连续调用多次只有第一次生效,后面的会返回false。正确做法是维护一个发送队列,在onCharacteristicWrite回调里取下一个包继续发。
private final Queue<byte[]> sendQueue = new LinkedList<>(); private boolean isWriting = false; public void enqueue(byte[] data) { sendQueue.offer(data); if (!isWriting) { nextWrite(); } } private void nextWrite() { byte[] data = sendQueue.poll(); if (data == null) { isWriting = false; return; } isWriting = true; writeChar.setValue(data); gatt.writeCharacteristic(writeChar); } @Override public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic ch, int status) { nextWrite(); // 上一个写完了,发下一个 }这个队列机制是 BLE 串口助手稳定性的核心。没有它,快速点击发送按钮时数据会丢,而且丢得悄无声息,排查起来非常痛苦。
5. 避坑与排查:BLE_SPP 源码调试中最容易翻车的五个点
5.1 扫描不到设备或回调不触发
现象:点击扫描按钮后列表一直空白,日志里没有任何onScanResult回调。
原因通常有三个:权限没给全(Android 12+ 缺BLUETOOTH_SCAN)、定位服务没开(部分 ROM 要求位置开关打开才能扫描)、或者startScan传了不匹配的ScanFilter。
解决:先检查运行时权限是否全部授予,再确认系统定位开关状态。如果用了ScanFilter,暂时去掉过滤看能否搜到,能搜到说明过滤条件写错了。另外,startScan后不要立刻stopScan,至少等 5 秒。
5.2 连接成功但收不到通知数据
现象:onConnectionStateChange返回了STATE_CONNECTED,服务也发现了,但onCharacteristicChanged永远不触发。
原因:只调了setCharacteristicNotification,没写 CCCD 描述符。或者写 CCCD 时用了writeDescriptor但没等回调就发了其他 GATT 操作,导致描述符写失败。
解决:确认 CCCD 写入的onDescriptorWrite回调返回GATT_SUCCESS。如果失败,检查特征是否真的支持 NOTIFY 属性。有些模块标着 NOTIFY 但实际只支持 INDICATE,这时要改用ENABLE_INDICATION_VALUE。
5.3 发送数据丢包或只发出去第一包
现象:连续发送多条数据,只有第一条成功,后面全部丢失。
原因:没有实现发送队列,连续调用writeCharacteristic时前一次写还没完成,后续调用被系统忽略。
解决:按 4.3 节的队列方案改造,确保每次写入都在onCharacteristicWrite回调里触发下一次。另外,WRITE_TYPE_NO_RESPONSE模式下没有写回调,需要自己控制发送间隔,一般 20ms 以上比较安全。
5.4 经典蓝牙连接缓慢或频繁断开
现象:SPP 连接要等十几秒,或者连上几秒后自动断开。
原因:连接前没有cancelDiscovery(),射频资源被扫描占用。或者 socket 连接在主线程执行,触发 ANR 后被系统回收。
解决:连接前务必取消扫描,socket 的connect()放在子线程。如果仍然断开,检查外设的配对状态,未配对设备需要先createBond()配对再连接。
5.5 安卓 13 上后台扫描被限制
现象:应用切到后台后,BLE 扫描自动停止,回到前台才能继续。
原因:Android 13 对后台 BLE 扫描加了更严格的限制,没有BLUETOOTH_SCAN配合neverForLocation或前台服务的话,后台扫描会被系统掐断。
解决:如果确实需要后台扫描,起一个前台服务并声明FOREGROUND_SERVICE_CONNECTED_DEVICE类型。但大多数串口助手场景不需要后台扫描,切前台时重新startScan即可。
6. 进阶技巧:用日志回放和模拟外设验证你的串口助手
调试蓝牙最头疼的是“问题复现不了”——外设不在手边,或者偶发丢包抓不到。我的习惯是在BleManager和SppManager里加一个可开关的日志层,把所有收发数据带时间戳写到文件。格式用[时间] [方向] [HEX],回放时按时间戳逐条注入,就能在没硬件的情况下复现大部分逻辑问题。
public class BtLogger { private static final String TAG = "BtLogger"; private static boolean enabled = false; private static BufferedWriter writer; public static void init(File logFile) throws IOException { writer = new BufferedWriter(new FileWriter(logFile, true)); } public static void log(String direction, byte[] data) { if (!enabled || writer == null) return; String line = String.format("[%d] [%s] %s", System.currentTimeMillis(), direction, HexUtils.toHex(data)); try { writer.write(line); writer.newLine(); writer.flush(); } catch (IOException e) { Log.e(TAG, "log write failed", e); } } }回放工具单独写一个类,读日志文件,按时间戳差值Thread.sleep,然后调用onDataReceived回调。这样 UI 层的分包拼接、HEX 显示、自动滚动逻辑都能脱离硬件验证。我一般会在发版前跑一遍回放,确认没有数组越界和空指针。
另一个技巧是 MTU 协商的降级策略。不要一上来就请求 247,先请求 512,失败再降 247,再失败降 128,最后用默认 23。有些外设对特定 MTU 值有兼容性问题,多级降级能提高一次连接成功率。
private int[] mtuCandidates = {512, 247, 128, 23}; private int mtuIndex = 0; private void tryNextMtu() { if (mtuIndex >= mtuCandidates.length) return; gatt.requestMtu(mtuCandidates[mtuIndex++]); } @Override public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { maxPayload = mtu - 3; } else { tryNextMtu(); // 失败就试下一个 } }这套降级逻辑在对接不同厂商模块时特别省心,不用为每个模块单独改代码。最后说一个我踩过的坑:不要在onCharacteristicChanged里直接更新 UI,这个回调在 Binder 线程,必须runOnUiThread切回去。早期版本我忘了切,界面偶尔闪退,日志里全是CalledFromWrongThreadException,查了半天才定位到。希望帮到你。
本文还有配套的精品资源,点击获取