
简介这是一份面向Android蓝牙通信开发者的串口调试助手源码适合需要测试蓝牙设备、验证串口数据传输的开发者与学习者使用。源码围绕BluetoothAdapter、BluetoothSocket与RFCOMM通道展开涵盖设备扫描、配对连接、数据收发、连接状态监听等核心流程并涉及Handler异步处理、权限声明与Logcat调试等实践要点对理解Android蓝牙通信机制有较高参考价值。资源包共38个文件包含17个class、8个xml、4个java及apk、dex等涵盖源码、布局、资源与可运行安装包整体约80KB结构紧凑便于快速导入分析。目前已有1446人学习下载。通过阅读与修改源码读者可掌握蓝牙连接建立、数据交换与异常处理思路并可按需扩展加密传输、连接稳定性优化或更多蓝牙协议支持为自身应用添加蓝牙功能提供可复用的基础模板。1. 蓝牙串口调试助手从源码到真机跑通的完整路径做嵌入式或者硬件联调的人手里大概率都备着一个蓝牙串口调试工具。手机端打开 App连上 HC-05、HC-06 这类经典蓝牙模块直接收发十六进制指令比抱着电脑插 USB 转 TTL 方便太多。但市面上现成的调试助手要么广告多要么不支持自定义波特率和数据位要么连上就断。这份 Android 蓝牙串口调试助手源码解决的就是「我想自己改一个趁手工具」的问题——它基于 Android 经典蓝牙 SPP 协议实现覆盖设备扫描、配对、连接、数据收发、十六进制显示与发送、日志导出等核心功能适合有 Android 基础、需要跟蓝牙模块打交道的嵌入式工程师、硬件测试人员和物联网方向的学生。源码结构清晰没有过度封装拿到手就能编译运行改起来也不费劲。2. 经典蓝牙 SPP 协议为什么不用 BLE以及源码里的连接链路2.1 SPP 与 BLE 的选型分水岭很多人一上来就问「为什么不用 BLE」这个问题在蓝牙串口场景里几乎每次都要解释一遍。经典蓝牙Bluetooth Classic的 SPPSerial Port Profile本质上是在蓝牙链路上模拟一条串口走的是 RFCOMM 协议对上层应用来说就是「打开一个 Socket往里写字节从里读字节」。而 BLE低功耗蓝牙走的是 GATT 协议数据以 Characteristic 为单位组织收发都要经过 Service UUID、Characteristic UUID 的层层查找还要处理 MTU 协商、Notify 开关、连接间隔等一堆参数。对于 HC-05、HC-06、JDY-31 这类模块它们默认跑的就是 SPP你拿 BLE 的 API 去连根本连不上。反过来如果你用的是 CC2541、nRF52 这类 BLE 模块那 SPP 也帮不了你。所以选型的第一刀就切在这里看你的蓝牙模块是经典蓝牙还是 BLE。这份源码走的是经典蓝牙路线对应的就是那些「配对码 1234 或 0000」的老牌串口模块。源码里连接链路的核心逻辑在BluetoothChatService这个类里它继承自Thread内部维护了一个BluetoothSocket。连接过程分三步先通过BluetoothAdapter.getRemoteDevice(address)拿到远端设备对象再调用createRfcommSocketToServiceRecord(SPP_UUID)创建 Socket最后socket.connect()发起连接。这里的SPP_UUID是固定的00001101-0000-1000-8000-00805F9B34FB所有经典蓝牙串口设备都用这个 UUID不需要改。// BluetoothChatService.java 中的连接线程核心片段 private class ConnectThread extends Thread { private final BluetoothSocket mmSocket; private final BluetoothDevice mmDevice; public ConnectThread(BluetoothDevice device) { mmDevice device; BluetoothSocket tmp null; try { // SPP UUID 固定所有经典蓝牙串口设备通用 tmp device.createRfcommSocketToServiceRecord(MY_UUID); } catch (IOException e) { Log.e(TAG, Socket 创建失败, e); } mmSocket tmp; } public void run() { // 连接前必须取消扫描否则会拖慢甚至阻塞连接 mAdapter.cancelDiscovery(); try { mmSocket.connect(); } catch (IOException connectException) { try { mmSocket.close(); } catch (IOException closeException) { Log.e(TAG, Socket 关闭异常, closeException); } return; } // 连接成功交给 ConnectedThread 管理数据流 synchronized (BluetoothChatService.this) { mConnectThread null; } connected(mmSocket, mmDevice); } }这段代码里有两个参数值得注意。MY_UUID就是上面说的 SPP UUID不要改成 BLE 的 UUID否则createRfcommSocketToServiceRecord会直接抛异常。mAdapter.cancelDiscovery()这一行是血泪经验——如果不取消扫描就发起连接在部分机型上连接成功率会明显下降因为扫描和连接共用射频通道互相抢资源。2.2 数据收发线程与流式读写连接建立之后ConnectedThread接管数据流。它从BluetoothSocket拿到InputStream和OutputStream然后在一个while(true)循环里阻塞读取。读到的字节通过Handler发回 UI 线程更新到接收区的TextView或RecyclerView里。// ConnectedThread 中的数据读取循环 public void run() { byte[] buffer new byte[1024]; int bytes; while (true) { try { // 阻塞读取直到有数据到达或连接断开 bytes mmInStream.read(buffer); // 将读到的字节数组通过 Handler 发回主线程 mHandler.obtainMessage(MESSAGE_READ, bytes, -1, buffer) .sendToTarget(); } catch (IOException e) { Log.e(TAG, 连接断开, e); connectionLost(); break; } } } // 写入方法供 UI 线程调用 public void write(byte[] buffer) { try { mmOutStream.write(buffer); // 把发送的数据也回显到界面 mHandler.obtainMessage(MESSAGE_WRITE, -1, -1, buffer) .sendToTarget(); } catch (IOException e) { Log.e(TAG, 写入失败, e); } }缓冲区大小设的是 1024 字节对于串口调试场景够用了。如果你要传大块数据比如固件升级包可以调到 4096 甚至 8192但要注意部分低端模块的射频缓冲区有限一次写太多会丢包。写入方法里把发送数据也回显到界面这是调试助手的标配——你得看到自己发了什么才能对照设备返回判断问题。2.3 权限声明与运行时申请Android 6.0 之后蓝牙权限从安装时授予变成了运行时申请。源码的AndroidManifest.xml里声明了BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION三个权限。前两个是普通权限安装即授予ACCESS_FINE_LOCATION是危险权限必须动态申请因为蓝牙扫描可以推断用户位置。!-- AndroidManifest.xml 中的蓝牙权限声明 -- uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- Android 6.0 起扫描蓝牙设备需要定位权限 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / !-- Android 12 起新增的蓝牙权限按需添加 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /如果你的目标机型是 Android 12 及以上BLUETOOTH_SCAN和BLUETOOTH_CONNECT必须加上否则扫描和连接会直接失败。neverForLocation这个标志告诉系统「我扫描蓝牙不是为了定位」可以避免申请定位权限但前提是你的 App 确实不拿扫描结果推断位置。源码里保留了定位权限的申请逻辑兼容老版本。3. 从源码到真机编译、配对、收发指令的完整操作链3.1 导入工程与 Gradle 配置调整拿到源码后第一步是用 Android Studio 打开工程根目录。源码的build.gradle里compileSdkVersion和targetSdkVersion可能比较旧比如 28 或 29。如果你用的是新版 Android StudioGradle 插件版本需要同步升级否则会报「Gradle 版本不兼容」。// app/build.gradle 中需要关注的配置项 android { compileSdkVersion 33 // 建议升到 33 或更高 defaultConfig { minSdkVersion 21 // 最低支持 Android 5.0 targetSdkVersion 33 // 目标版本影响权限行为 // ... } } dependencies { // 源码可能用了 support 库建议迁移到 AndroidX implementation androidx.appcompat:appcompat:1.6.1 implementation androidx.recyclerview:recyclerview:1.3.0 }minSdkVersion设 21 是合理的再低会缺少一些蓝牙 API。targetSdkVersion建议跟compileSdkVersion保持一致否则 Android 12 以上的权限行为会变得很诡异。如果源码用的是android.support.v7老库在 Android Studio 里右键工程 → Refactor → Migrate to AndroidX 一键迁移省得手动改包名。3.2 真机配对与连接的操作顺序编译通过后装到手机上操作顺序有讲究。先把蓝牙模块上电确认指示灯在闪烁说明未配对。然后打开手机系统设置 → 蓝牙搜索到模块名称通常是 HC-05 或类似点击配对输入配对码 1234 或 0000。配对成功后模块指示灯变成慢闪或常亮。这时候再打开调试助手 App点击「扫描设备」在列表里找到已经配对的模块点击连接。注意不要在 App 里直接配对因为 App 内的配对流程在部分机型上会弹不出配对框先走系统设置配对更稳妥。连接成功后App 界面上的连接状态会变成「已连接」接收区开始等待数据。3.3 十六进制收发与常用 AT 指令验证调试助手的核心功能是十六进制收发。源码里有一个HexUtil工具类负责字符串和字节数组之间的转换。发送区输入01 03 00 00 00 01勾选「十六进制发送」点击发送按钮底层会把空格去掉、每两个字符解析成一个字节然后通过OutputStream写出去。// HexUtil.java 中的十六进制字符串转字节数组 public static byte[] hexStringToBytes(String hexString) { if (hexString null || hexString.equals()) { return null; } // 去掉空格和换行统一大写 hexString hexString.replaceAll( , ).toUpperCase(); int length hexString.length() / 2; char[] hexChars hexString.toCharArray(); byte[] d new byte[length]; for (int i 0; i length; i) { int pos i * 2; // 高四位和低四位分别解析后合并 d[i] (byte) (charToByte(hexChars[pos]) 4 | charToByte(hexChars[pos 1])); } return d; } private static byte charToByte(char c) { return (byte) 0123456789ABCDEF.indexOf(c); }这个转换逻辑很直白但有一个坑如果输入的十六进制字符串长度是奇数比如1 03length算出来是 0直接返回空数组。所以发送前要保证每个字节都是两位不够的补零。接收方向同理bytesToHexString把字节数组转成带空格的十六进制字符串显示方便肉眼比对。验证连接是否正常最常用的方法是发 AT 指令。对于 HC-05发送AT\r\n十六进制是41 54 0D 0A如果模块返回OK说明链路通了。注意 AT 指令末尾的\r\n不能省很多新手只发AT两个字符然后纳闷为什么没反应。3.4 日志导出与数据回放源码里还带了一个简单的日志导出功能把接收区的数据按时间戳写入文件存到Environment.getExternalStorageDirectory()下的某个目录里。这个功能在长时间跑数据采集时很有用——你不可能一直盯着屏幕导出日志后用脚本分析更靠谱。// 日志写入的简化逻辑 public void saveLog(String data) { File logFile new File(Environment.getExternalStorageDirectory(), BtSerialLog_ System.currentTimeMillis() .txt); try { FileWriter writer new FileWriter(logFile, true); // 每行前面加时间戳方便回放时对齐 writer.write(new SimpleDateFormat(HH:mm:ss.SSS).format(new Date()) data \n); writer.close(); } catch (IOException e) { Log.e(TAG, 日志写入失败, e); } }Android 10 之后getExternalStorageDirectory()的行为变了需要改用getExternalFilesDir()或者申请MANAGE_EXTERNAL_STORAGE权限。源码如果没处理这个在 Android 10 以上的机器上导出会失败。常见做法是改成getExternalFilesDir(null)路径变成Android/data/包名/files/不需要额外权限。4. 避坑与排查连接失败、乱码、断连的五个真实翻车现场4.1 扫描不到设备列表一直是空的现象打开 App 点击扫描等了十几秒列表里一个设备都没有但系统蓝牙设置里能看到模块。原因最常见的是定位权限没给。Android 6.0 到 Android 11 之间扫描蓝牙设备必须要有ACCESS_FINE_LOCATION权限而且用户必须手动在设置里打开定位开关不是权限是系统级的定位总开关。另一个原因是BLUETOOTH_SCAN权限在 Android 12 以上没声明。解决检查AndroidManifest.xml里的权限声明然后在 App 首次启动时动态申请定位权限。如果用户拒绝了弹一个对话框解释为什么需要引导去设置页手动开启。代码里可以用ActivityCompat.checkSelfPermission判断用ActivityCompat.requestPermissions申请。4.2 连接时抛出Service discovery failed现象点击设备连接日志里报java.io.IOException: Service discovery failed连接直接断开。原因这个异常通常意味着 SPP UUID 不对或者模块根本不支持 SPP。有些 BLE 模块虽然名字里带「串口」但走的是 GATT用createRfcommSocketToServiceRecord连不上。另一个可能是模块已经被其他设备连接了经典蓝牙 SPP 不支持多主连接。解决先确认模块型号查手册看是经典蓝牙还是 BLE。如果是经典蓝牙检查 UUID 是不是00001101-0000-1000-8000-00805F9B34FB。如果模块已被其他设备占用断开那个设备的连接再试。还有一个偏方调用createInsecureRfcommSocketToServiceRecord代替安全版本绕过配对验证但这样连接不安全只适合调试。4.3 接收到的数据是乱码现象设备明明返回的是 ASCII 字符串但接收区显示一堆问号或者方块。原因编码格式不匹配。源码默认按 UTF-8 解码但有些模块返回的是 GBK 编码或者你勾选了「十六进制显示」但设备发的是 ASCII。反过来设备发的是二进制数据你没勾十六进制按文本解码自然乱码。解决先确认设备输出的是文本还是二进制。文本的话在源码里把解码字符集改成GBK试试二进制的话勾选「十六进制显示」直接看字节。源码里new String(buffer, 0, bytes, UTF-8)这一行就是解码入口改字符集就在这里改。4.4 连接一段时间后自动断开现象连上后能正常收发但过几分钟或者手机息屏后连接就断了重新打开 App 才能连上。原因Android 的电源管理会在息屏后限制后台网络活动蓝牙 Socket 虽然不像 TCP 那样容易被杀但部分机型尤其是国产 ROM会主动断开长时间空闲的蓝牙连接。另外如果ConnectedThread的读取循环里抛了异常但没有正确处理线程退出也会导致连接丢失。解决在ConnectedThread的catch块里加断线重连逻辑或者至少发一个通知告诉用户连接断了。对于息屏断连可以申请WAKE_LOCK权限在连接期间持有唤醒锁但这样费电。更温和的做法是让设备端定期发心跳包保持链路活跃。4.5 Android 12 以上扫描直接崩溃现象在 Android 12 或 13 的手机上点击扫描按钮App 直接闪退日志里报SecurityException。原因Android 12 引入了新的蓝牙权限模型BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个权限必须动态申请。如果只在 Manifest 里声明了旧权限运行时检查会直接抛异常。解决在AndroidManifest.xml里加上BLUETOOTH_SCAN和BLUETOOTH_CONNECT并在代码里判断系统版本如果是 Android 12 及以上申请这两个新权限否则申请旧的ACCESS_FINE_LOCATION。源码如果没做这个兼容需要手动补上。// 按系统版本申请不同权限的兼容写法 if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { // Android 12 及以上 requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }, REQUEST_CODE); } else { // Android 11 及以下 requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE); }5. 进阶改造自定义协议解析与多设备切换的实用技巧源码跑通之后大多数人不会满足于「能收发就行」。实际项目里你面对的是带帧头、长度、校验位的自定义协议接收区里一堆十六进制数字靠肉眼找帧头效率太低。我一般会在ConnectedThread的读取循环里加一个环形缓冲区按协议格式自动切分数据帧解析出有效载荷再显示。// 简易环形缓冲区按帧头 0xAA 0x55 切分数据帧 private ByteArrayOutputStream frameBuffer new ByteArrayOutputStream(); private void parseFrame(byte[] data, int length) { for (int i 0; i length; i) { frameBuffer.write(data[i]); byte[] buf frameBuffer.toByteArray(); // 检查是否以帧头开头 if (buf.length 2 (buf[0] 0xFF) 0xAA (buf[1] 0xFF) 0x55) { // 帧头正确继续等长度字段 if (buf.length 4) { int payloadLen buf[2] 0xFF; int frameLen 4 payloadLen 1; // 帧头2 长度1 载荷 校验1 if (buf.length frameLen) { // 完整帧到达提取载荷 byte[] payload new byte[payloadLen]; System.arraycopy(buf, 3, payload, 0, payloadLen); // 校验和验证 byte checksum 0; for (byte b : payload) checksum ^ b; if (checksum buf[frameLen - 1]) { // 校验通过发到 UI 显示 mHandler.obtainMessage(MESSAGE_FRAME, payload).sendToTarget(); } // 移除已处理的帧保留剩余数据 frameBuffer.reset(); frameBuffer.write(buf, frameLen, buf.length - frameLen); } } } else if (buf.length 0 (buf[0] 0xFF) ! 0xAA) { // 首字节不是帧头丢弃 frameBuffer.reset(); } } }这段代码的关键在于不要假设一次read就能读到完整帧。蓝牙串口的传输是流式的一帧数据可能分两次到达也可能两帧粘在一起。环形缓冲区的作用就是攒够一帧再解析攒不够就继续等。校验和用异或是最简单的实际项目里可能是 CRC16把校验逻辑换掉就行。另一个实用改造是多设备切换。源码默认只维护一个BluetoothSocket连新设备前必须先断开旧的。如果你需要在多个模块之间轮询可以维护一个MapString, BluetoothSocket按 MAC 地址索引切换时只切换ConnectedThread的读写目标。但要注意经典蓝牙同时只能维持有限个连接一般不超过 7 个实际用起来 2 到 3 个就差不多了。最后说一个我踩过的坑不要在onDestroy里只调socket.close()就完事。ConnectedThread的while(true)循环如果还在阻塞读close()之后read()会抛IOException线程才退出。如果你先cancel()线程再close()顺序反了可能导致 Socket 泄漏。正确的顺序是先cancel()中断线程再close()释放 Socket最后把引用置空。从那以后我每次改蓝牙连接逻辑都强制走一遍「连接 → 收发 → 断开 → 重连」的完整流程确认没有残留线程和 Socket 才提交代码。希望帮到你。本文还有配套的精品资源点击获取