Android BLE 里,MTU、分包和长数据发送到底该怎么处理

做 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。

也就是说,这里最好分开看两层成功:

  1. GATT 写成功
  2. 设备业务确认成功

如果你把这两件事混成一个概念,后面排查问题会非常累。因为日志里你看到的是"都写成功了",实际用户感知却是"设备没生效"。

接收端也一样会遇到分包问题

很多人只在发送侧想分包,接收侧反而默认"设备回来的每次回调就是完整消息"。这和前面提到的误区是同一类问题。

如果设备回的是长数据,比如固件信息、配置列表、升级状态块,那回包也可能是分段的。

也就是说,接收端同样需要缓冲和组包,而不是直接在 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 了,它已经开始逼你认真设计传输层。

相关推荐
Gary Studio3 小时前
Android AIDL HAL工程结构示例
android
y = xⁿ3 小时前
MySQL八股知识合集
android·mysql·adb
andr_gale4 小时前
04_rc文件语法规则
android·framework·aosp
祖国的好青年5 小时前
VS Code 搭建 React Native 开发环境(Windows 实战指南)
android·windows·react native·react.js
黄林晴5 小时前
警惕!AGP 9.2 别只改版本号,R8 规则与构建链路全线收紧
android·gradle
小米渣的逆袭6 小时前
Android ADB 完全使用指南
android·adb
儿歌八万首6 小时前
Jetpack Compose Canvas 进阶:结合 animateFloatAsState 让自定义图形动起来
android·动画·compose
zhangphil7 小时前
Android Page 3 Flow读sql数据库媒体文件,Kotlin
android·kotlin