MTU协商与分包传输:大数据的“拆”与“合”

好,咱们继续往下聊。上一章我们把BLE的连接建立起来了,但连接只是第一步。真正开始传数据时,你很快会遇到一个现实问题:一次能发多少字节?

答案是——取决于MTU。

MTU,全称是Maximum Transmission Unit,最大传输单元。说白了,就是蓝牙协议栈里,一个数据包最多能装多少字节的有效载荷。默认情况下,这个值通常是23字节。你没看错,23。扣掉3个字节的L2CAP头部,真正留给你的应用数据只有20字节。

20字节能干什么?传个温度值还行,传个128字节的UUID都费劲。更别提传图片、传OTA固件了。所以,MTU协商是BLE开发里绕不开的一步

核心结论:MTU协商不是可选项,而是高性能BLE应用的必选项。不做MTU协商,你的传输效率可能只有理论值的十分之一。

MTU协商:requestMtu与onMtuChanged

Android BLE API里,MTU协商的入口是BluetoothGatt.requestMtu(int mtu)。你传一个期望值进去,比如512,系统会跟设备端协商,最终取双方都支持的最小值。

我个人的习惯是,连接成功后立即发起MTU协商,不等用户操作。因为很多外设的MTU能力是固定的,早协商早受益。

// 发起MTU协商
bluetoothGatt.requestMtu(512);

协商结果通过回调onMtuChanged通知你:

@Override
public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
    if (status == BluetoothGatt.GATT_SUCCESS) {
        // 协商成功,mtu就是实际可用的MTU值
        Log.d("BLE", "MTU协商成功: " + mtu);
        // 注意:实际应用数据载荷 = mtu - 3
    } else {
        // 协商失败,通常回退到默认23
        Log.w("BLE", "MTU协商失败,使用默认值");
    }
}

这里有个坑,我曾经踩过:requestMtu必须在连接成功后调用,但不能在onConnectionStateChange里直接调。因为此时GATT服务可能还没准备好。我建议在onServicesDiscovered回调之后再发起协商,或者加一个短暂延迟。

小技巧:很多外设支持的最大MTU是247(ATT层最大包长)。如果你不确定,可以先请求512,看回调返回的实际值。Android 5.0以上设备通常支持到512甚至更大。

大包数据拆分:把大象装进冰箱

假设你协商到了512的MTU,实际可用载荷是509字节。但如果你要传一个10KB的固件包呢?显然不能一次发完。你需要拆分

拆分的逻辑其实很简单:

  • 确定每次发送的有效载荷大小:payloadSize = mtu - 3
  • 把大数据按payloadSize切块
  • 逐块发送,每块加上序号或长度信息
  • 接收端按序号重组

我写过一个通用的拆分发送工具类,核心逻辑是这样的:

public class BlePacketSender {
    private static final int HEADER_SIZE = 4; // 2字节序号 + 2字节总长度
    
    public void sendLargeData(BluetoothGatt gatt, byte[] data, int mtu) {
        int payloadSize = mtu - 3 - HEADER_SIZE; // 实际数据载荷
        int totalPackets = (int) Math.ceil((double) data.length / payloadSize);
        
        for (int i = 0; i < totalPackets; i++) {
            int offset = i * payloadSize;
            int length = Math.min(payloadSize, data.length - offset);
            
            // 构建包:序号(2字节) + 总包数(2字节) + 数据
            ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE + length);
            buffer.putShort((short) i);          // 当前包序号
            buffer.putShort((short) totalPackets); // 总包数
            buffer.put(data, offset, length);
            
            // 发送到特征值
            gatt.writeCharacteristic(characteristic, buffer.array(), 
                BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE, null);
        }
    }
}

嗯,这里要注意:WRITE_TYPE_NO_RESPONSE和WRITE_TYPE_DEFAULT的选择。带响应的写入(WRITE_TYPE_DEFAULT)每个包都要等ACK,速度慢但可靠。无响应写入(WRITE_TYPE_NO_RESPONSE)快,但可能丢包。我一般在OTA场景用无响应写入,配合应用层的ACK机制。

粘包处理:接收端的“拼图游戏”

发送端拆了包,接收端就得拼回去。但BLE的onCharacteristicChanged回调是按包触发的,你收到的是一块一块的数据。如果接收端不做处理,直接拿到的就是碎片。

