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回调是按包触发的,你收到的是一块一块的数据。如果接收端不做处理,直接拿到的就是碎片。
粘包处理的核心是缓存与重组:
- 维护一个接收缓冲区
- 每收到一个包,解析头部,提取序号和总包数
- 按序号存入缓冲区对应位置
- 检查是否收齐所有包
- 收齐后合并数据,回调给上层
我曾经在项目里遇到一个奇怪的问题:接收端总是少一个包。排查了半天,发现是序号从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这块的坑我踩了不少。挑几个典型的说说:
- MTU协商不是越大越好。我曾经在一个项目里把MTU设成1024,结果某些老旧设备直接断连。后来发现那些设备虽然支持大MTU,但底层buffer不够,导致溢出。建议先请求一个保守值(比如247),再根据实际需求逐步增大。
- 分包发送要加流控。Android的writeCharacteristic虽然是异步的,但底层队列有限。如果你一股脑发几百个包,很可能丢包。我习惯在
onCharacteristicWrite回调里发下一个包,形成一个“发一个等一个”的流水线。 - 粘包处理要考虑乱序。虽然BLE底层保证同一连接上的包顺序,但如果你用了多个连接或者有重传机制,乱序是可能发生的。所以接收端一定要按序号重组,不能假设顺序到达。
- 别忘了处理MTU变化。有些设备在连接过程中会动态调整MTU(比如进入OTA模式后)。你的代码要能响应
onMtuChanged,动态调整分包大小。
个人经验:如果你在做OTA升级,建议把MTU协商、分包发送、ACK确认、超时重传做成一个完整的状态机。不要用简单的for循环发完所有包,那样太脆弱了。状态机虽然代码量多一些,但稳定性和可维护性都好很多。
好了,MTU这块就聊这么多。说白了,就是三个字:拆、传、合。拆得合理,传得稳定,合得完整,你的BLE数据传输就稳了。