ARTICLE DETAIL

资讯详情

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

Android BluetoothSocket 从原理到实战:UUID、权限与数据通信全攻略

Android BluetoothSocket 从原理到实战:UUID、权限与数据通信全攻略 做安卓开发的人只要涉及到“两个设备之间传数据”蓝牙基本都是绕不开的方案。环境配好、Hello World 能跑之后想真正把数据从一台手机送到另一台手机或者从手机发送到一块开发板上你迟早会撞上BluetoothSocket这个类。网上搜“Android 蓝牙”的时候跳出来最多的反而是 Android Studio 安装、SDK 下载、FileProvider 路径这类环境问题但真正到了 BluetoothSocket 这一层能把这些讲透的资料其实不多。这篇文章我就从实际项目的角度把 BluetoothSocket 从原理到代码、再到各种坑完整拆开聊一遍。先说清楚它能干什么BluetoothSocket 是 Android 传统蓝牙Bluetooth Classic框架里负责建立 RFCOMM 通信通道的套接字类。连上之后两台设备之间就是一条双向字节流管道你想传什么数据都可以自己定义。它适合做安卓手机与手机之间的小型数据互传、手机与蓝牙串口模块HC-05、HC-06、ESP32 这类通信、自制定位器或 DIY 硬件交互等等。接下来要讲的这些内容既适合刚开始接触蓝牙通信的开发者也适合已经被连接失败、Socket 异常折磨过一两天的朋友我会把服务端、客户端、UUID、权限适配、数据读写和排错经验全部摆在台面上。1. 从整体架构看 BluetoothSocket它到底处在蓝牙协议栈的哪一层1.1 蓝牙通信的完整链路里BluetoothSocket 扮演的角色很多初学者会把蓝牙理解成“一个发送数据的接口”但如果真正去看 Android 的蓝牙协议栈你会发现BluetoothSocket只是应用层露出来的冰山一角。整个传统蓝牙的协议栈大致分为应用层、系统蓝牙服务也就是BluetoothAdapter这一层、HCI 层、L2CAP 层以及再往下的物理射频层。不同的蓝牙配置文件比如 HFP打电话用的、A2DP听歌用的、HID键盘鼠标用的都是在这些底层协议之上各自定义了数据传输规则。BluetoothSocket真正对应的底层通道是 RFCOMMRadio Frequency Communication。RFCOMM 是基于 L2CAP 层做的一个串口仿真协议你可以把它理解成“一根看不见的串口线”物理上走的是蓝牙射频逻辑上它模拟了一条串口数据通道。我们平时用蓝牙耳机用的是系统已经帮你封装好的 A2DP/HFP 配置文件直接拿来就能用不需要关心底层但如果你想自己定义两个设备之间传什么数据、怎么传BluetoothSocket就是应用层通往 RFCOMM 通道的唯一入口。这也解释了为什么它的用法和java.net.Socket那么像——本质上它就是一个基于 RFCOMM 协议的套接字。理解了这层关系你就明白为什么BluetoothSocket有getInputStream()和getOutputStream()为什么读写是阻塞式的为什么它按字节流而不是按包来组织数据。1.2 为什么选 BluetoothSocket而不是 BLE GATT现在做蓝牙开发绕不开 BLE低功耗蓝牙也就是BluetoothGatt那套 API。但很多场景下传统 BluetoothSocket 反而是更合适的选择。为什么这么说我给你对比一下。BLE 的核心设计目标是低功耗所以它把数据切割得很小一次典型的数据传输大概是 20 到几百个字节整个通信模型是“中心设备 外围设备”还要定义 Service、Characteristic、Descriptor 这些概念。如果你是做运动手环、低功耗传感器这类需要长时间续航的设备BLE 确实是对的方案。但如果你只是想让两台安卓手机之间互发一段字符串或者驱动一个 HC-05 蓝牙串口模块去控制单片机BLE 那套机制会显得特别重既要设计 GATT 服务结构又要处理 MTU 协商、多个 Characteristic 来回读写开发成本高心态容易崩。而 BluetoothSocket 的 RFCOMM 通道就是一条双向的、源源不断的字节流。想连就连连上之后你只需要往OutputStream里写字节从InputStream里读字节。对于传输量中等、持续交互的场景开发心智成本非常低。唯一缺点就是功耗比 BLE 高、连接建立速度也相对慢一些但换个角度说它胜在稳定和简单特别适合安卓设备和嵌入式模块之间的数据交换。1.3 自定义通信协议的整体设计思路用 BluetoothSocket 之前脑子里首先要建立“服务端和客户端”这个模型。跟普通的 TCP Socket 几乎一模一样一端监听等待连接像接电话的人另一端主动拨号过去像打电话的人。Android 提供了两种对应的类BluetoothServerSocket服务端使用调用listenUsingRfcommWithServiceRecord()创建并开始监听然后调用accept()阻塞等待客户端连入。BluetoothSocket客户端使用调用createRfcommSocketToServiceRecord()创建然后调用connect()去连接服务端。连接建立之后双方手上各持有一个BluetoothSocket实例通过输入输出流收发数据。这套模型看着简单但有一个非常重要的前提条件服务端和客户端必须“掌握同一个暗号”这个暗号就是 UUID。后面我会专门讲 UUID 的选法这里先记住结论——两边不匹配连接一定会失败。2. 动手前必须先搞懂的四个核心细节2.1 UUID 不是随便填的它是双方约定的服务标识UUIDUniversally Unique Identifier是一个 128 位的全局唯一标识符。在蓝牙通信里UUID 的作用就是告诉系统“我提供的是哪一类服务”。客户端发起连接时系统会拿着这个 UUID 去跟远端设备通信远端设备收到请求后会看看自己有没有对应的服务。UUID 对上了连接就建立成功对不上客户端就会抛出Service discovery failed之类的异常。我们先说最简单的做法自己随便生成一个 UUID比如用UUID.randomUUID()得到一个值然后把它同时写死在服务端和客户端的代码里。只要两边用的值一样就能连上。还有一种做法是使用 SPP 的标准 UUID00001101-0000-1000-8000-00805F9B34FB这个 UUID 代表“串口配置文件服务”很多蓝牙串口模块比如 HC-05默认就支持这个服务所以手机连接 HC-05 时客户端 UUID 用这个标准值模块那边不用任何配置就能直接连上。经验之谈自己写 App 在内网里玩用UUID.randomUUID()生成后把字符串写死在代码里就行别每次启动都随机生成否则就是标准的自己连自己都进不去了。我在早期项目里就踩过这种低级坑服务端每次启动都随机生成 UUID客户端写死的是另外一串结果就是永远连不上排查了大半天才发现根因。2.2 服务端创建的完整姿势listenUsingRfcommWithServiceRecord 的隐藏注意点服务端要创建一个监听套接字代码看起来很简单val serverSocket: BluetoothServerSocket? bluetoothAdapter?.listenUsingRfcommWithServiceRecord(MyBluetoothServer, MY_UUID)listenUsingRfcommWithServiceRecord的第一个参数是服务名称这个名称主要用于给别人看也可以理解为蓝牙服务的“备注名”第二个参数才是关键就是上文的 UUID。创建完成后accept()方法会阻塞等待客户端连接所以务必要放到子线程里。accept()还有一个带超时的重载方法accept(timeoutMillis)超时后会抛出IOException。如果你不想让服务端无限期等下去建议使用这个带超时的版本但有一点要注意超时异常不代表连接失败只是“等待时间到了还没人连”你要根据自己的业务逻辑决定是退出监听还是重新开启监听。在拿到accept()返回的BluetoothSocket之后有一个很多教程都不会说的细节立即关闭BluetoothServerSocket。因为单个服务端套接字只需要接受一次连接连接建立后它的使命就完成了留着不去关闭不仅占用系统资源在某些 Android 系统版本上还会影响后续再次监听。如果是需要接受多个设备轮流连接的应用那就把accept()放循环里但每成功接受一个连接就开一个新线程去处理这个连接的数据。2.3 客户端连接的完整姿势createRfcommSocketToServiceRecord 前前后后客户端这一侧的代码同样很简短val socket: BluetoothSocket device.createRfcommSocketToServiceRecord(MY_UUID) socket.connect()但很多人的连不上问题恰恰出在这两行代码之外的上下文。第一连接之前必须取消蓝牙设备发现。你可以在用BluetoothAdapter.startDiscovery()扫描附近设备但扫描过程会明显拖慢 RFCOMM 连接建立速度有时候甚至会让连接直接失败。所以connect()之前一定要调用bluetoothAdapter.cancelDiscovery()这是官方文档明确建议的也是我实测下来最有效的连接提速手段。第二connect()是阻塞操作而且如果另一端没有及时响应可能需要好几秒甚至十几秒才返回所以一定要放在子线程。第三目标设备如果没有配对连接时会触发系统配对流程。最稳妥的做法是预先在设备列表里通过ACTION_PAIRING_REQUEST等广播或者device.createBond()完成配对再发起 Socket 连接避免在connect()调用期间弹窗打断。2.4 权限声明Android 11 和 Android 12 完全是两套规则蓝牙权限是开发中最容易炸的一环尤其是现在新设备普遍跑 Android 12 以上权限模型改过一次之后老代码很容易踩坑。在 Android 11API 30及以下的系统上主要使用的是两个普通权限BLUETOOTH和BLUETOOTH_ADMIN这两个在 manifest 里直接声明即可不需要运行时申请。但是需要注意的是如果你想要扫描和发现周围的蓝牙设备还需要申请定位权限ACCESS_FINE_LOCATION而且这个权限是危险权限必须在运行时动态申请。因为系统认为扫描蓝牙可以发现设备的地理位置所以强制绑定了定位权限这在 Android 6.0 到 Android 11 都是这个规则。到了 Android 12API 31及以上系统引入了三个新的蓝牙运行时权限BLUETOOTH_SCAN扫描设备、BLUETOOTH_CONNECT连接已配对设备、BLUETOOTH_ADVERTISE对外广播本机为可发现设备。如果 targetSdk 是 31 以上且运行在 Android 12 以上的设备上这三个权限需要动态申请同时BLUETOOTH和BLUETOOTH_ADMIN这两个旧权限在这个级别上不再生效直接用旧的还要申请ACCESS_FINE_LOCATION也不是必须了尤其是当你在 manifest 里给BLUETOOTH_SCAN加了usesPermissionFlagsneverForLocation属性时。实际项目里为了兼容通常会做一套“双版本权限声明 运行时按版本判断”的逻辑。常见配置是旧权限加上android:maxSdkVersion30新权限单独声明然后运行时按系统版本分流申请。下面这段就是兼容性配置的常规写法uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /别小看这套配置我在 Android 12 真机上调试时因为 targetSdk 升级后旧权限失效打开蓝牙搜索设备直接空白查了很久才定位到是这个原因。3. 手写一个最小可用的 BluetoothSocket 通信流程3.1 先搭好整体结构线程模型怎么设计BluetoothSocket 的accept()和connect()都是阻塞调用数据流的read()也是阻塞的所以线程模型设计必须在一开始就定好。我的建议是准备一个独立的BluetoothConnectionManager类内部维护一个固定线程池或者协程作用域网络操作全部丢到子线程回调结果通过Handler、runOnUiThread或者协程的withContext(Dispatchers.Main)回到主线程。绝对不能偷懒直接在主线程调用connect()那样不仅会 ANR而且长时间阻塞还会让用户以为 App 卡死了。建立一个通信会话一般需要两个线程一个是服务端等待连接的监听线程另一个是为每个成功连接的 Socket 分配的数据读写线程。客户端相对来说简单一些发起连接本身在一个线程里连接成功后的读写可以复用同一个线程也可以单独再开一个读循环线程。3.2 服务端完整实现监听、接受、读到数据服务端核心代码可以这样组织class BluetoothServerManager(private val adapter: BluetoothAdapter) { private var serverSocket: BluetoothServerSocket? null private var isListening false fun startServer(uuid: UUID) { isListening true Thread { try { serverSocket adapter.listenUsingRfcommWithServiceRecord(MyServer, uuid) while (isListening) { val socket serverSocket?.accept() socket ?: continue // 每接受一个连接单独开线程处理读写 Thread { handleConnectedSocket(socket) }.start() } } catch (e: IOException) { e.printStackTrace() } }.start() } private fun handleConnectedSocket(socket: BluetoothSocket) { // 连接建立成功后尽快关闭监听端口 serverSocket?.close() val input socket.inputStream val buffer ByteArray(1024) try { while (isListening) { val count input.read(buffer) if (count -1) { // 对端关闭连接 break } val message String(buffer, 0, count, Charsets.UTF_8) // 收到数据回传主线程 } } catch (e: IOException) { // 连接异常断开 } finally { closeSocket(socket) } } private fun closeSocket(socket: BluetoothSocket?) { try { socket?.close() } catch (_: IOException) {} } fun stopServer() { isListening false try { serverSocket?.close() } catch (_: IOException) {} } }这段代码有一个关键决策在handleConnectedSocket里一进来就把serverSocket关闭了。因为一旦接受了第一个客户端的连接对于大多数点对点场景监听使命就结束了。如果你不关后续如果真的再有设备来连系统端口状态会变得很微妙有时候会连不上。当然如果你的场景就是轮番多个设备接入那serverSocket先留着循环接受即可但要注意每个连接都开新线程防止一个慢设备阻塞其他设备的连接。3.3 客户端完整实现搜索已配对设备并建立连接客户端这边思路更直接。第一步先拿到目标BluetoothDevice。如果你只需要和已经配对过的设备通信可以直接遍历adapter.bondedDevicesval pairedDevices: SetBluetoothDevice? adapter.bondedDevices val targetDevice pairedDevices?.firstOrNull { it.name HC-05 }如果你要动态发现新设备则需要通过startDiscovery()扫描然后注册广播接收ACTION_FOUND从EXTRA_DEVICE中取BluetoothDevice。这个流程比较常见我这里不展开全部广播代码只说关键点拿到设备地址后建议直接把地址存下来下次通信直接根据地址获取设备绕开扫描流程又快又稳。拿到设备后发起连接就很简单了class BluetoothClientManager(private val adapter: BluetoothAdapter) { private var socket: BluetoothSocket? null fun connect(device: BluetoothDevice, uuid: UUID, callback: (Boolean) - Unit) { Thread { var result false try { // 连接前必须取消发现否则会明显拖慢连接 adapter.cancelDiscovery() socket device.createRfcommSocketToServiceRecord(uuid) socket?.connect() result true } catch (e: IOException) { e.printStackTrace() } callback(result) }.start() } fun sendData(data: ByteArray) { try { socket?.outputStream?.write(data) socket?.outputStream?.flush() } catch (e: IOException) { e.printStackTrace() } } fun close() { try { socket?.close() } catch (_: IOException) {} socket null } }这里有个细节值得多说一句我在代码里特意用了Charsets.UTF_8来编解码字符串不要用系统默认字符集。不同手机默认字符集可能不一样如果对方和自己用的不是同一个字符集中文乱码和字节错位就是分分钟的事。无论读写字符集统一这是所有通信项目的底线。3.4 数据收发的协议设计为什么光用 read 不行Socket 连上只是第一步数据能正确解析才是真正完成通信。BluetoothSocket的InputStream是字节流它不保证一次 read 就能拿到完整的一条消息。比如你发送一个“hello”对端可能一次读到了全部 5 个字节也可能先读到 2 个字节再读到 3 个字节完全取决于系统调度。如果发送的数据长度超过缓冲区还会出现一条消息被分成多次读到的“粘包/半包”问题。要解决这个问题最简单实用的方案就是“长度头 内容体”的帧格式。发数据时先把内容字节数组的长度用一个固定长度的整数比如 4 字节放在最前面然后紧接着写内容字节收数据时先读取 4 字节解析出长度 N再继续读 N 个字节作为内容。这样才能保证对端拼出来的永远是一条完整消息。fun writeFrame(outputStream: OutputStream, payload: ByteArray) { // 先写一个 4 字节的长度头Java 默认大端序 val lengthHeader ByteBuffer.allocate(4).putInt(payload.size).array() outputStream.write(lengthHeader) outputStream.write(payload) outputStream.flush() } fun readFrame(inputStream: InputStream): ByteArray? { val lengthBytes ByteArray(4) var offset 0 // 先保证读到 4 字节长度 while (offset 4) { val count inputStream.read(lengthBytes, offset, 4 - offset) if (count -1) return null offset count } val length ByteBuffer.wrap(lengthBytes).int // 再按长度读内容 val payload ByteArray(length) var readCount 0 while (readCount length) { val count inputStream.read(payload, readCount, length - readCount) if (count -1) return null readCount count } return payload }这是我从第一次做蓝牙透传项目就在用的协议简单可靠尤其适合手机和单片机之间通信。如果你和硬件模块通信协议的字段定义就得提前约定好比如第 1 字节是命令字、第 2 字节是数据长度后面跟着具体数据这种协议设计思路完全一样只是把“长度头”替换成更贴合业务的自定义头。3.5 资源释放和断开的正确顺序通信结束或者异常退出时资源释放顺序很有讲究。原则是先关流再关 Socket顺序反了会出现底层文件描述符提前释放导致对方读到奇怪的 EOF。正确做法是在finally块里对InputStream、OutputStream、BluetoothSocket依次调用close()同时每个close()都用异常捕获包住避免一个流关闭失败影响其他资源释放。还有一个小坑Socket 关闭之后你再调用getInputStream()或者getOutputStream()会抛出IOException所以不要保存流引用到处复用每次通信前最好检查一下 Socket 的isConnected状态。但要注意isConnected只能代表“连接过”不能代表“当前还连接着”所以真正判断对方是否断线还是要靠读流时返回 -1 或者抛出异常来判断。4. 常见问题与排查技巧实录4.1 高频异常对照速查表先放一张我能想到的最全的速查表都是我实际遇到过、也在网上帮别人排查过很多次的问题现象或报错常见原因处理思路connect()抛出IOException: socket closed蓝牙适配器被关闭、权限不足或 Socket 已提前 close先确认isEnabled()申请权限后再连接检查代码是否有先 close 再 connectService discovery failed客户端和服务端 UUID 不一致或服务端没有注册对应服务打印两端的 UUID 字符串逐字节对比确认完全一致read failed, socket might closed or timeout对端主动断开、链路超时、或者 Socket 状态异常捕获异常后统一走重连流程检查对端设备是否休眠Socket is not connected还没有调用connect()就调用getInputStream()确认连接成功后再获取流在网络状态回调里加状态判断连接耗时很长甚至几十秒才成功没有在connect()前调用cancelDiscovery()在connect()之前显式取消扫描Android 12 上扫描不到任何设备没有动态申请BLUETOOTH_SCAN权限进入设置检查权限运行时申请附近设备权限连接成功但收不到数据对端发送格式不是 UTF-8或协议存在粘包检查字符集用十六进制打印原始数据排查能连接但一写数据就崩主线程操作流导致的 ANR 或售后异常确认读写都发生在子线程这张表我建议截图留一份出了问题翻出来对照比翻系统日志肉眼找快得多。4.2 Android 12 以上权限导致连不上设备这个坑我重点讲一下。我在 targetSdk 升到 33 之后第一次跑旧的蓝牙功能发现设备扫描列表直接是空的当时第一反应是startDiscovery()没生效后来反复查才发现是权限模型变了。Android 12 上BLUETOOTH和BLUETOOTH_ADMIN这两个权限已经不再生效必须把 targetSdk 31 的设备上运行的 App 换成BLUETOOTH_SCAN、BLUETOOTH_CONNECT这几个新权限并且要像申请定位权限一样在运行时动态申请。一个很容易被忽略的问题是手机上“附近的设备”权限和“位置信息”权限在设置里是分开显示的。如果你只申请了位置权限没申请附近的设备权限可能不会立刻报错但扫描会异常。所以判断的问题思路应该是系统版本、targetSdk、manifest 权限、动态权限逐一核对。这个排查顺序能帮你省下至少俩小时。4.3 蓝牙模块连不上先排查这两个方向如果你的连接对象是 HC-05、HC-06 或者 ESP32 这类蓝牙串口模块连不上通常主要做两件事第一确认模块的工作模式。HC-05 有主模式、从模式、回环模式等。手机作为客户端发起连接的时候模块需要处于从模式或者可被搜索状态有的模块默认是 AT 模式压根不响应你的连接请求。第二确认模块的波特率和 UUID。部分模块除了 SPP 标准 UUID 外可能自己实现了一套服务列表如果你的客户端 UUID 不是它们支持的就会Service discovery failed。这类硬件模块还有个非常典型的现象连接成功后第一次通信可能就超时。这个大概率是模块内部缓冲区或者电源不稳定。我的经验是先把波特率调到模块的默认值用串口助手确认模块自身收发没问题再跟手机 App 对接避免两边同时怀疑对方排查效率很低。4.4 真机调试的一些小技巧开发蓝牙功能模拟器基本帮不上忙。建议准备两台真机一台做服务端一台做客户端这是最接近真实环境的调试方案。如果没有两台手机用安卓手机 开发板或者 USB 转蓝牙适配器也能凑合。另外说一个很实用的调试技巧在 App 的调试界面把远端蓝牙设备的 MAC 地址打印出来并且允许手动输入 MAC 地址去连接。这样调试某些“只出现在日志里但不在设备列表里”的硬件时能省掉一整套扫描流程。数据收发阶段如果发现读到的字节不对不要拿字符串直接看先把原始字节转成十六进制字符串打印出来这一步能帮你快速判断是编码问题、粘包问题还是对端数据本身就发错了。我调试蓝牙时经常用这个方法一眼就能看出数据帧边界。5. 进一步扩展从 BluetoothSocket 到真实项目的落地方案5.1 连接状态机和自动重连机制真正做产品级 App不可能只做一次连接。设备远离、系统蓝牙休眠、对端被系统回收都可能导致连接断开。不做断线重连的话用户很快会心累。我的做法是引入一个连接状态机把状态分成 IDLE、CONNECTING、CONNECTED、DISCONNECTING 这几个每个状态都定义进入和离开的条件。断线后不立刻重连而是延迟 1 秒、2 秒、4 秒这样指数退避地重试同时暴露一个回调给 UI 层显示“连接中 / 已连接 / 连接断开”的状态。这套机制看着简单但做起来对稳定性的提升非常明显。尤其是在手机蓝牙本身会自动休眠的情况下没有重连机制App 在锁屏 10 分钟之后基本就成“死连接”了。5.2 与硬件设备通信时的协议约定和嵌入式硬件通信跟两台安卓手机互发数据还不一样。手机之间可以双方约定用同一个自定的 JSON 文本协议出了问题好排查但硬件设备往往只有几十 KB 的 Flash没有能力和耐心去解析 JSON更多时候是只认一两个字节的命令帧。所以与硬件通信协议设计要更紧凑比如帧头固定为0xAA 0x55紧接着命令字、长度、数据、校验和。这里有一个最容易出的问题校验和怎么算、长度字节是单字节还是双字节这些细节必须写进协议文档并且在代码里用常量定义好。我见过不少项目安卓这边写得好好的硬件那边因为一字节长度上限 255 导致大数据包被截断两边调试了整整一天才发现是长度字段宽度定义不一致。所以和硬件对接前建议先用一个简单的 PC 串口工具把完整协议跑通再连手机。5.3 文件传输场景下和 FileProvider 的配合很多人都想用蓝牙互传文件而且网上搜 “Android 蓝牙” 时经常能看到content://开头的路径。这里要说明白蓝牙 Socket 本身只负责字节流传输不关心数据来源。你要传文件得先用系统的FileProvider或者ContentResolver拿到文件对应的输入流比如通过文件选择器选择文件后得到content://...这样的 URI再通过contentResolver.openInputStream(uri)从里面读出字节往蓝牙OutputStream里写。注意一个易踩的坑content://URI 不能直接转成File路径去读文件尤其是 Android 10 以后普遍启用分区存储直接操作文件路径会抛FileNotFoundException或者权限异常。正确的做法永远是先拿 URI再拿 InputStream然后字节流对接字节流。蓝牙传输文件千万不要用一次性把整个文件读入内存再发送的方式几 MB 的小文件问题不大几十 MB 的文件会直接 OOM。读一段 4KB 的缓冲区边读边写加上我之前说的长度头分帧协议对端一边收一边写文件这才是正确的文件传输姿势。我个人在实际项目里的体会是BluetoothSocket 看着是个不起眼的类但它把 Android 传统蓝牙最核心的通信能力封装得恰到好处。它没有 BLE 那么繁琐的 GATT 结构也没有网络 Socket 那么复杂的路由和包管理就是一条干净利落的字节通道。只要你搞清了 UUID、权限兼容、线程模型和帧协议这四件事剩下的大多数问题都是经验问题。最后再分享一个小技巧把 UUID 和蓝牙设备 MAC 地址用一个可配置的常量表管理起来调试时改成可外部注入会省掉大量来回烧版本的时间。如果你正被蓝牙连接折磨得头疼希望你今天合上这篇文章之后能对着代码日志更有底气地定位问题。
返回列表