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 了,它已经开始逼你认真设计传输层。

相关推荐
2501_915909062 小时前
iOS应用签名的三种方法全解析:从官方到第三方工具
android·ios·小程序·https·uni-app·iphone·webview
饭小猿人17 小时前
Android 腾讯X5WebView如何禁止系统自带剪切板和自定义剪切板视图
android·java
_李小白17 小时前
【android opencv学习笔记】Day 8: remap(像素位置重映射)
android·opencv·学习
美狐美颜SDK开放平台17 小时前
多场景美颜SDK解决方案:直播APP(iOS/安卓)开发接入详解
android·人工智能·ios·音视频·美颜sdk·第三方美颜sdk·短视频美颜sdk
嗷o嗷o18 小时前
Android BLE 里,MTU、分包和长数据发送到底该怎么处理
android
Gary Studio19 小时前
Android AIDL HAL工程结构示例
android
y = xⁿ20 小时前
MySQL八股知识合集
android·mysql·adb
andr_gale20 小时前
04_rc文件语法规则
android·framework·aosp