简介:围绕 Java 蓝牙编程整理的实战示例包,面向具备 Java 基础、希望切入物联网短距离通信开发的初学者与中级开发者。项目以 JSR-82 蓝牙 API 为主线,并结合 BlueCove 等第三方库,演示设备发现、RemoteDevice 连接建立、RFCOMM/L2CAP 数据传输、服务注册与安全认证等核心环节,同时给出异常处理、连接重试和性能优化的实践思路,便于理解蓝牙版本差异与跨平台兼容问题。压缩包共 17 个文件、约 826KB,以 java 源码和 class 编译文件为主体,辅以 jar 依赖、prefs 配置及 classpath/project 工程文件,可直接导入 IDE 查看目录结构并运行调试。已有 2415 人学习下载,适合用作课程实验、智能家居与健康监测等蓝牙应用的原型参考;对 SDP 服务记录、串口模拟通信和 PIN 码验证的展示,也能帮助读者快速建立从设备扫描到服务注册的完整开发脉络。
1. 从“列表永远是空的”说起
很多人第一次跑 java 蓝牙 demo,卡住的地方都很像:代码能编译,日志不报错,扫描列表就是空的。多数时候不是 demo 写错了,而是适配器状态、权限、系统蓝牙栈三样里至少一样没到位。这套 java 蓝牙 demo 的价值不在于那几个 API 调用,而在于把“适配器状态 → 权限 → 发现 → 配对 → 连接 → 收发”这条状态机串起来,同时给出经典蓝牙 SPP 与 BLE GATT 两条主线的可运行示例。它的目标读者很明确:需要做上位机、产测工装、设备配置工具的 Java 开发者,尤其是那些不满足于在手机端跑通、还想在桌面端也能落地的人。
2. 三条技术路线怎么选:JSR-82、BLE GATT、SPP 串口
动手写第一行代码之前,先决定走哪条路。蓝牙开发在 Java 生态里没有唯一答案,选错路线,后面几天的调试基本都白费。三条路线在依赖、平台覆盖和稳定性上差别很大,先把边界摸清楚,再决定 demo 里放哪套代码。
2.1 JSR-82 与它的社区实现:API 好用,栈难伺候
javax.bluetooth这套 API 来自 JSR-82 规范,设计上很干净:LocalDevice拿本机适配器,DiscoveryAgent负责发现设备和服务,StreamConnection拿到 RFCOMM 流。问题是 Java SE 从来没有内置实现,桌面端只能靠社区维护的开源实现(BlueCove 那一套)来桥接系统蓝牙栈。
它的依赖链条很长:bluecove.jar只是 Java 层,真正干活的是 native 库,Windows 走厂商蓝牙栈,Linux 走 BlueZ 的 socket 接口。启动时要确保 native 库被正确加载,常见做法是显式指定:
java -Djava.library.path=./native/x64 \ -cp "lib/bluecove.jar:lib/bluecove-gpl.jar:out" \ com.example.bt.DemoMain参数说明:-Djava.library.path指向.dll或.so所在目录,位数必须和 JVM 一致,64 位 JDK 配 32 位 native 库是最常见的启动失败原因;bluecove-gpl提供 Linux 下的额外协议支持,缺了它,在 Linux 上发现服务会直接抛异常。
这条路线适合的场景很窄:存量 Windows 7/10 工装、内网固定机型、系统蓝牙栈没有被替换过。新机器上直接押注它,翻车概率不低。我一般把它当成“能跑就留着、跑不动立刻换路”的备选方案,而不是主方案。
2.2 BLE GATT 路线:移动端顺手,桌面端要自己搭桥
BLE 设备现在占了绝大多数,心率带、传感器模组、电子锁、部分工控外设都在走 GATT。移动端有完整的原生 API,扫描、连接、服务发现、特征值读写、通知回调一应俱全;桌面端 Java 就没有这么幸运了,没有官方 BLE API,只能通过 D-Bus 去调系统蓝牙服务,或者用第三方封装库。
两条路的思维方式不一样:GATT 是请求-回调模型,一次只能有一个未完成的操作;SPP 是流模型,读写随手就来。把这两者混在一个接口里,是后面维护成本飙升的根源。我的做法是先抽一层薄接口,把“发一帧、等一帧”作为统一语义:
public interface BluetoothTransport extends AutoCloseable { void connect(String address) throws IOException; // 地址统一用 12 位十六进制无分隔符 byte[] request(byte[] payload, int timeoutMs) throws IOException; // 同步请求,内部等回调 void setOnNotify(java.util.function.Consumer<byte[]> cb); // 设备主动上报 boolean isConnected(); @Override void close(); }逻辑说明:request内部用CountDownLatch把异步回调转成同步返回,timeoutMs兜住“设备不回包”的情况;setOnNotify单独暴露,是因为 BLE 的通知是持续推送的,不能塞进请求响应里。参数上,地址格式统一去掉冒号,能省掉后面大量字符串处理的分支。
选型时按这个顺序问自己四个问题就够了:目标设备只支持 BLE 还是也能走经典蓝牙;运行环境是移动端还是桌面端;业务是“请求-响应”为主还是“持续上报”为主;现场能不能接受操作员手动配对。四个答案基本能锁定路线。
| 维度 | JSR-82 / RFCOMM | BLE GATT | SPP 虚拟串口 |
|---|---|---|---|
| 桌面 Java 支持 | 依赖 native 实现,老系统较稳 | 需 D-Bus 或第三方库 | 最好,纯串口 |
| 移动端支持 | 基本不可用 | 原生完整 | 需 OTG,较少用 |
| 典型延迟 | 10~50 ms | 20~100 ms(受连接间隔影响) | 5~30 ms |
| 数据模型 | 字节流 | 特征值读写 + 通知 | 字节流 |
| 主要翻车点 | native 库与栈不匹配 | GATT 操作未串行化 | 虚拟串口未绑定 |
2.3 SPP 串口兜底:最土但最能交付
经典蓝牙 SPP 在系统配对成功之后,会暴露一个虚拟串口:Windows 上是COMx,Linux 上是/dev/rfcomm0。Java 侧完全不用碰蓝牙 API,用jSerialComm或 JSSC 当普通串口读写就行。这条路的好处是稳定、可测、出问题能用串口助手对照;坏处是只支持经典蓝牙,且现场需要先完成配对。
Linux 下绑定虚拟串口是固定动作:
# 前提:设备已配对且 trust,channel 1 是 SPP 常用通道 sudo rfcomm bind 0 AA:BB:CC:DD:EE:FF 1 ls -l /dev/rfcomm0 # 释放 sudo rfcomm release 0bind 0里的 0 是 rfcomm 设备序号,对应/dev/rfcomm0;最后的1是对方设备的 RFCOMM 通道号,写错会连到一个不存在或非 SPP 的服务上,表现是Connection refused而不是超时。
import com.fazecast.jSerialComm.SerialPort; public class SppSerialClient { public static void main(String[] args) throws Exception { SerialPort port = SerialPort.getCommPort("COM5"); // Linux 换成 /dev/rfcomm0 port.setBaudRate(115200); // 对虚拟串口无实际意义,但部分驱动要求设置 port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, 500, 0); if (!port.openPort()) throw new IllegalStateException("串口打开失败"); port.getOutputStream().write(new byte[]{0x01, 0x03, 0x00, 0x00}); byte[] buf = new byte[64]; int n = port.getInputStream().read(buf); // 超时返回 -1 或 0,取决于驱动 System.out.println("收到 " + n + " 字节"); port.closePort(); } }setComPortTimeouts的第一个参数决定读行为:TIMEOUT_READ_SEMI_BLOCKING表示“至少读到 1 字节或超时返回”,适合一问一答;如果是持续上报,改成非阻塞配合独立读线程更稳。超时值 500 ms 不是随便写的,短于设备的最坏响应时间会误判掉线。
3. 经典蓝牙 SPP 打通:从发现服务到字节流收发
选定 SPP 之后,真正要写的代码并不多,麻烦都在细节里:服务怎么找、连接怎么建、读阻塞怎么解。这一章按“发现 → 连接 → 组帧”的顺序走一遍,代码可以直接贴进 demo 跑。
3.1 设备发现与服务搜索:别手拼连接 URL
很多人拿到 MAC 地址就直接拼btspp://去连,结果时好时坏。正确做法是先做服务搜索,从ServiceRecord里取连接 URL,通道号由对端决定,不是固定的 1。
import javax.bluetooth.*; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; public class SppDiscovery { public static String findSppUrl(String targetMac) throws Exception { LocalDevice local = LocalDevice.getLocalDevice(); // 适配器不可用会在这里抛异常 DiscoveryAgent agent = local.getDiscoveryAgent(); CountDownLatch done = new CountDownLatch(1); final String[] result = new String[1]; DiscoveryListener listener = new DiscoveryListener() { @Override public void deviceDiscovered(RemoteDevice btDevice, DeviceClass cod) { try { if (!targetMac.equalsIgnoreCase(btDevice.getBluetoothAddress())) return; UUID[] uuids = { new UUID(0x1101) }; // 0x1101 = Serial Port Profile agent.searchServices(null, uuids, btDevice, this); } catch (Exception ignore) { } } @Override public void servicesDiscovered(int transID, ServiceRecord[] records) { if (records.length == 0) return; // NOAUTHENTICATE_NOENCRYPT:使用系统已完成的配对结果 result[0] = records[0].getConnectionURL( ServiceRecord.NOAUTHENTICATE_NOENCRYPT, false); done.countDown(); } @Override public void serviceSearchCompleted(int transID, int respCode) { if (respCode != SERVICE_SEARCH_COMPLETED) done.countDown(); } @Override public void inquiryCompleted(int discType) { } }; agent.startInquiry(DiscoveryAgent.GIAC, listener); // GIAC 为通用查询 if (!done.await(20, TimeUnit.SECONDS)) throw new java.io.IOException("服务搜索超时"); if (result[0] == null) throw new java.io.IOException("未找到 SPP 服务"); return result[0]; // 形如 btspp://AABBCCDDEEFF:1;authenticate=false;encrypt=false;master=false } }关键点有三个。startInquiry是异步的,必须用CountDownLatch等方式等结果,直接顺序读result拿到的一定是 null。searchServices必须在deviceDiscovered回调里发起,放到外面会因为拿不到RemoteDevice而失败。getConnectionURL的第二个参数false表示不强制 master 模式,设成true在部分适配器上会导致连接建立后立刻断开。20 秒超时是经验值,查询窗口本身要消耗 10 秒左右,设太短会误判。
搜索之前,设备必须已经配对。桌面环境下用系统工具完成配对最省事,Linux 下固定走这套:
bluetoothctl <<'EOF' power on agent on default-agent scan on pair AA:BB:CC:DD:EE:FF trust AA:BB:CC:DD:EE:FF quit EOFtrust这一步经常被忽略。不 trust 的话,重连时系统可能重新弹配对或直接拒绝 RFCOMM 连接,表现是“第一次能用,第二天连不上”,排查起来很费时间。
3.2 建立连接与流读写:读阻塞没有超时
拿到 URL 之后建连很简单,难的是读。JSR-82 的InputStream.read()是阻塞的,对端掉线、电量耗尽、走出范围,它都不会主动返回,线程就那么挂着。
import javax.microedition.io.Connector; import javax.microedition.io.StreamConnection; import java.io.*; public class SppLink implements AutoCloseable { private StreamConnection conn; private InputStream in; private OutputStream out; public void open(String url) throws IOException { conn = (StreamConnection) Connector.open(url, Connector.READ_WRITE, true); in = conn.openInputStream(); out = conn.openOutputStream(); } public byte[] readFully(int len, long deadlineMs) throws IOException { byte[] buf = new byte[len]; int off = 0; while (off < len) { if (System.currentTimeMillis() > deadlineMs) throw new IOException("读超时"); int avail = in.available(); // 只是探测,不能依赖它判断流结束 if (avail <= 0) { sleep(10); continue; } int n = in.read(buf, off, Math.min(avail, len - off)); if (n < 0) throw new IOException("对端关闭"); off += n; } return buf; } private static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } @Override public void close() throws IOException { if (in != null) in.close(); if (out != null) out.close(); if (conn != null) conn.close(); } }readFully用available()加睡眠轮询替代裸read(),把“无限等待”变成“可控超时”。这是桌面端 SPP 最实用的一招,代价是 10 ms 级别的延迟抖动,对工装场景完全可以接受。deadlineMs传绝对时间戳而不是相对时长,能避免循环里反复计算导致的误差累积。
写方向要注意 flush:openOutputStream()拿到的流在部分实现里带缓冲,写完不 flush,对端可能一直等不到数据。每帧写完立刻out.flush(),别攒。
3.3 帧格式设计:粘包和校验必须自己管
RFCOMM 是字节流,不保证一次read对应一帧。业务数据一长,粘包拆包必然出现。别指望协议层帮你,自己定帧头。
public final class FrameCodec { private static final byte SOF = (byte) 0xA5; private static final int MAX_PAYLOAD = 1024; // 帧结构:SOF(1) | CMD(1) | SEQ(1) | LEN(2,小端) | PAYLOAD(n) | CRC16(2) public static byte[] encode(int cmd, int seq, byte[] payload) { if (payload.length > MAX_PAYLOAD) throw new IllegalArgumentException("payload 过长"); byte[] f = new byte[5 + payload.length + 2]; f[0] = SOF; f[1] = (byte) cmd; f[2] = (byte) seq; f[3] = (byte) (payload.length & 0xFF); f[4] = (byte) ((payload.length >> 8) & 0xFF); System.arraycopy(payload, 0, f, 5, payload.length); int crc = crc16(f, 1, 4 + payload.length); // 校验范围从 CMD 开始,不含 SOF f[5 + payload.length] = (byte) (crc & 0xFF); f[6 + payload.length] = (byte) ((crc >> 8) & 0xFF); return f; } private static int crc16(byte[] d, int off, int len) { int crc = 0xFFFF; for (int i = off; i < off + len; i++) { crc ^= (d[i] & 0xFF); for (int b = 0; b < 8; b++) { crc = ((crc & 1) != 0) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); } } return crc; } }解码端要做的是:维护一个ByteArrayOutputStream缓冲区,每次读到的数据都追加进去,然后循环判断——缓冲区首字节是否等于0xA5,不是就丢弃一个字节继续找;长度够 5 字节就解析LEN;缓冲区长度达到5 + LEN + 2才切出一帧,校验 CRC 后再交给业务。这个“找头 → 判长 → 等齐 → 校验”的四步循环是整个 demo 里最容易写错的地方,写错了表现为“偶发丢帧”或“偶发乱码”,很难在短连接测试里复现。
CRC 选 MODBUS 用的 0xA001 多项式,原因是嵌入式侧现成实现多,对端不用重写。校验范围从CMD开始不含SOF,这是为了让帧头本身不参与校验,丢一个字节时还能靠找头恢复同步。
4. BLE GATT 实战:扫描、连接、开通知、写特征值
BLE 和 SPP 的脾气完全不同。GATT 是严格串行的状态机,上一个操作没回调,下一个操作发出去就是石沉大海。这一章的代码以移动端原生 API 为基准,思路同样适用于桌面端通过 D-Bus 调系统蓝牙服务的场景。
4.1 扫描参数与过滤:省电和速度是个权衡
扫描是 BLE 里最容易“看着跑通、实际不工作”的一步。权限、定位开关、扫描频率限制都会影响结果,代码本身反而简单。
import android.bluetooth.*; import android.bluetooth.le.*; import android.content.Context; import java.util.*; public class BleScanner { private final BluetoothLeScanner scanner; private final ScanCallback callback; public BleScanner(Context ctx, ScanCallback cb) { BluetoothManager mgr = (BluetoothManager) ctx.getSystemService(Context.BLUETOOTH_SERVICE); BluetoothAdapter adapter = mgr.getAdapter(); this.scanner = adapter.getBluetoothLeScanner(); // 蓝牙关闭时返回 null this.callback = cb; } public void start(String serviceUuid) { ScanFilter filter = new ScanFilter.Builder() .setServiceUuid(new ParcelUuid(UUID.fromString(serviceUuid))) .build(); ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 高占空比,工装场景适用 .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) // 每个广播包都回调 .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .build(); scanner.startScan(Collections.singletonList(filter), settings, callback); } public void stop() { scanner.stopScan(callback); } }SCAN_MODE_LOW_LATENCY的扫描占空比最高,发现最快,耗电也最狠,适合插电的工装设备;做可穿戴配套工具就换成SCAN_MODE_BALANCED。MATCH_MODE_AGGRESSIVE会让同一个设备在每次广播时都回调,方便你根据 RSSI 判断远近,代价是回调次数暴涨,onScanResult里必须做轻量处理,否则主线程会被拖住。
还有一个隐性限制:连续扫描不能长时间不停。多个应用频繁扫描时,系统会做节流,表现为“扫着扫着就没回调了”。工装场景的做法是扫描 10 秒、停 2 秒再开,比一直开着更可靠。
4.2 连接、MTU 与服务发现:一步一步来
连接之后的第一件事不是读特征值,而是按顺序把状态机推完:连接成功 → 请求 MTU → 发现服务 → 找到目标特征值 → 开通知。中间任何一步跳过,后面的操作都会返回失败。
import android.bluetooth.*; import java.util.UUID; public class GattClient extends BluetoothGattCallback { private BluetoothGatt gatt; private BluetoothGattCharacteristic target; private static final UUID SVC = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb"); private static final UUID CHAR = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); public void connect(android.content.Context ctx, BluetoothDevice dev) { // autoConnect=false:直连,超时更快;true 适合后台长期等待 gatt = dev.connectGatt(ctx, false, this, BluetoothDevice.TRANSPORT_LE); } @Override public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { if (status != BluetoothGatt.GATT_SUCCESS) { g.close(); return; } // 133 等错误在这里暴露 if (newState == BluetoothProfile.STATE_CONNECTED) { g.requestMtu(247); // 回调 onMtuChanged 里再发起 discoverServices } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { g.close(); } } @Override public void onMtuChanged(BluetoothGatt g, int mtu, int status) { g.discoverServices(); // 必须在 MTU 协商完成之后再发现服务 } @Override public void onServicesDiscovered(BluetoothGatt g, int status) { if (status != BluetoothGatt.GATT_SUCCESS) return; BluetoothGattService svc = g.getService(SVC); if (svc == null) return; target = svc.getCharacteristic(CHAR); enableNotify(g, target); } private void enableNotify(BluetoothGatt g, BluetoothGattCharacteristic ch) { g.setCharacteristicNotification(ch, true); // 本地开关 BluetoothGattDescriptor d = ch.getDescriptor(CCCD); d.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); // 写 CCCD 才算真开 g.writeDescriptor(d); } @Override public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic ch) { byte[] data = ch.getValue(); // 通知数据在这里,注意不要做耗时操作 } }requestMtu(247)是为了把每包可用载荷从默认的 20 字节提到 244 字节左右,传感器类设备提升明显。但 MTU 是协商结果,设备可能只给 100,所以写数据前必须用回调里拿到的实际值算分片。setCharacteristicNotification只是打开本地接收开关,不写 CCCD(描述符0x2902)的话,设备根本不会发通知,这是新手最常见的“连上了但收不到数据”。
API 33 之后,writeDescriptor(descriptor)已被标记废弃,改用带 value 参数的重载或者新的BluetoothGattDescriptor写入方式。做兼容时按版本分支,别图省事只写一套,否则在新系统上会静默失败。
4.3 特征值读写与分片:GATT 一次只能干一件事
写数据的核心约束是串行。发一个包,等onCharacteristicWrite回调回来,再发下一个。中间插入discoverServices、readCharacteristic,或者同时发两个writeCharacteristic,结果就是回调错乱、部分包丢失,甚至直接断连。
import android.bluetooth.*; import java.util.ArrayDeque; import java.util.Queue; public class ChunkWriter { private final BluetoothGatt gatt; private final BluetoothGattCharacteristic ch; private final Queue<byte[]> queue = new ArrayDeque<>(); private int chunkSize; private boolean busy; public ChunkWriter(BluetoothGatt gatt, BluetoothGattCharacteristic ch, int negotiatedMtu) { this.gatt = gatt; this.ch = ch; this.chunkSize = Math.max(20, negotiatedMtu - 3); // 3 字节为 ATT 头开销 } public synchronized void enqueue(byte[] payload, boolean withResponse) { for (int i = 0; i < payload.length; i += chunkSize) { int len = Math.min(chunkSize, payload.length - i); byte[] part = new byte[len]; System.arraycopy(payload, i, part, 0, len); queue.offer(part); } pump(); } private synchronized void pump() { if (busy || queue.isEmpty()) return; byte[] part = queue.poll(); busy = true; ch.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); // 带响应,可靠 ch.setValue(part); if (!gatt.writeCharacteristic(ch)) { // 返回 false 说明上一条还没完成 queue.offer(part); busy = false; } } public synchronized void onWriteComplete(int status) { busy = false; if (status == BluetoothGatt.GATT_SUCCESS) pump(); // 成功才继续下一条 } }chunkSize取MTU - 3,3 是 ATT 协议头,写满会返回GATT_INVALID_ATTRIBUTE_LENGTH。WRITE_TYPE_DEFAULT带响应,速度慢但可靠,配置类指令必须用它;流式数据可以用WRITE_TYPE_NO_RESPONSE,但发送端要自己做限速,发太快会把设备侧缓冲打爆。writeCharacteristic返回 false 是常态,不代表出错,只能说明当前有未完成操作,把包塞回队列等下次即可。
onWriteComplete里的status要区分处理:GATT_SUCCESS才继续,其他状态要么重试一次,要么整体重连,无脑重试会陷入死循环。
5. 踩坑排查:五个让 demo 只在别人机器上跑得通的原因
蓝牙问题难查,是因为失败路径几乎不报错,只表现为“没反应”。下面五条是我在实机上反复遇到的,每条都按现象、原因、解决写清楚,遇到对应症状可以直接对号入座。
5.1 扫描不到任何设备
现象:代码进到startScan,回调一次都不触发,换个手机就正常。
原因通常有三层:权限没给全、定位服务没开、扫描器实例拿错了。Android 12 以后,扫描和连接是两个独立权限,只申明BLUETOOTH_SCAN而漏了BLUETOOTH_CONNECT,会出现能扫到但连不上的情况;12 以前的系统则要求ACCESS_FINE_LOCATION并且系统定位开关必须是开的,这个限制跟业务逻辑毫无关系,但确实存在。另外,adapter.getBluetoothLeScanner()在蓝牙关闭时返回 null,直接调用就空指针,日志里看不出所以然。
解决:启动扫描前做一次状态自检,把适配器开关、权限、扫描器是否为 null 全判一遍,不满足就给出明确提示,而不是静默失败。工装场景建议把自检结果打在屏幕上,现场人员能自己判断。
# 桌面端排查同类问题,先看适配器状态和是否被 rfkill 关掉 hciconfig -a rfkill list bluetooth dmesg | grep -i blue | tail -205.2 连上立刻断开,或者状态码 133
现象:onConnectionStateChange收到STATE_CONNECTED,几百毫秒后变成STATE_DISCONNECTED,status是 133 或 8。
原因基本是操作顺序错了。连接成功回调里同时发起discoverServices和requestMtu,或者在上一个 GATT 操作没完成时又发一个,协议栈会直接把连接掐掉。另一个高发原因是connectGatt的第二个参数传了true(autoConnect),实际直连场景下这个模式会走后台扫描,超时时间极长,表现就是“连不上也不报错”。
解决:把所有 GATT 操作串成一条链,用回调驱动下一步,绝不并列发起。连接一律用autoConnect=false,后台重连的需求单独用定时器做,别指望系统帮你兜。
提示:133 这个错误码覆盖面很广,别把它当成具体原因,只当成“连接过程被打断”的信号,往上找最近一次 GATT 操作。
5.3 写特征值总是返回 false
现象:writeCharacteristic一直返回 false,日志里没有任何回调。
原因是前一条写操作的回调还没回来。GATT 的命令队列深度是 1,在上一条未完成时再写,框架直接拒绝。还有人把setValue写在writeCharacteristic之后,那写出去的自然是旧值。
解决:维持一个发送队列和busy标志,回调里清标志再发下一条;setValue必须在writeCharacteristic之前调用,顺序反了不会报错,只是数据不对,属于最阴的一类 bug。
5.4 桌面端 demo 换台机器就起不来
现象:在开发机上跑得好好的,拷到现场机器上启动就抛UnsatisfiedLinkError或适配器不可用。
原因是 native 库和运行环境不匹配。JVM 位数和 native 库位数对不上、java.library.path没指对、系统蓝牙栈被替换过(装了第三方蓝牙管理软件),都会导致底层拿不到适配器。Linux 上还多一层:当前用户没有蓝牙设备节点的访问权限。
解决:启动脚本里把java.library.path、JVM 位数、系统栈版本一起校验,启动失败时把这三项打出来。Linux 下确认当前用户在bluetooth组里,设备节点权限正常。
# 确认 JVM 位数与 native 库位数一致 java -XshowSettings:properties -version 2>&1 | grep sun.arch.data.model file ./native/x64/libbluecove.so # 确认当前用户能看到蓝牙设备 ls -l /dev/bus/usb/*/* 2>/dev/null | head groups | tr ' ' '\n' | grep bluetooth5.5 数据偶发乱码或丢帧
现象:短连接测试全对,跑十分钟开始出现乱码,或者某几帧凭空消失。
原因分两类。编码类:发送端用平台默认字符集getBytes(),接收端按 UTF-8 解,中文必乱;固定用StandardCharsets.UTF_8就没有这个问题。成帧类:接收端每次read就当一帧处理,粘包和拆包时必然错位。BLE 侧还有个特有问题:MTU 协商后没有更新分片大小,仍然按 20 字节切,长包会被截断。
解决:接收端一律走缓冲区加长度判定,按第 3 章的四步循环切帧;BLE 侧在onMtuChanged里更新分片大小并加日志。乱码问题排查时,先把原始字节以十六进制打印出来,能立刻区分是传输问题还是解码问题。
6. 把 demo 变成能验收的工具:长连接压测与断连重连
demo 跑通只是起点。真正交付前,我会做一件事:把 demo 改成一个能连续跑 30 分钟的自测工具,因为蓝牙的问题九成出在“跑得久了”之后。具体做法是加一个压测入口,按固定周期发心跳帧,同时记录三项数据:每帧往返耗时、连续成功帧数、断连次数与恢复耗时。
public class StabilityRunner { public static void main(String[] args) throws Exception { BluetoothTransport t = TransportFactory.create(args[0]); // spp / ble / serial t.connect(args[1]); long ok = 0, fail = 0, maxRtt = 0; long start = System.currentTimeMillis(); while (System.currentTimeMillis() - start < 30 * 60 * 1000L) { // 30 分钟 long t0 = System.nanoTime(); try { t.request(new byte[]{0x01, 0x03, 0x00, 0x00}, 1000); ok++; maxRtt = Math.max(maxRtt, (System.nanoTime() - t0) / 1_000_000); } catch (Exception e) { fail++; // 不退出,记录后继续,看重连能否自愈 Thread.sleep(200); } Thread.sleep(100); // 10 Hz 心跳,贴近实际业务频率 } System.out.printf("ok=%d fail=%d maxRtt=%dms%n", ok, fail, maxRtt); t.close(); } }判断标准要提前定好,不然跑完也不知道算不算过。我一般用这张表卡:
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| 长连接稳定性 | 30 分钟 10 Hz 心跳 | 断连次数 ≤ 2,且每次 5 秒内自动恢复 |
| 往返时延 | 记录每帧request耗时 | P95 < 100 ms,无单帧超过 1000 ms |
| 断连自愈 | 中途手动关掉设备电源再开 | 重连成功,业务不重启 |
| 数据完整性 | 每帧带自增序号并回显校验 | 序号连续,无重复无跳变 |
| MTU 分片 | 发 512 字节长包 | 对端完整收到,长度与校验一致 |
重连策略上,我的习惯是退避重试:第 1 次 500 ms,第 2 次 1 秒,之后固定 3 秒,上限 10 次。无脑 while 循环里立刻重连,在 BLE 上会触发系统的连接频率限制,反而越连越连不上。SPP 侧则要先关流再关连接,顺序反了会留下半开连接,下次连的时候对端还占着通道。
还有一个容易被忽略的点:日志留痕。压测时把每帧的十六进制、时间戳、耗时写进文件,出问题直接翻文件,比在现场加打印快得多。这个习惯救过我很多次——有一次现场反馈“偶发丢指令”,翻日志发现是操作员按按钮太快,两条指令间隔 30 ms,设备固件处理不过来,跟蓝牙一点关系没有。
从那以后我每次接蓝牙相关的活,都强制先跑一遍 30 分钟长连接压测,再谈业务逻辑,因为蓝牙的坑几乎都藏在“跑久了”的那一段里。希望帮到你。
本文还有配套的精品资源,点击获取