资讯动态

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

发布时间:2026/10/26 13:52:29 来源:尧图企业网站定制
简介面向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管连接 URLUUID和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;authenticatefalse;encryptfalse;masterfalse; 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-pagerjava -version确认运行时可用rfkill list看蓝牙是否被软/硬阻塞系统服务状态确认后台没有停。JSR-82 依赖具体实现Demo 的依赖里如果带了本地库要确认本地库架构和当前运行时一致。参数上不要同时改三处先固定设备地址再固定通道号最后才调超时。启动参数建议显式传入java -Dbt.address001122334455 -Dbt.channel1 -Dbt.timeoutMs8000 -jar demo.jarbt.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 ;authenticatefalse;encryptfalse;masterfalse; 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对象所有操作走会话不直接碰底层连接。这样关闭、重连、并发控制都有统一入口。我吃过一次亏读线程异常退出后连接对象还在主流程以为在线实际已经半死。从那以后我每次改蓝牙代码都强制走一遍“扫描 → 连接 → 收发 → 拔电 → 重连”的回归不省略拔电这一步。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