粘包处理的核心是缓存与重组

  1. 维护一个接收缓冲区
  2. 每收到一个包,解析头部,提取序号和总包数
  3. 按序号存入缓冲区对应位置
  4. 检查是否收齐所有包
  5. 收齐后合并数据,回调给上层

我曾经在项目里遇到一个奇怪的问题:接收端总是少一个包。排查了半天,发现是序号从0开始,但总包数计算时用了整数除法,导致最后一个包被吞了。后来改成Math.ceil才解决。细节是魔鬼啊。

public class BlePacketReceiver {
    private byte[][] buffer;
    private int totalPackets = -1;
    
    public void onPacketReceived(byte[] packet) {
        ByteBuffer bb = ByteBuffer.wrap(packet);
        int seq = bb.getShort();
        int total = bb.getShort();
        
        if (totalPackets != total) {
            // 新的一批数据,重置缓冲区
            totalPackets = total;
            buffer = new byte[total][];
        }
        
        byte[] payload = new byte[packet.length - 4];
        bb.get(payload);
        buffer[seq] = payload;
        
        // 检查是否收齐
        for (int i = 0; i < totalPackets; i++) {
            if (buffer[i] == null) return; // 还有缺失
        }
        
        // 全部收齐,合并
        int totalLength = 0;
        for (byte[] part : buffer) totalLength += part.length;
        byte[] fullData = new byte[totalLength];
        int offset = 0;
        for (byte[] part : buffer) {
            System.arraycopy(part, 0, fullData, offset, part.length);
            offset += part.length;
        }
        
        // 回调上层
        onDataComplete(fullData);
    }
}

注意:接收缓冲区要考虑内存问题。如果传输的数据很大(比如几MB),不要一次性分配全部缓冲区。可以用稀疏数组或Map来存储,避免OOM。

知识体系总览

下面这张图把MTU协商、分包发送、粘包接收的完整流程串起来了。你可以把它当作一个参考架构:

BLE MTU协商与分包传输流程 1. MTU协商 requestMtu(512) 2. 分包发送 按MTU-3切块 3. 粘包接收 缓存+重组 MTU协商细节 • 默认MTU: 23字节 • 有效载荷: MTU-3 • 协商时机: 连接成功后 • 回调: onMtuChanged • 失败处理: 回退默认值 • 常见值: 247/512/1024 分包发送细节 • 计算payloadSize • 添加包头: 序号+总包数 • 逐块写入特征值 • 写入类型选择 • 流控: 等待onWrite • 超时重传机制 粘包接收细节 • 解析包头: 序号 • 缓存到缓冲区 • 检查是否收齐 • 合并完整数据 • 内存管理 • 异常包处理 三者协同:协商→分包→重组,构成完整的大数据传输链路

避坑指南:我踩过的那些坑

做BLE开发这么多年,MTU这块的坑我踩了不少。挑几个典型的说说:

  • MTU协商不是越大越好。我曾经在一个项目里把MTU设成1024,结果某些老旧设备直接断连。后来发现那些设备虽然支持大MTU,但底层buffer不够,导致溢出。建议先请求一个保守值(比如247),再根据实际需求逐步增大。
  • 分包发送要加流控。Android的writeCharacteristic虽然是异步的,但底层队列有限。如果你一股脑发几百个包,很可能丢包。我习惯在onCharacteristicWrite回调里发下一个包,形成一个“发一个等一个”的流水线。
  • 粘包处理要考虑乱序。虽然BLE底层保证同一连接上的包顺序,但如果你用了多个连接或者有重传机制,乱序是可能发生的。所以接收端一定要按序号重组,不能假设顺序到达。
  • 别忘了处理MTU变化。有些设备在连接过程中会动态调整MTU(比如进入OTA模式后)。你的代码要能响应onMtuChanged,动态调整分包大小。

个人经验:如果你在做OTA升级,建议把MTU协商、分包发送、ACK确认、超时重传做成一个完整的状态机。不要用简单的for循环发完所有包,那样太脆弱了。状态机虽然代码量多一些,但稳定性和可维护性都好很多。

好了,MTU这块就聊这么多。说白了,就是三个字:拆、传、合。拆得合理,传得稳定,合得完整,你的BLE数据传输就稳了。


公众号:蓝海资料掘金营,微信deep3321