简介:Android蓝牙串口调试助手完整源码,面向需要在安卓平台快速实现蓝牙设备连接的开发者,适用于蓝牙串口通信测试、硬件交互调试与二次开发场景。资源共38个文件,以Java源码、XML布局与配置、class编译文件及可直接安装的APK为主,整包仅80KB,轻量紧凑。目前已有1443人学习下载。源码围绕BluetoothAdapter、BluetoothSocket等核心API展开,涵盖设备扫描、配对、RFCOMM串口连接、双向数据收发、权限声明与连接状态监听等完整流程,并通过Handler调度异步任务避免阻塞UI,同时保留Logcat日志便于排错。界面层包含设备列表与收发区布局,可在Android Studio中直接运行或按需扩展,例如增加加密传输或自定义协议,是学习Android蓝牙通信和串口调试的高性价比参考工程。 搞嵌入式这些年,手里没有个趁手的蓝牙调试工具是真的难受。我自己做蓝牙模块和单片机联调的时候,市面上能找到的蓝牙串口调试助手要么广告满天飞,要么只能发纯文本,发个hex帧、控制个回车换行都费劲,更别提收数据了,稍微多点就直接卡死。后来索性自己动手,用Android Studio把一个蓝牙串口调试助手从零到一写了出来,跑通之后一直稳定用到现在,实测解决了我大半的联调痛点。
这篇博文就把这套源码的设计思路、关键代码和踩坑记录完整分享出来,代码我反复编译验证过,保证能正确运行。适合正在做嵌入式开发、智能硬件调试,或者想学习Android蓝牙通讯的开发者参考。这篇东西不讲虚的,全是能直接落地的干货。
1. 为什么非要自己写一个蓝牙串口调试助手
先说清楚一个概念:这里讲的蓝牙串口是基于经典蓝牙的SPP协议,也就是RFCOMM通道,它模拟出来的是一个虚拟串口,和传统UART串口的读写方式几乎一模一样。很多蓝牙模块比如HC-05、HC-06,以及ESP32经典蓝牙、STM32接蓝牙模块的场景,走的都是这条路。
市面上的蓝牙调试工具有几个通病我实在忍不了。第一,大部分工具是给用户做产品演示用的,协议定制死了,没法自定义UUID,碰上厂商自定的SPP服务UUID直接连不上。第二,很多工具收发数据的时候没有处理好缓冲,数据一多就丢帧、乱码。第三,找不到源码,出了问题没法改,也没法学习里面蓝牙通讯的实现逻辑。
自己写的好处就是完全可控。我可以决定用什么UUID,用什么样的读写线程模型,怎么处理粘包和半包,甚至加不加hex收发模式都由我说了算。而且这套源码本身就是一个很好的Android蓝牙通讯学习样例,把设备扫描、配对、RFCOMM连接、双向读写、权限适配全都串起来了。
从我自己的实际体验来看,自己写的这个调试助手在交互响应上比某些商用工具还顺手,没有任何广告和启动延迟,打开就能连,连接稳定性和收发速度也足够日常调试使用。
2. 动手前先把蓝牙权限和系统兼容性理清楚
这部分是新手最容易翻车的点,也是"保证正确"的关键之一。Android蓝牙权限在不同系统版本上的要求变化很大,尤其是Android 6.0、Android 12这两个版本,几乎把权限模型重做了一遍,如果还拿老一套写法去适配新系统,编译能过,运行时直接崩或者功能不可用。
2.1 动态权限和Manifest配置
Manifest里最基本的蓝牙权限是BLUETOOTH和BLUETOOTH_ADMIN。但注意,如果你的targetSdkVersion在31以上(Android 12及以上),光声明这两个权限已经不够了,还必须声明新的蓝牙权限:
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- Android 12+ --> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-feature android:name="android.hardware.bluetooth" android:required="true" />这里有几个非常容易踩的坑。第一个,ACCESS_FINE_LOCATION这个定位权限在Android 6.0到Android 11之间是扫描蓝牙设备的必要条件,因为系统认为蓝牙扫描可以间接推测用户位置。很多人只声明了蓝牙权限没申请定位权限,结果startDiscovery()一点反应都没有,扫描不到任何设备。第二个,Android 12以后的neverForLocation标志要在Manifest里声明,声明了之后系统会认为你不会用蓝牙推导位置,扫描权限弹窗会干净很多。
2.2 检查权限和打开蓝牙的完整流程
运行时权限检查和申请逻辑要写在进入调试界面的前置流程里,不能在连接的时候才想起来,否则一旦用户拒绝,整个流程就会卡住。我一般是这样处理的:
private void checkPermissions() { List<String> needRequest = new ArrayList<>(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { needRequest.add(Manifest.permission.BLUETOOTH_SCAN); } if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { needRequest.add(Manifest.permission.BLUETOOTH_CONNECT); } } else { if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { needRequest.add(Manifest.permission.ACCESS_FINE_LOCATION); } } if (!needRequest.isEmpty()) { requestPermissions(needRequest.toArray(new String[0]), REQUEST_PERMISSION_CODE); } }打开蓝牙的系统意图是BluetoothAdapter.ACTION_REQUEST_ENABLE,调用startActivityForResult之后用户在系统弹窗点确认,然后通过onActivityResult回调确认是否成功。这里我建议在初始化的时候就提前检查一遍,而不是等用户点连接按钮的时候再检查,体验会舒服很多,避免连接前手忙脚乱。
另外注意一点,BluetoothAdapter.getDefaultAdapter()在较新的API中已经标记为废弃,官方推荐的写法是通过BluetoothManager获取,不过对于SPP调试助手来说旧的调用方式仍然可用,但为了后续维护方便,我建议在谷歌推荐写法还没有强制移除之前,逐步切换到getSystemService(BluetoothManager.class).getAdapter()。
3. 源码骨架:扫描、配对、连接、读写一条龙
整套源码的架构我按功能拆成了四块:设备扫描发现模块、RFCOMM连接模块、数据读写线程模块和界面控制模块。模块之间通过接口回调通信,界面层不直接操作蓝牙API,这样代码结构清晰,后面要扩展协议解析或者改UI也不会牵一发动全身。
3.1 扫描已配对设备和新设备的方法差异
很多第一次写蓝牙应用的人会把扫描和获取已配对设备混在一起。实际上这是两种完全不同的机制。已配对设备通过bluetoothAdapter.getBondedDevices()直接读取系统缓存,不需要定位权限,也不需要扫描;新设备则必须调用startDiscovery()主动扫描,并且要注册BroadcastReceiver监听BluetoothDevice.ACTION_FOUND。
我处理的方式是:主界面一打开先把已配对设备列出来,因为这是最常连的设备,然后提供一个"扫描新设备"按钮,点下去才执行startDiscovery()。扫描的时候注意在onDestroy和界面不可见的时候及时stopDiscovery()和注销receiver,这个细节很关键,不然会导致Activity泄漏。
3.2 RFCOMM连接的核心代码
连接这块是SPP调试助手的咽喉。核心就是通过createRfcommSocketToServiceRecord()创建BluetoothSocket,然后调用connect()建立通道。这里我踩过一个深坑——connect()是一个阻塞调用,绝不能在主线程执行,必须放到子线程中,否则会抛出AndroidRuntimeException导致应用崩溃。
标准SPP服务的UUID是固定的:00001101-0000-1000-8000-00805F9B34FB。绝大多数蓝牙模块出厂时都是这个UUID。如果你连接的设备用的是厂商自定义UUID,必须在代码里改成对应值,这也是我为什么在源码里把UUID定义成一个常量,方便修改。
private static final UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); public boolean connect(BluetoothDevice device) { try { // 优先尝试标准RFCOMM连接 socket = device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect(); isConnected = true; return true; } catch (IOException e) { // 标准方法失败时,尝试反射创建隐藏API通道 try { Method m = device.getClass().getMethod("createRfcommSocket", int.class); socket = (BluetoothSocket) m.invoke(device, 1); socket.connect(); isConnected = true; return true; } catch (Exception ex) { isConnected = false; return false; } } }关于反射创建RFCOMM通道这段,是我在实际调试中总结的经验。有些蓝牙设备或者系统版本对标准UUID连接支持得不好,会报Service discovery failed异常,这时候用反射调用隐藏APIcreateRfcommSocket(1)往往能绕过去。这段代码平时可能用不到,但一旦碰上奇葩设备,能救命。
3.3 读写双线程与缓冲设计
连接成功后,读写逻辑我用的是两个独立线程。一个线程专门处理输入流InputStream的数据读取,另一个线程通过输出流OutputStream处理发送请求。这样设计的好处是接收数据的时候不会阻塞发送操作,调试指令的响应速度和连续数据流的处理都能兼顾。
读数据的实现上有一个关键点:不能一次性read很大的buffer,否则只等到缓冲区满了才返回,体验很差。我采用的是定长缓冲+循环读取的方式,代码如下:
private final byte[] buffer = new byte[1024]; private void readLoop() { InputStream inputStream = null; try { inputStream = socket.getInputStream(); int bytes; while (isConnected && (bytes = inputStream.read(buffer)) > 0) { byte[] data = new byte[bytes]; System.arraycopy(buffer, 0, data, 0, bytes); onDataReceived(data); // 回调到UI层 } } catch (IOException e) { // 连接断开处理 } }inputStream.read(buffer)会阻塞等待数据到来,有数据就读取,没有数据就挂起,完全不浪费CPU,而且系统会按实际到达的字节数返回,不会丢数据。发送侧我用了一个同步锁保护OutputStream.write(),防止多线程同时发数据导致的交错混乱。
4. 界面交互设计:怎么把调试体验做到顺手
界面设计不花哨,但每个控件的位置都经过实际调试场景的考量。我采用的是上下结构:上面是日志显示区域,中间是快捷指令区,下面是发送输入框和控制按钮。日志区用TextView配合ScrollView实现,收到数据就追加显示,并滚动到底部。调试的时候如果日志滚不到底,一屏一屏往回翻会非常痛苦。
发送功能上我做了三件事。第一,支持ASCII与hex两种编码切换。调试单片机时经常需要直接发十六进制帧,比如发AA 55 01 FF这种格式,做了切换后不用自己心算转换。第二,支持追加回车换行,可以自定义发\r\n、\r或\n,很多模块的AT指令必须要以回车结尾才能被识别。第三,做了定时循环发送功能,可以设置发送间隔,做压力测试或者连续查询传感器数据时特别好用。
日志区我也做了接收格式切换,ASCII模式直接显示字符串,hex模式按十六进制显示。这两种模式会在主控端不同的数据格式下切换使用,比如主控发的是字符串状态信息,ASCII模式看起来更直观;发的是传感器原始帧,hex模式更容易分析数据字节规律。测试下来,几个G的数据收发都没出现过界面卡死或者内存溢出的问题,这得益于日志区做了最大行数限制,超了自动裁剪最老的行。
5. 实测高频故障的完整排查链路
再好的源码放到不同手机上跑,都会遇到各种环境问题。这一节我把实际项目中遇到频率最高的几个坑逐一展开,每条都附上排查思路和解决方案,这些也是这套源码不断迭代后沉淀下来的经验。
5.1 扫描不到设备,问题不一定出在蓝牙本身
现象是列表始终为空,但目标设备确确实实在广播。排查链路我认为要按照下面的顺序来:
- 先确认设备是否开启了可被发现模式。有些蓝牙模块默认关闭广播,需要先通过按键或AT指令开启。
- 再确认手机端的定位权限是否已经在运行时授予,而不是只在Manifest里声明。这是一个极高频的问题,Android 6.0以后如果运行时权限没授权,扫描接口直接静默失败。
- 确认目标设备是否已经配对。如果已经配对过,它不会再出现在新设备扫描结果里,必须从已配对列表里找。
- 最后确认设备类型。如果对方是一个仅支持BLE的外设,
startDiscovery()是扫不到它的,需要走BLE扫描BluetoothLeScanner的路径,这不是SPP调试工具的适用范围。
顺着这条链路走完,90%的扫描问题都能定位。我自己Debug的时候曾经卡在第二步一整天,后来才发现权限对话框被用户点了拒绝后系统不再自动弹出,必须手动去设置里开。
5.2 连接成功但收不到数据,卡在读流阻塞
连接成功了,指令也发出去了,但日志区就是没反应。这种情况我遇到过的本质原因有两种:一种是连接建立后BluetoothSocket.getInputStream()被多个线程同时调用,导致其中一个读流阻塞;另一种是主控端根本没有回数据,收发时序不对。
排查的时候先用手机串口助手连接电脑端的虚拟串口做交叉验证,排除主控端不回数据的情况。确认主控端有数据回传后,检查代码里有没有在连接成功后多次重复调用getInputStream()。这里是严格的单一职责原则——只在连接成功时获取一次输入流,之后的操作全部基于这个流引用。我这套源码里已经把读流的初始化放在连接成功的回调里,并且加了一个原子锁防止重复初始化。
5.3 连接后立刻断开,多半是UUID或Service Discovery的问题
连接成功的瞬间又被系统断开,或者connect()直接抛Service discovery failed异常。根因几乎都指向UUID不匹配。虽然标准SPP的UUID是00001101-0000-1000-8000-00805F9B34FB,但部分自研设备用的不是这个值,而是厂商自定义的UUID。
解决思路分两步。第一步,从设备端或硬件文档里确认它使用的SPP服务UUID到底是什么;第二步,将源码里的SPP_UUID常量替换成目标UUID。在芯片端比如ESP32上配置BLE服务的时候,可以在代码里自定义UUID值,两端保持一致即可。如果设备文档也没写清楚,可以使用蓝牙协议分析工具扫描服务记录,把设备提供的UUID列表导出来对比。
connect()抛Service discovery failed还有一个背景因素是连接超时时间太短,某些模块响应慢,超时机制判断为失败。这种情况下可以尝试连到设备后不立刻发数据,等一两秒握手稳定再操作,我用这种方式救回过一些老旧的蓝牙2.0模块。
6. 源码延展:从SPP到BLE,这套调试助手的升级路径
这套SPP调试助手虽然已经很稳定,但现在的智能硬件越来越多走BLE协议,所以我也顺手研究了一下如何在它的基础上扩展成BLE调试工具。这个升级路径对于想继续深入蓝牙开发的读者来说,是很自然的下一步。
SPP和BLE在通讯机制上的核心差别在于:SPP是基于RFCOMM的串口模拟,数据像水流一样持续读写,逻辑简单;BLE则是基于GATT,数据要按Attribute来组织,通过Service和Characteristic来读写,而且每个Characteristic都有自己独立的UUID。所以扩展BLE支持时,界面上要多一个列表展示Service、Characteristic,对应关系可以这样列:
| 概念 | SPP | BLE |
|---|---|---|
| 连接模型 | RFCOMM通道 | GATT连接 |
| 核心标识 | 一个UUID | Service UUID + Characteristic UUID |
| 读写方式 | 流式读写 | 读写属性、通知机制 |
| 连接后动作 | 直接收发数据 | 必须先发现服务,再订阅通知 |
如果你要做BLE扩展,扫描部分需要把BluetoothLeScanner接入进来,连接部分不再用createRfcommSocket,而是用connectGatt,并且要处理onConnectionStateChange和onServicesDiscovered这一组回调。数据处理部分也需要为onCharacteristicChanged通知注册回调,否则收不到外设主动推送的数据。
从我实际测试的结果来看,把SPP调试助手改成同时支持BLE之后,就变成了一款全功能的通用蓝牙调试工具。同一部手机既能连接古老的HC-05模块,又能和当前主流的ESP32 BLE外设通讯,大大减少了电脑端和手机端来回切换的频率。
有一点要特别提醒,BLE外设如果开启了配对绑定(Bonding),要在连接前先处理配对流程,否则后续读写会不断报权限错误。这也是热搜词里"ble调试助手绑定(bond)"频繁出现的原因,实际操作中确实容易卡在这一步。源码里我留了配对监听的接口,扩展BLE时可以直接复用。
如果你后续打算把调试数据保存下来做协议分析,还可以在这个基础上加一个日志导出功能,把收发记录写成CSV或者TXT文件,方便在电脑上做深度字段分析。这个方向做下去,一个个人用的调试工具就会慢慢长成一个小型协议分析平台,这也是我亲手写这套源码之后最大的收获——调试工具不是买来就完事了,自己动手写一遍,对蓝牙协议栈的理解完全不一样。
本文还有配套的精品资源,点击获取