做 BLE,很多人前期都会觉得收发数据没那么复杂。连上设备,拿到 characteristic,调一下写入,回调也能进,事情看起来就差不多了。
这种感觉一般持续到你第一次发长数据。
一开始可能只是发一段稍长一点的配置,或者下发一串参数。再往后一点,像 OTA、设备同步、批量设置、固件命令这种东西一上来,问题就开始集中出现了。写入回调成功了,但设备没按预期执行;设备回包回来了一部分,后面像丢了一样;日志里能看到字节流,但协议总拼不完整。再往深一点,你会发现很多所谓的"BLE 不稳定",其实根本不是连接问题,而是你对 MTU、分包和长数据链路的假设太乐观了。
BLE 这块最容易被想简单的一点,就是默认把"发一条数据"当成"写一个 ByteArray"。
比如一开始代码通常长这样:
kotlin
val payload = buildPayload()
gatt.writeCharacteristic(
characteristic,
payload,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
如果 payload 很短,这么写可能完全没问题。于是很多人就顺理成章地把这个模式一路带到后面。真正开始出问题,往往是数据长度上来以后。
因为 BLE 并不保证你这包数据可以原样一把发完。
你最后真正能一次性带走多少数据,首先受 MTU 影响。
MTU 到底在限制什么
最容易记的一句话是:
MTU 决定的是单次 ATT 层能承载的最大数据长度,不是你业务消息天然能多长。
Android 里如果你不主动协商,默认 MTU 通常就是比较保守的一档。很多人第一次接 BLE,甚至都没碰过 requestMtu(),因为小数据场景确实还能混过去。可只要消息稍微长一点,这个默认值就会马上变成限制。
先看最常见的写法:
kotlin
gatt.requestMtu(247)
回调里拿结果:
kotlin
private var currentMtu = 23
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
currentMtu = mtu
}
}
这里要先有一个基本概念:即使你申请了 247,最终也不是你说了算,而是双方协商出来的值。设备不支持那么大,你最后拿到的还是会小。
另外,很多人以为 MTU 拿到了 247,就意味着可以随便发 247 字节。这也不对。因为 ATT 写 characteristic 时还要扣掉协议头开销,真正 payload 能用的部分通常还得减。
在最常见的 write request / write command 场景里,业务上经常会先按:
kotlin
val payloadSize = mtu - 3
来算每段最大可发长度。
也就是说,如果当前 MTU 是 23,那很多时候你真正一次能稳定写的业务 payload 大概也就 20 字节左右。这个数字很多 BLE 老项目里经常会被直接写死成 20,不是因为大家懒,而是因为默认场景下它确实经常就是这个量级。
为什么长数据不能默认一把写完
这个问题说白了,其实不是 Android API 的问题,而是传输层的现实约束。
如果你有一段 300 字节的数据,当前 payload 上限只有 20 或者 100 多字节,那它就不可能原样一次性穿过去。你必须自己分。
所以 Android 端真正稳一点的写法,通常不会直接对"完整业务消息"调用 writeCharacteristic(),而是先做 chunk。
比如先写一个最基本的拆包函数:
kotlin
fun chunkPayload(data: ByteArray, mtu: Int): List<ByteArray> {
val maxPayload = mtu - 3
if (maxPayload <= 0) return emptyList()
val result = mutableListOf<ByteArray>()
var index = 0
while (index < data.size) {
val end = minOf(index + maxPayload, data.size)
result.add(data.copyOfRange(index, end))
index = end
}
return result
}
这样一来,真正写的时候就不是"一条消息",而是"一串片段"。
kotlin
val chunks = chunkPayload(payload, currentMtu)
到这里还只是第一步。真正的坑在下一步:这些片段不能一股脑全写出去。
分包以后,发送顺序还是要靠队列
很多人分包做对了,后面还是不稳,原因是拆完以后直接循环写:
kotlin
for (chunk in chunks) {
gatt.writeCharacteristic(
characteristic,
chunk,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
}
这类代码看起来有理,其实很危险。因为 writeCharacteristic() 不是同步阻塞操作,你不能假设上一包刚调完,下一包就自然排好了。前面你写 GATT 操作队列那篇其实已经碰到这个问题了,这里只是把它放大了。
长数据发送更稳的方式,一般还是回到串行队列。
比如先做一个最小发送器:
kotlin
class BleChunkWriter(
private val gatt: BluetoothGatt,
private val characteristic: BluetoothGattCharacteristic
) {
private val queue = ArrayDeque<ByteArray>()
private var writing = false
fun send(data: ByteArray, mtu: Int) {
val chunks = chunkPayload(data, mtu)
queue.addAll(chunks)
if (!writing) {
writeNext()
}
}
fun onChunkWritten() {
writing = false
writeNext()
}
fun onChunkFailed() {
writing = false
queue.clear()
}
private fun writeNext() {
val chunk = queue.removeFirstOrNull() ?: run {
writing = false
return
}
writing = true
gatt.writeCharacteristic(
characteristic,
chunk,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
}
}
然后在回调里推进下一包:
kotlin
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
chunkWriter.onChunkWritten()
} else {
chunkWriter.onChunkFailed()
}
}
这个结构看起来很普通,但它至少抓住了长数据发送最重要的一点:
分包只是开始,串行推进才是真正的发送过程。
写入成功,不等于设备已经处理成功
这一点也特别容易被误解。
很多人看到 onCharacteristicWrite() 成功,就会下意识觉得"设备已经收到了,并且已经处理完了"。实际上这个回调通常只能说明:当前这包数据从 Android 侧写出去了。
它不一定代表:
- 对端业务层已经处理完成
- 整条长数据已经拼完
- 这次操作在设备侧被认成成功
所以如果你的业务场景是"写一大包配置 -> 等设备真正应用",更稳的设计通常还需要设备协议层回一个明确 ACK。
也就是说,这里最好分开看两层成功:
- GATT 写成功
- 设备业务确认成功
如果你把这两件事混成一个概念,后面排查问题会非常累。因为日志里你看到的是"都写成功了",实际用户感知却是"设备没生效"。
接收端也一样会遇到分包问题
很多人只在发送侧想分包,接收侧反而默认"设备回来的每次回调就是完整消息"。这和前面提到的误区是同一类问题。
如果设备回的是长数据,比如固件信息、配置列表、升级状态块,那回包也可能是分段的。
也就是说,接收端同样需要缓冲和组包,而不是直接在 onCharacteristicChanged() 里假设消息已经完整。
比如你可以做一个最简单的接收缓冲器:
kotlin
class IncomingPacketAssembler(
private val onMessageReady: (ByteArray) -> Unit
) {
private val buffer = mutableListOf<Byte>()
fun append(chunk: ByteArray) {
buffer.addAll(chunk.toList())
tryAssemble()
}
private fun tryAssemble() {
while (true) {
if (buffer.size < 3) return
if (buffer[0] != 0xAA.toByte()) {
buffer.removeAt(0)
continue
}
val payloadLength = buffer[2].toInt() and 0xFF
val fullLength = 4 + payloadLength
if (buffer.size < fullLength) return
val packet = buffer.take(fullLength).toByteArray()
repeat(fullLength) { buffer.removeAt(0) }
onMessageReady(packet)
}
}
}
然后回调里别直接 parse:
kotlin
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
incomingAssembler.append(value)
}
你前面写"收到字节流以后,为什么业务状态还是不对"那篇,其实已经碰到这个问题了。这里只是把它放到长数据场景里再说透一点。
MTU 不要想得太理想
还有一个现实问题,很多人一开始都会乐观估计。
比如代码里写了:
kotlin
gatt.requestMtu(247)
然后脑子里就默认"后面都按 247 来发"。
但真实项目里,你得面对几个事实:
- 设备未必支持这个值
- 某些机型协商行为不完全一致
- 某些链路阶段 MTU 改了,业务层未必知道
- 你自己的 chunk size 如果写死了,后面很容易错
所以更稳一点的做法是:
不要把分包逻辑写死在常量里,而是始终基于当前协商出来的 currentMtu 去算。
kotlin
private var currentMtu = 23
private fun currentPayloadSize(): Int {
return (currentMtu - 3).coerceAtLeast(1)
}
然后无论发送还是接收侧,都围绕这个值工作。
长数据问题,最后常常会变成协议问题
这也是 BLE 项目到后面最真实的一点。
很多"长数据发不稳"的问题,最后不一定停留在 MTU 本身,而是会一路往协议设计上走。
比如:
- 每个 chunk 要不要带序号
- 设备侧怎么知道一条长消息结束了
- 如果中间丢了一段,要不要重发
- 对端 ACK 是按 chunk 回,还是按整条业务消息回
- OTA 这种场景是不是要上更完整的传输控制
你如果只是偶尔发一段配置,简单分包加队列可能已经够了。
但如果你后面要做 OTA、批量参数同步、或者高频大包交换,单纯"按 MTU 拆一下再发"通常不够。
这也是为什么很多高通耳机项目最后会显得比普通 BLE 控制重很多。因为一旦进入升级、同步、批处理这些链路,BLE 已经不再只是"写个 characteristic"那么简单了。
一个最小可用的完整发送链路
如果把今天这篇收成一个最小可参考版本,大概可以是这样:
kotlin
class BleLongDataSender(
private val gatt: BluetoothGatt,
private val characteristic: BluetoothGattCharacteristic
) {
private var currentMtu = 23
private val queue = ArrayDeque<ByteArray>()
private var sending = false
fun onMtuChanged(mtu: Int) {
currentMtu = mtu
}
fun send(payload: ByteArray) {
val chunks = chunkPayload(payload, currentMtu)
queue.addAll(chunks)
if (!sending) {
sendNext()
}
}
fun onWriteResult(success: Boolean) {
if (!success) {
sending = false
queue.clear()
return
}
sending = false
sendNext()
}
private fun sendNext() {
val next = queue.removeFirstOrNull() ?: run {
sending = false
return
}
sending = true
gatt.writeCharacteristic(
characteristic,
next,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
}
}
这个版本当然还不算工业级,但已经把最核心的几个现实问题收进去了:
- MTU 要动态拿
- 长数据要拆
- chunk 不能并发乱发
- 写成功只代表当前 chunk 走完
对很多 BLE 项目来说,这已经比"直接扔一个大 ByteArray"稳很多了。
最后一句话
BLE 里真正麻烦的,从来不是"怎么写一包数据",而是当数据不再短小的时候,你还能不能把链路想清楚。
MTU 不只是一个参数,分包也不只是一个循环。
一旦数据变长,发送顺序、设备 ACK、接收组包、协议边界,这些东西都会一起找上门。
所以 Android BLE 里,MTU、分包和长数据发送真正难的地方,不是 API,而是你得承认:
从长数据开始,BLE 就不只是写 characteristic 了,它已经开始逼你认真设计传输层。