ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android经典蓝牙SPP调试助手源码解析与实战

Android经典蓝牙SPP调试助手源码解析与实战 简介Android蓝牙调试助手完整工程源码面向有Java基础、需要深入蓝牙通信的开发者既适合自学进阶也可作为毕业设计或物联网项目的实用蓝牙模块参考。压缩包共53个文件整体约116KB包含5个Java源文件、9个XML布局与资源文件、21个编译后的class文件以及APK、JAR、DEX等构建产物并附有工程配置与资源索引目录结构规范可直接导入IDE查看。项目围绕Android Bluetooth API展开通过BluetoothAdapter实现蓝牙开关和扫描用BluetoothDevice与BluetoothSocket建立RFCOMM串口连接再借助InputStream/OutputStream完成数据读写同时采用BroadcastReceiver监听蓝牙状态广播涉及权限申请、BLE低功耗扫描与Gatt连接、多线程I/O处理、异常定位和性能调优等实践。已有1026人学习下载借助源码可完整理解从设备发现到数据收发的链路也能掌握系统服务交互与模块化设计方法方便二次开发时复用其中的连接和数据传输模块。1. 为什么调试蓝牙传感器时需要一个自己写的调试助手在 Android 上做外设联调第一反应通常是装一个现成的蓝牙串口工具。但当你手里是一块自定义协议的传感器模块或者设备端固件还在频繁改通信帧时通用工具往往只能发固定格式也没法按时间戳记录收发数据。这个蓝牙调试助手源码的价值就是给你一套可以随时改的经典蓝牙 SPP 调试端。它覆盖了从BluetoothAdapter扫描、设备配对、RFCOMM socket 建立到输入输出流双向收发和界面回显的完整链路。拆完这套源码后最大的感受是难点并不在 API 本身而在 UUID 怎么选、读线程怎么退出、广播状态怎么同步到界面这些容易被忽略的边界。后面几章按实际调试顺序逐个讲清楚最后给出一份可直接抄的排错清单和日志回放方案。2. 蓝牙通信链路BluetoothAdapter 扫描、配对与 RFCOMM Socket 建立解压后先注意工程结构.project、proguard.cfg、gen这些是 Eclipse 时代的产物直接拿 Android Studio 打开会卡在 Gradle 同步。常见做法是新建一个空工程把src、res、AndroidManifest.xml拷进去再补一份build.gradle指定compileSdk与minSdk。老工程迁移本身不算难但它决定了你后面能不能顺利跑权限和广播流程。2.1 权限与最小版本先过 Manifest 这一关把工程导入 Android Studio 后第一件事不是看界面是打开 AndroidManifest.xml 核对权限。原因是 Android 12API 31开始蓝牙权限被拆成了运行时权限老项目里只写BLUETOOTH和BLUETOOTH_ADMIN在新设备上会直接抛SecurityException而且不会弹授权窗口Logcat 里只留一行权限拒绝记录。uses-permission android:nameandroid.permission.BLUETOOTH/ uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN/ uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION/ uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation/ uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT/参数说明BLUETOOTH负责常规连接与数据收发BLUETOOTH_ADMIN负责扫描与开关蓝牙ACCESS_FINE_LOCATION是 Android 6 到 11 之间扫描蓝牙所必需的位置权限。neverForLocation表示扫描结果不用于推导地理位置部分机型看到这个标记会放宽对定位开关的要求。代码里要按 SDK 版本做两次运行时授权请求一次覆盖 23 到 30一次覆盖 31 及以上否则新旧设备总有一个场景点不开扫描。2.2 扫描设备startDiscovery 与 ACTION_FOUND设备发现是异步的不少新手把startDiscovery()当成同步方法调用完立刻遍历设备列表结果什么都拿不到。正确姿势是注册一个BroadcastReceiver监听BluetoothDevice.ACTION_FOUND系统每发现一个设备就发一次广播设备对象放在intent的EXTRA_DEVICE里。private final BroadcastReceiver scanReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { if (!BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) return; BluetoothDevice device intent .getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); if (device ! null !deviceList.contains(device.getAddress())) { deviceList.add(device.getAddress()); deviceNameList.add(device.getName()); adapter.notifyDataSetChanged(); } } };这段逻辑里有两个点要留意。getAddress()是设备唯一标识用字符串列表去重避免同一次扫描中同一设备触发多次ACTION_FOUND导致列表重复刷新。getName()可能为空需要在列表适配器里做空值兜底否则setText(null)会显示为一片空白。启动扫描前最好先判断isDiscovering()为真就cancelDiscovery()再重新启动否则部分 ROM 会忽略第二次扫描请求按钮点了没反应。2.3 RFCOMM SocketUUID 匹配决定能不能连上连接前先撤销扫描是常见做法因为扫描和 RFCOMM 连接会竞争蓝牙芯片资源带着扫描跑connect()概率性失败。然后调用createRfcommSocketToServiceRecord()创建 socket这个方法的参数是 UUID必须与设备端服务端保持一致。串口透传模块普遍走 SPP 协议对应标准 UUID 是00001101-0000-1000-8000-00805F9B34FB。private static final UUID SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB); private void connectDevice(String address) throws IOException { BluetoothManager manager (BluetoothManager) getSystemService(BLUETOOTH_SERVICE); BluetoothAdapter bluetoothAdapter manager.getAdapter(); if (bluetoothAdapter ! null bluetoothAdapter.isDiscovering()) { bluetoothAdapter.cancelDiscovery(); } BluetoothDevice device bluetoothAdapter.getRemoteDevice(address); BluetoothSocket socket device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect(); // 阻塞调用必须放到子线程 handleConnectedSocket(socket); }参数说明address是扫描阶段拿到的 MAC 地址字符串createRfcommSocketToServiceRecord返回一个未连接的BluetoothSocket真正建立链路的是connect()。connect()会阻塞当前线程直到连接成功或超时放在主线程会触发 ANR所以整套流程建议包在AsyncTask的doInBackground或专用线程里执行。源码里如果出现了BluetoothGatt、BluetoothLeScanner这类类那才涉及 BLE当前这个资源的重点在经典蓝牙 SPPBLE 的回调模型和这里完全不同先不要混着学。3. 收发数据InputStream/OutputStream 读写线程与粘包处理RFCOMM 建立后拿到的是双向字节流逻辑上和 TCP socket 的用法接近一端read()阻塞等待数据另一端write()发出请求。真正的工程难点不在 API而在读线程的生命周期和底层字节流没有帧边界这两个问题上。这一章给出可以直接抄走的线程模型与极简帧协议。3.1 读线程的阻塞与退出机制InputStream.read()是阻塞的没有数据时线程挂住不返回。非常典型的崩溃场景是用户点了断开按钮主线程关闭 socket读线程还在read()里堵着socket 关闭后read()抛IOException如果把它当成普通异常处理就会重复打印无意义报错。private volatile boolean isClosing false; private void startReadLoop(BluetoothSocket socket) { Thread reader new Thread(() - { byte[] buffer new byte[1024]; try (InputStream in socket.getInputStream()) { int len; while (!isClosing (len in.read(buffer)) ! -1) { byte[] data new byte[len]; System.arraycopy(buffer, 0, data, 0, len); Message msg mHandler.obtainMessage(MSG_RECEIVE, data); msg.sendToTarget(); } } catch (IOException e) { if (!isClosing) Log.e(TAG, read error, e); } }, bluetooth-reader); reader.start(); }逻辑说明buffer是复用缓冲区每次读到数据后立刻arraycopy出定长数组避免后续写入覆盖前一帧内容data通过Message.obj交给Handler送到主线程不要在读取线程里直接改TextView。isClosing是退出标志关闭 socket 前先置为 true这样read()抛出的IOException会被静默吞掉避免正常断连打出一堆红色堆栈。这套设计在查看源码时最容易看出作者线程功底也是整个项目里最值得学习的一段。3.2 发送数据同步写与帧边界约定发送方向相对简单从输入框拿到内容转成字节数组write()出去即可。但多线程同时调用write()会把两个半帧交错在一起所以常见做法是给写操作加synchronized保证同一时刻只有一个发送者占用输出流。SPP 底层是字节流不像 UDP 有天然消息边界两端如果不约定帧格式接收方根本判断不出一条指令在哪结束。public synchronized void send(byte[] payload) throws IOException { OutputStream out socket.getOutputStream(); byte[] frame new byte[payload.length 2]; frame[0] (byte) 0xAA; // 帧头 System.arraycopy(payload, 0, frame, 1, payload.length); frame[frame.length - 1] (byte) 0x55; // 帧尾 out.write(frame); out.flush(); mHandler.obtainMessage(MSG_TX_COUNT, payload.length, 0).sendToTarget(); }帧头0xAA、帧尾0x55是最简单的切帧方式适合设备端是单片机这类资源受限的场景逐字节解析状态机实现成本最低。另一种常见做法是两字节长度头加内容适合负载可能包含0x55的二进制场景。参数里payload.length通过msg.arg1传递是为了在界面上累加发送字节数如果没有这一步进度条和计数永远停在一个固定值上用户根本看不出数据有没有真正发出去。3.3 发送队列与背压控制连续点击发送按钮时大量数据帧会堆积在OutputStream的内部缓冲区极端情况下直接 OOM。我一般在发送方法外层包一个计数判断待发送字节数超过阈值例如 4KB就丢弃后续请求并提示用户而不是继续入队。提示发送大文件时不要在主线程调用write()改用独立发送线程加队列。每帧 256 字节、间隔 20ms 是一组比较保守的参数具体数值以对端设备缓冲能力为准调试初期从慢到快逐步加压。帧头帧尾方案在文本指令场景下还有一个好处Logcat 里直接按 ASCII 打印就能肉眼对帧不需要额外写解析器。后面第 5 章会在此基础上叠加日志回放把每一次收发都落到文件里。4. BroadcastReceiver 监听状态与连接异常排错从空指针到瞬时断连蓝牙的状态变化、新设备发现、配对结果全部通过系统广播上抛。项目里比较典型的问题是 Activity 旋屏重建时重复注册广播或者注册了忘记反注册导致内存泄漏。这章先把广播模型讲透再给一张高频排错表覆盖我实际调试时遇到的绝大多数情况。4.1 状态广播的动态注册与反注册ACTION_STATE_CHANGED反映蓝牙开关状态ACTION_FOUND是扫描结果ACTION_BOND_STATE_CHANGED是配对状态变化。从 Android 8.0 开始隐式广播基本不能靠 manifest 静态注册接收必须在代码里动态注册所以统一做成一个BroadcastReceiver实例在onResume注册、onPause反注册最省事。private final BroadcastReceiver btReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) { int state intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR); updateBluetoothStateUI(state); } else if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) { BluetoothDevice device intent .getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); if (device ! null) updateBondUI(device.getBondState()); } } };解释一下参数EXTRA_STATE取值是STATE_ON、STATE_OFF、STATE_TURNING_ON等界面按状态切换按钮可用性device.getBondState()返回BOND_BONDED或BOND_NONE决定连接按钮是直连还是先引导用户进系统设置配对。这段代码放在 Activity 里时要特别注意getParcelableExtra的返回值判空部分 ROM 在快速切换蓝牙开关时会发出不带EXTRA_DEVICE的广播不判空就是空指针崩溃。4.2 高频排错清单现象常见原因处理方式扫描不到目标设备缺位置权限目标设备未进入可被发现模式扫描缓存未清理确认运行时授权在设备端重新进入配网模式调用startDiscovery前先cancelDiscoveryconnect()抛 IOExceptionUUID 不匹配远程设备不在范围旧 socket 未释放核对 SPP UUID确认设备距离重建连接前先close()旧 socket连上后立刻断开读取线程异常退出对端主动关闭未撤销扫描导致链路不稳定置isClosingfalse后重连检查对端固件是否要求先发握手帧收到的数据是乱码误把字节流按字符串显示或编码假设错误先切 HEX 视图确认链路再按 UTF-8/GBK 逐个试已配对但连接失败配对信息失效或地址变化取消配对后重新扫描配对并核对getAddress()是否变化这张表的使用顺序很重要先切 HEX 视图判断是链路问题还是编码问题再查 UUID 和服务端固件最后才怀疑模块硬件。很多连上就断的案例最后定位到的是读取线程写了return导致线程退出socket 被系统回收和蓝牙模块本身没有任何关系。4.3 日志过滤与 socket 安全关闭排查连接问题时Logcat 里直接过滤BluetoothSocket相关标签效率最高常见做法是adb logcat -s BluetoothSocket:E BluetoothDevice:V能看到connect()阶段的错误码和底层状态迁移。关闭连接的代码必须放进finally或 try-with-resources并且先把isClosing置 true再关闭输入输出流最后关闭 socket。private void safeClose() { isClosing true; try { if (inputStream ! null) inputStream.close(); } catch (IOException ignored) {} try { if (outputStream ! null) outputStream.close(); } catch (IOException ignored) {} try { if (socket ! null) socket.close(); } catch (IOException ignored) {} }这里的关键点是关闭顺序先置标志位避免读线程把正常关闭当成异常再关流、再关 socket顺序反过来会先触发read()抛异常日志里多一条噪音。这种写法在源码学习里属于基本功但实际很多蓝牙项目就是因为少写了isClosing标志位导致断连日志永远报错问题真相被淹没。5. 把连接状态和收发计数同步到 UIHandler 消息契约与回放验证蓝牙线程不能直接改界面所以整套源码的核心 UI 更新都走Handler。我建议把消息编码固定成一套契约what表示事件类型arg1传字节数obj传字节数组这样连接、接收、发送、错误四类事件都能覆盖。5.1 Handler 消息契约与进度条联动private final Handler uiHandler new Handler(Looper.getMainLooper()) { Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_CONNECTED: tvStatus.setText(已连接); btnConnect.setEnabled(false); break; case MSG_RECEIVE: byte[] data (byte[]) msg.obj; appendHexData(data); break; case MSG_TX_COUNT: progressBar.setMax(totalTx); progressBar.setProgress(progressBar.getProgress() msg.arg1); break; } } };MSG_TX_COUNT这条消息的arg1就是上一章发送方法里传的payload.length每次发送后进度条递增这样用户能直观看到流量走了多少。接收方向要注意节流高频数据帧直接setText会导致主线程卡顿合理做法是每秒刷新一次缓冲区或者只显示最后 4KB 内容。msg.obj传byte[]时要保证数组是新创建的因为Message会复用于线程池旧数组被二次改写后界面显示会跳变。5.2 用私有目录记录收发会话并回放联调时设备偶发掉线靠肉眼盯屏幕不现实。我一般的做法是连接建立时打开FileOutputStream按「时间戳 方向 十六进制数据」追加写入日志落在应用私有目录路径通常对应Android/data/包名/files下这样既不依赖外部存储权限也符合 Android 11 分区存储要求。复现问题时把这个文件取出来把每一帧按分隔符拆开重新喂给解析器就能区分是协议设计缺陷还是时序问题。最后一处细节值得收进工程里连接断开后把 socket 的关闭操作和 UI 状态更新放进同一个finally块确认isClosing标志位复位再允许下一次重连。按下连接按钮前先执行一次safeClose()就能避免快速重连时出现的socket already closed或读线程泄漏。本文还有配套的精品资源点击获取
返回列表