Java蓝牙Demo实战:扫描连接收发与重连避坑指南

发布时间:2026/10/26 13:52:22
Java蓝牙Demo实战:扫描连接收发与重连避坑指南

简介:面向Java蓝牙开发初学者与物联网应用实践者的入门示例包,围绕桌面端蓝牙编程中JSR-82支持不足、需借助BlueCove等第三方库的问题,提供可运行的设备发现、连接建立、数据传输、服务注册与安全处理参考。压缩包为rar格式,共17个文件、约826KB,以5个java源码和6个class文件为核心,辅以2个jar依赖、2个prefs及classpath、project等工程配置,便于在Eclipse中导入后对照学习。已有2415人学习下载。读者可借此理解DiscoveryAgent、RemoteDevice、RFCOMM/L2CAP通信及异常处理等关键环节,并参考BlueCove集成方式完成本地蓝牙调试,适合作为智能家居、健康监测、无线音频等场景的起步Demo。

1. 先别急着连设备:Java 蓝牙开发 Demo 的边界与适用场景

第一次拿到这份 java 蓝牙 demo,是在一个“设备明明配对成功、Java 端却始终读不到数据”的现场。很多人把蓝牙当成一个简单的 socket,真跑起来才发现扫描、配对、通道、UUID、权限、分包、重连这些环节各有一道坎。这份 Demo 的价值不在界面,而在把扫描、连接、收发、日志和异常恢复串成一条最小可复现链路,适合刚接触 Java 蓝牙的开发者,也适合需要快速验证硬件协议的老手。它的边界要说清:桌面 Java 常见做法是走 JSR-82 兼容实现,移动端多用系统原生 API,经典蓝牙 SPP 与 BLE GATT 也不是同一套调用模型。先接受这个边界,后面调参才不会把平台差异误判成代码 bug。

2. 拆开 Demo 看分层:从扫描、配对到 RFCOMM/GATT 的调用链

2.1 资源目录与入口类怎么读

下载包拿到手,先别从头到尾翻代码。Java 蓝牙 Demo 的阅读顺序应该是“入口类 → 设备发现 → 连接工厂 → 收发协议 → 日志与异常”。常见分层如下,目录名可能不同,但职责基本对应:

java-bt-demo/ src/main/java/.../App.java # 启动入口,读配置、装配流程 src/main/java/.../discovery/ # 扫描、设备缓存、配对状态 src/main/java/.../transport/ # RFCOMM/GATT 连接与会话 src/main/java/.../protocol/ # 帧头、长度、校验、粘包处理 src/main/java/.../logging/ # 状态迁移与收发十六进制日志

找到App.java后只看三件事:它从哪里读设备地址、用哪个 UUID 或通道号、连接失败后有没有重试。设备地址不要写死在代码里,常见做法是放到配置文件或启动参数;UUID 也不要散落在多个类里,集中成一个常量便于替换。读入口类时可以用关键词快速定位:

grep -RInE "LocalDevice|DiscoveryAgent|Connector.open|UUID|btspp" src/main/java

这条命令的作用是把蓝牙相关调用一次性捞出来,避免在业务代码里迷路。LocalDevice管本机适配器,DiscoveryAgent管异步发现,Connector.open管连接 URL,UUID和btspp决定通道类型。参数上重点看连接 URL 里的通道号和认证标志,它们比业务代码更容易导致“配对成功但连不上”。

2.2 扫描与设备发现的状态机

JSR-82 的设备发现是异步回调,不是同步返回列表。Demo 里通常会有一个状态机:空闲、发现中、已发现、连接中、已连接、断开。状态机不是为了好看,而是为了防止用户在扫描未结束时重复点击连接,也防止回调线程里做阻塞 IO。一个可抄的最小扫描骨架如下:

