☰
Android BluetoothSocket 从原理到实战:UUID、权限与数据通信全攻略
2026/10/3 7:43:31 网站建设 项目流程

做安卓开发的人,只要涉及到“两个设备之间传数据”,蓝牙基本都是绕不开的方案。环境配好、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真正对应的底层通道,是 RFCOMM(Radio 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 不是随便填的,它是双方约定的服务标识

UUID(Universally Unique Identifier)是一个 128 位的全局唯一标识符。在蓝牙通信里,UUID 的作用就是告诉系统“我提供的是哪一类服务”。客户端发起连接时,系统会拿着这个 UUID 去跟远端设备通信;远端设备收到请求后,会看看自己有没有对应的服务。UUID 对上了,连接就建立成功;对不上,客户端就会抛出Service discovery failed之类的异常。

我们先说最简单的做法:自己随便生成一个 UUID,比如用UUID.randomUUID()得到一个值,然后把它同时写死在服务端和客户端的代码里。只要两边用的值一样,就能连上。还有一种做法是使用 SPP 的标准 UUID:00001101-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 11(API 30)及以下的系统上,主要使用的是两个普通权限:BLUETOOTH和BLUETOOTH_ADMIN,这两个在 manifest 里直接声明即可,不需要运行时申请。但是需要注意的是,如果你想要扫描和发现周围的蓝牙设备,还需要申请定位权限:ACCESS_FINE_LOCATION,而且这个权限是危险权限,必须在运行时动态申请。因为系统认为扫描蓝牙可以发现设备的地理位置,所以强制绑定了定位权限,这在 Android 6.0 到 Android 11 都是这个规则。

到了 Android 12(API 31)及以上,系统引入了三个新的蓝牙运行时权限:BLUETOOTH_SCAN(扫描设备)、BLUETOOTH_CONNECT(连接已配对设备)、BLUETOOTH_ADVERTISE(对外广播本机为可发现设备)。如果 targetSdk 是 31 以上且运行在 Android 12 以上的设备上,这三个权限需要动态申请,同时BLUETOOTH和BLUETOOTH_ADMIN这两个旧权限在这个级别上不再生效,直接用旧的还要申请ACCESS_FINE_LOCATION也不是必须了,尤其是当你在 manifest 里给BLUETOOTH_SCAN加了usesPermissionFlags="neverForLocation"属性时。

实际项目里为了兼容,通常会做一套“双版本权限声明 + 运行时按版本判断”的逻辑。常见配置是旧权限加上android:maxSdkVersion="30",新权限单独声明,然后运行时按系统版本分流申请。下面这段就是兼容性配置的常规写法:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.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.bondedDevices:

val pairedDevices: Set<BluetoothDevice>? = 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 再 connect
Service 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 地址用一个可配置的常量表管理起来,调试时改成可外部注入,会省掉大量来回烧版本的时间。如果你正被蓝牙连接折磨得头疼,希望你今天合上这篇文章之后,能对着代码日志更有底气地定位问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询