import javax.bluetooth.*; public class DemoDiscovery implements DiscoveryListener { private final DiscoveryAgent agent; private volatile boolean discovering = false; public DemoDiscovery() throws BluetoothStateException { this.agent = LocalDevice.getLocalDevice().getDiscoveryAgent(); } public void start() throws BluetoothStateException { if (discovering) return; // 防止重复进入发现流程 discovering = true; agent.startInquiry(DiscoveryAgent.GIAC, this); // GIAC: 通用查询 } @Override public void deviceDiscovered(RemoteDevice device, DeviceClass cod) { try { String address = device.getBluetoothAddress(); String name = device.getFriendlyName(false); // false: 不强制刷新 System.out.println("FOUND " + address + " " + name); } catch (Exception e) { System.out.println("FOUND " + device.getBluetoothAddress() + " name?"); } } @Override public void inquiryCompleted(int discType) { discovering = false; System.out.println("INQUIRY_DONE " + discType); } @Override public void servicesDiscovered(int transID, ServiceRecord[] records) {} @Override public void serviceSearchCompleted(int transID, int respCode) {} }

startInquiry的第一个参数常用GIAC,它会找周围可发现的设备;LIAC更受限,适合已知设备类型。getFriendlyName(false)的第二参数表示是否强制远端刷新名称,现场网络差时强制刷新会拖慢回调。回调线程不要直接做长连接,正确做法是把设备地址丢进队列,由工作线程发起连接。inquiryCompleted的返回码要记录,取消、超时、设备限制都会走到这里,不记录就只能靠猜。

2.3 连接通道选型:经典蓝牙 SPP 与 BLE GATT 的差别

Java 蓝牙 Demo 最容易选错的是通道模型。经典蓝牙的 SPP/RFCOMM 更像串口:建立流式连接后连续读写;BLE 的 GATT 是服务、特征、描述符模型,读写按特征值走,还要处理通知和 MTU。两者不是换一个类名就能迁移。选型时先看硬件协议文档,如果对方说“虚拟串口”“透传”“SPP”,基本是经典蓝牙;如果说“服务 UUID”“特征值通知”“MTU 协商”,那就是 BLE。

维度经典蓝牙 SPP/RFCOMMBLE GATT
数据形态流式字节,类似串口特征值读写/通知,按包交互
典型 URL/标识btspp://地址:通道服务 UUID + 特征 UUID
分包关注点粘包、帧头、长度字段MTU、通知包长度、分包重组
常见失败通道号错、配对状态异常UUID 错、未使能通知、MTU 未协商
适用场景持续透传、老设备兼容低功耗传感器、间歇上报

经典蓝牙连接 URL 常见写法如下,注意地址要去掉冒号,通道号以硬件文档为准:

String address = "001122334455"; // 示例地址,运行时从配置读取 String url = "btspp://" + address + ":1;authenticate=false;encrypt=false;master=false"; StreamConnection conn = (StreamConnection) Connector.open(url, Connector.READ_WRITE, true);

authenticate和encrypt不是越小越好,取决于对端安全要求;master表示是否要求主从角色,很多模块不接受强制主角色。连接失败先看 URL 语法和通道号,再看系统层是否已完成配对。BLE 侧则要按“扫描 → 连接 → 发现服务 → 找特征 → 使能通知 → 读写”的顺序走,任何一步跳过都会得到空数据而不是明确报错,这就是黑匣子感的来源。

3. 把 Demo 跑起来:环境准备、权限与最小可复现链路

3.1 环境与依赖的检查顺序

Java 蓝牙跑不起来,先别改代码,按“Java 运行时 → 蓝牙适配器 → 系统蓝牙服务 → Demo 配置”的顺序查。桌面端常见问题是机器有蓝牙图标但 Java 侧拿不到本地设备,或者适配器被系统禁用。检查命令不用多,关键是看输出:

java -version rfkill list systemctl status bluetooth --no-pager

java -version确认运行时可用;rfkill list看蓝牙是否被软/硬阻塞;系统服务状态确认后台没有停。JSR-82 依赖具体实现,Demo 的依赖里如果带了本地库,要确认本地库架构和当前运行时一致。参数上不要同时改三处:先固定设备地址,再固定通道号,最后才调超时。启动参数建议显式传入:

java -Dbt.address=001122334455 -Dbt.channel=1 -Dbt.timeoutMs=8000 -jar demo.jar

bt.address是目标设备地址,bt.channel是 RFCOMM 通道,bt.timeoutMs控制连接等待上限。地址和通道写错时,表现通常是连接超时而不是立即报错,所以日志里要打印最终拼出的 URL。超时不要设得太短,首次配对和服务发现可能超过两秒;也不要无限等,工作线程会被拖死。

3.2 权限与运行时开关

桌面 Java 的权限问题相对少,但系统蓝牙开关、配对白名单、适配器独占仍会拦人。移动端原生 API 的权限模型更细,扫描、连接、定位相关权限在不同系统版本上要求不同,后台扫描还会被限制。Demo 里如果只有业务代码没有权限检查,现场很容易出现“代码没问题、系统不给回调”。我一般会加一段启动自检,把本机适配器状态、可发现性、目标设备缓存情况打出来:

LocalDevice local = LocalDevice.getLocalDevice(); System.out.println("LOCAL_ADDR=" + local.getBluetoothAddress()); System.out.println("LOCAL_NAME=" + local.getFriendlyName()); System.out.println("DISCOVERABLE=" + local.getDiscoverable());

getDiscoverable()返回的是本机可被发现状态,不是目标设备状态,别混淆。自检通过后再进扫描,能排除一半“玄学”问题。权限配置不要照抄旧模板,按当前运行环境的实际权限模型逐项确认;后台运行场景要单独验证,前台能收数据不代表后台能收。

3.3 用一段最小代码验证扫描-连接-收发

最小链路只做四件事:连上、写一帧、读一帧、关掉。不要一上来就接业务协议。下面这段代码适合经典蓝牙透传验证,地址和通道从配置读取:

import javax.bluetooth.*; import javax.microedition.io.Connector; import javax.microedition.io.StreamConnection; import java.io.*; public class MiniRfcomm { public static void main(String[] args) throws Exception { String address = System.getProperty("bt.address").replace(":", ""); String channel = System.getProperty("bt.channel", "1"); int timeoutMs = Integer.parseInt(System.getProperty("bt.timeoutMs", "8000")); String url = "btspp://" + address + ":" + channel + ";authenticate=false;encrypt=false;master=false"; System.out.println("OPEN " + url); StreamConnection conn = (StreamConnection) Connector.open(url, Connector.READ_WRITE, true); conn.getClass(); // 保持连接对象,避免被优化掉 DataOutputStream out = conn.openDataOutputStream(); DataInputStream in = conn.openDataInputStream(); byte[] frame = new byte[]{0x01, 0x03, 0x00, 0x00, 0x00, 0x01}; out.write(frame); out.flush(); System.out.println("TX " + toHex(frame)); long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { if (in.available() > 0) { byte[] buf = new byte[in.available()]; int n = in.read(buf); System.out.println("RX " + toHex(buf, n)); break; } Thread.sleep(50); } in.close(); out.close(); conn.close(); } private static String toHex(byte[] data) { return toHex(data, data.length); } private static String toHex(byte[] data, int len) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < len; i++) sb.append(String.format("%02X ", data[i])); return sb.toString().trim(); } }

这段代码里,Connector.open的第三个参数true表示允许超时控制,实际超时仍受实现影响;out.flush()必须调用,否则小帧可能留在缓冲区;读侧用available()加截止时间,避免无限阻塞。frame只是示例帧,真实项目要按硬件协议替换。第一次验证不要发复杂指令,先发一条只读状态或回环指令,确认链路通了再叠加业务。

4. 参数别拍脑袋:UUID、MTU、超时与重连策略怎么设

4.1 UUID 与服务发现

经典蓝牙 SPP 常用标准 UUID,很多模块文档会直接给出;BLE 则要用服务 UUID 和特征 UUID。UUID 写错的表现很隐蔽:连接可能成功,但服务发现为空,或者读写返回失败。Demo 里应把 UUID 集中配置,并在日志里打印完整值,避免短 UUID 和 128 位 UUID 混用。下面是一个集中配置的写法:

import javax.bluetooth.UUID; public final class BtConfig { // 标准 SPP 服务 UUID,具体项目以硬件文档为准 public static final UUID SPP = new UUID("0000110100001000800000805F9B34FB", false); // BLE 自定义服务 UUID 示例,运行时从配置覆盖 public static final String BLE_SERVICE = System.getProperty( "ble.service", "0000FFF0-0000-1000-8000-00805F9B34FB"); public static final String BLE_NOTIFY_CHAR = System.getProperty( "ble.notifyChar", "0000FFF1-0000-1000-8000-00805F9B34FB"); }

new UUID(..., false)的第二参数表示是否按短 UUID 处理,标准 128 位值要传false。BLE 服务 UUID 常见 16 位短 UUID 展开成 128 位,日志里最好同时打印原始配置和展开结果。服务发现失败先核对 UUID,再核对设备是否已连接、是否已配对、是否使能通知。不要用“能连上”推断“服务对”,这是两个层面。

4.2 MTU 与分包

经典蓝牙 RFCOMM 是流式的,没有 BLE 那种 MTU 协商概念,但同样要处理粘包和半包;BLE 默认 MTU 常见为 23 字节,协商后可变大,实际可用负载还要减去协议开销。无论哪种通道,业务层都应有明确帧格式。下面是一个简单分包器,帧头0xAA55,带序号、长度和校验:

import java.io.ByteArrayOutputStream; import java.util.zip.CRC32; public class FrameCodec { private static final int MAX_PAYLOAD = 512; private int seq = 0; public byte[] encode(byte[] payload) { if (payload.length > MAX_PAYLOAD) throw new IllegalArgumentException("payload too large"); ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(0xAA); out.write(0x55); // 帧头,便于重同步 out.write(seq++ & 0xFF); // 序号,用于发现丢帧 out.write((payload.length >> 8) & 0xFF); // 长度高字节 out.write(payload.length & 0xFF); // 长度低字节 out.write(payload, 0, payload.length); CRC32 crc = new CRC32(); crc.update(payload); long v = crc.getValue(); out.write((int) ((v >> 24) & 0xFF)); out.write((int) ((v >> 16) & 0xFF)); out.write((int) ((v >> 8) & 0xFF)); out.write((int) (v & 0xFF)); return out.toByteArray(); } }

MAX_PAYLOAD要按通道能力设,BLE 通知包不要直接按 512 发;经典蓝牙也不要无限拼大包,接收端缓冲区有限。帧头用于断包后重新同步,序号用于发现丢帧,CRC 用于拒绝坏帧。解析侧要先找帧头,再读长度,长度超过上限就丢弃并继续找下一个帧头,不能盲目分配大数组。

4.3 超时、重试与重连

蓝牙连接的超时和重连不能拍脑袋。连接超时太短,首次配对必失败;太长,界面像卡死。读超时要和业务心跳匹配,重连要有退避,避免把对端打爆。我一般用下面这组起点参数,再按现场调整:

参数建议起点调整依据
连接超时8 秒首次配对、服务发现是否偏慢
读超时2 秒设备上报周期与心跳间隔
重连初始间隔1 秒避免瞬时重连风暴
重连最大间隔30 秒长时间离线时的资源占用
单次发送重试1 次幂等指令才可重试

重连代码要带指数退避和最大次数,不要写成死循环:

public void reconnectLoop() throws InterruptedException { long backoff = 1000; while (!Thread.currentThread().isInterrupted()) { try { openAndRun(); // 内部完成连接、收发、异常退出 backoff = 1000; // 成功后复位 } catch (Exception e) { System.out.println("CONNECT_FAIL " + e.getMessage()); Thread.sleep(backoff); backoff = Math.min(backoff * 2, 30000); } } }

openAndRun退出时一定要关闭流和连接,否则下次重连会积累文件描述符。重试只对幂等指令开放,写配置、升级固件这类指令重发可能造成重复执行。退避上限 30 秒不是固定值,要看设备离线时长和业务容忍度。

5. 避坑与排查:六个现场翻车记录

5.1 扫描不到设备

现象是周围手机能搜到模块,Java Demo 列表为空。原因通常有三类:目标设备未进入可发现模式、本机适配器被系统禁用、扫描回调被异常吞掉。解决顺序是先看本机自检输出,再用系统工具确认适配器状态,最后检查deviceDiscovered里是否有异常被空catch吃掉。扫描期间不要重复调用startInquiry,重复进入会让状态机错乱。设备名读取失败不代表设备不存在,地址能拿到就可以先尝试连接。

5.2 连接后立刻断开

现象是Connector.open返回成功,几百毫秒后读写抛异常。常见原因是通道号不对、对端只接受已配对连接、或者连接后没有及时发心跳被设备踢掉。排查时把最终 URL、配对状态、对端服务记录打出来;如果服务记录里没有目标通道,说明通道号猜错了。另一个坑是连接对象被局部变量提前回收,流关闭顺序不对也会导致假断开。解决方法是连接、流、会话对象统一由会话管理器持有,关闭时按“读流 → 写流 → 连接”的顺序释放。

5.3 收发乱码与粘包

现象是十六进制日志里帧头和长度对不上,业务解析偶尔成功。原因多半是流式读取没有按帧解析,或者发送端一次写入被拆成多次到达。解决方法是固定帧格式,接收缓冲区循环查找帧头,长度字段做上限校验,CRC 不通过就丢弃当前帧并继续向后找。不要用readLine处理二进制协议,也不要把available()当成完整帧长度。日志要同时打印原始字节和解析后的字段,否则只能看到乱码。

5.4 权限与后台限制

移动端常见现象是前台扫描正常,切到后台后回调停止。原因是系统对后台扫描和连接有限制,权限声明不等于运行时授权。排查时先看权限申请结果,再看系统是否限制了后台活动,最后看连接是否被系统回收。解决方法是把扫描和连接放到前台服务或用户可见的会话中,后台只做已建立连接的心跳,不在后台反复全量扫描。桌面端则要检查蓝牙服务是否被休眠策略挂起。

5.5 多设备并发与资源释放

现象是连一台稳定,连三台后随机失败。原因通常是每个连接都开了独立线程和流,但没有统一上限,文件描述符或线程池被耗尽。解决方法是给连接会话建池,限制最大并发数,每个会话有独立状态和关闭钩子;扫描与连接分离,扫描线程不持有连接。关闭时先停读线程,再关流,最后关连接,并记录关闭原因。压测时重点看连接数上升后超时是否增加,而不是只看单次成功率。

6. 验证与进阶:把 Demo 变成可回归的蓝牙调试工具

6.1 用回环与假设备验证状态机

真设备不稳定时,先把传输层抽象成接口,用回环实现验证状态机。这样扫描、连接、收发、重连的逻辑可以先跑通,不被硬件卡住。核心是让Transport只暴露open、write、read、close,真蓝牙和假设备各实现一份。假设备可以注入延迟、断连、半包,专门验证异常分支。每次改协议解析,先跑回环用例,再上真机,能省很多来回。

6.2 日志与指标怎么留

日志至少留四类:状态迁移、连接 URL、收发十六进制、异常堆栈。指标看连接成功率、平均连接耗时、重连次数、坏帧率。不要把日志写成只打印“连接成功”,现场排错时没有地址、通道、UUID 就等于没有线索。十六进制日志要限制长度,避免大包刷屏;异常分支要打印当前状态,否则无法判断是扫描失败还是连接失败。

6.3 一个可复用技巧:连接会话对象

把连接、流、读线程、最后心跳时间、重连次数收进一个Session对象,所有操作走会话,不直接碰底层连接。这样关闭、重连、并发控制都有统一入口。我吃过一次亏:读线程异常退出后连接对象还在,主流程以为在线,实际已经半死。从那以后我每次改蓝牙代码都强制走一遍“扫描 → 连接 → 收发 → 拔电 → 重连”的回归,不省略拔电这一步。希望帮到你。

本文还有配套的精品资源,点击获取