在本次 Java UDP 视频传输项目的初期实现中,我们虽然完成了摄像头采集、图片压缩、UDP 数据包发送与接收的基础功能,但未考虑 UDP 协议本身无连接、不可靠、无流控、数据包大小受限 的特性。而在实际通信应用中,往往会对 UDP 进行改进,可以理解为在程序层面上去模仿 TCP 的安全机制。因此,本期文章我们将会对数据包进行拆包 ,并自定义确认机制,让 UDP 视频通信更加稳定、可靠。
1.发送方
在网络传输中,MTU(Maximum Transmission Unit,最大传输单元) 是指链路层一次能传输的最大数据包大小 ,局域网以太网的标准 MTU 为 1500 字节 。也就是说,UDP 数据包总大小超过 1500 字节时,会被网络自动分片,而这将大幅增加丢包率 。我们可以对原来的数据包进行手动拆包,即把一帧图片分开发送。为了使传输更加安全稳定,我们将单包有效数据大小设定为 1400 字节。
对于每个数据包,我们可以加上编号值作为它的 ACK 值,ACK(Acknowledgement)即确认应答 ,是网络通信中接收方向发送方返回的接收确认信号,作用是告知发送方指定数据已成功接收,这在之前的文章中也有所介绍。这里以序号为 ACK 值,让接收方接收数据包后回传,可确保数据包无丢失、有序传输。
理解以上两个核心操作后,我们可以一步步细化封装的发送视频方法。
首先,和之前的操作一样,我们需要把数据长度单独打包发送,这可以让接收方知晓应该接收几个数据包便于验证。
java
//将数据长度转成byte 数组
int lenData = imageData.length;
byte[] lenBuf = new byte[4];
intToBytes(lenBuf, lenData);
//发送长度数据包
DatagramPacket lenPacket = new DatagramPacket(lenBuf, 0, lenBuf.length, address, port);
clientA.send(lenPacket);
接下来,我们需要将原数据拆分,并且加上 ACK 值重新打包。我们分别定义 offset 和 packetIndex 作为当前的数据位置和数据包序号(ACK)。在循环内以 final 变量 PACKET_SIZE(大小1400)为标准分割 imageData[] (原数据数组)。考虑到数组末尾的数据长度可能不足1400,我们用 int size = Math.min(PACKET_SIZE, lenData - offset);处理末尾数据。处理好数组大小后,再用 Arrays 类方法中的 copyOfRange(要复制的数组,起始位置,结尾位置) 方法把数据拷贝到新数组 packetArr 中。
然后,我们需要一个新数组 sendPacketArr 来拼接 packetArr 和 ACK 值,在前四位加上序号信息后,在第四位之后用 arraycopy 拼上 packetArr 的全部数据。
java
//最后数据包不到PACKET_SIZE时,调整size大小
int size = Math.min(PACKET_SIZE, lenData - offset);
//从imageData 中拷贝数据
byte[] packetArr = Arrays.copyOfRange(imageData, offset, offset + size);
//当前数据位置改变
offset += size;
//重新定义byte 数组,用于拼装序号和数据
byte[] sendPacketArr = new byte[packetArr.length + 4];
//给前四位拼上序号信息(ack值)
intToBytes(sendPacketArr, packetIndex);
//第四位后面复制packetArr 中的数据
System.arraycopy(packetArr, 0, sendPacketArr, 4, size);
最后,我们加上 ACK 验证重传机制。设置初始值为 false 的标志位,当标志位不为真时进入发送数据包的循环。用 ackBuf 数组接收回传的 ACK 值,只有 ACK 值符合条件,即传输成功时才退出循环。
java
//ack标志位,初始置为false
boolean ackFlag = false;
while (!ackFlag) {
//发送数据包
DatagramPacket imagePacket = new DatagramPacket(sendPacketArr, 0, sendPacketArr.length,
address, port);
clientA.send(imagePacket);
//定义byte 数组用于接收回传的ack值
byte[] ackBuf = new byte[4];
DatagramPacket ackPacket = new DatagramPacket(ackBuf, 0, ackBuf.length);
clientA.receive(ackPacket);
int ack = byteToInt(ackBuf);
System.out.println("ack=" + ack);
if (ack == packetIndex) {
//ack值正确,则将标志位置为true,不需要重传,数据包序号加1
ackFlag = true;
packetIndex++;
}
}
以下是发送方完整的发送视频方法参考代码。
java
public void sendVideo(byte[] imageData) throws Exception {
//将数据长度转成byte 数组
int lenData = imageData.length;
byte[] lenBuf = new byte[4];
intToBytes(lenBuf, lenData);
//发送长度数据包
DatagramPacket lenPacket = new DatagramPacket(lenBuf, 0, lenBuf.length, address, port);
clientA.send(lenPacket);
System.out.println(lenData);
int offset = 0;//记录当前数据位置
int packetIndex = 0;//记录拆包个数(序号)
while (offset < lenData) {
//最后数据包不到PACKET_SIZE时,调整size大小
int size = Math.min(PACKET_SIZE, lenData - offset);
//从imageData 中拷贝数据
byte[] packetArr = Arrays.copyOfRange(imageData, offset, offset + size);
//当前数据位置改变
offset += size;
//重新定义byte 数组,用于拼装序号和数据
byte[] sendPacketArr = new byte[packetArr.length + 4];
//给前四位拼上序号信息(ack值)
intToBytes(sendPacketArr, packetIndex);
//第四位后面复制packetArr 中的数据
System.arraycopy(packetArr, 0, sendPacketArr, 4, size);
//ack标志位,初始置为false
boolean ackFlag = false;
while (!ackFlag) {
//发送数据包
DatagramPacket imagePacket = new DatagramPacket(sendPacketArr, 0, sendPacketArr.length,
address, port);
clientA.send(imagePacket);
//定义byte 数组用于接收回传的ack值
byte[] ackBuf = new byte[4];
DatagramPacket ackPacket = new DatagramPacket(ackBuf, 0, ackBuf.length);
clientA.receive(ackPacket);
int ack = byteToInt(ackBuf);
System.out.println("ack=" + ack);
if (ack == packetIndex) {
//ack值正确,则将标志位置为true,不需要重传,数据包序号加1
ackFlag = true;
packetIndex++;
}
}
}
}
2.接收方
接收方首先会接收到整个图片的数据长度,我们可以用这个数据得到有多少个数据包要接收。
java
//读取总长度
byte[] lenBuf = new byte[4];
DatagramPacket receiveLen = new DatagramPacket(lenBuf, 0, lenBuf.length);
clientB.receive(receiveLen);
int len = byteToInt(lenBuf);
System.out.println(len);
//存储还原后的数据
byte[] imageData = new byte[len];
int maxPacket = (len + PACKET_SIZE - 1) / PACKET_SIZE; //计算有多少个数据包要接收
计算出 maxPacket 后,新建二维数组 buffPacket 按包序号缓存各个数据包、布尔数组 receiveFlag 标记包是否接收。再在循环内持续接收数据包,每次收到一个数据包,先解析出前 4 字节的包序号 ACK,提取后面的图片数据,并立即将 ACK 序号回传给发送端作为接收确认。接着通过 if 条件校验,确保包序号合法、未越界且未重复接收,再将数据存入二维数组对应序号位置并更新接收状态与计数,直到所有分包全部收齐。
最后循环遍历二维数组,按包序号顺序将所有缓存的数据包拼接成完整的字节数组 imageData ,返回给上层用于图片还原与界面绘制。
java
//用二维数组缓存每个数据包,二维的默认值是null
byte[][] buffPacket = new byte[maxPacket][];
boolean[] receiveFlag = new boolean[maxPacket];
int receiveCount = 0;
while (receiveCount < maxPacket) {
//读取一个数据包
byte[] packetBuf = new byte[PACKET_SIZE + 4];
DatagramPacket packet = new DatagramPacket(packetBuf, 0,
packetBuf.length);
clientB.receive(packet);
int readLen = packet.getLength();
System.out.println("packet.getLength() = " + packet.getLength());
//从packetBuf 解析出ACK用于回传
int ack = byteToInt(packetBuf);
System.out.println("ack = " + ack);
//提取从 packetBuf数组的图像数据
byte[] imageBuf = new byte[readLen - 4];
System.arraycopy(packetBuf, 4, imageBuf, 0, imageBuf.length);
//回传 ACK
byte[] ackBuf = new byte[4];
intToBytes(ackBuf, ack);
DatagramPacket ackPacket = new DatagramPacket(ackBuf, 0,
ackBuf.length, packet.getAddress(), packet.getPort());
System.out.println("ip:" + packet.getAddress().getHostName() + "port:"+packet.getPort());
clientB.send(ackPacket);
//把读取到数据保存到缓冲数组中
if (ack >= receiveCount && ack < maxPacket && !receiveFlag[ack]) {
buffPacket[ack] = imageBuf;
receiveFlag[ack] = true;
receiveCount++;
}
}
//拼接缓冲数组数据
int index = 0;
for (byte[] b : buffPacket) {
System.arraycopy(b, 0, imageData, index, b.length);
index += b.length;
}
return imageData;
以下是接收方完整的接收视频方法参考代码。
java
public byte[] receiveVideo() throws Exception {
//读取总长度
byte[] lenBuf = new byte[4];
DatagramPacket receiveLen = new DatagramPacket(lenBuf, 0, lenBuf.length);
clientB.receive(receiveLen);
int len = byteToInt(lenBuf);
System.out.println(len);
//存储还原后的数据
byte[] imageData = new byte[len];
int maxPacket = (len + PACKET_SIZE - 1) / PACKET_SIZE; //计算有多少个数据包要接收
//用二维数组缓存每个数据包,二维的默认值是null
byte[][] buffPacket = new byte[maxPacket][];
boolean[] receiveFlag = new boolean[maxPacket];
int receiveCount = 0;
while (receiveCount < maxPacket) {
//读取一个数据包
byte[] packetBuf = new byte[PACKET_SIZE + 4];
DatagramPacket packet = new DatagramPacket(packetBuf, 0,
packetBuf.length);
clientB.receive(packet);
int readLen = packet.getLength();
System.out.println("packet.getLength() = " + packet.getLength());
//从packetBuf 解析出ACK用于回传
int ack = byteToInt(packetBuf);
System.out.println("ack = " + ack);
//提取从 packetBuf数组的图像数据
byte[] imageBuf = new byte[readLen - 4];
System.arraycopy(packetBuf, 4, imageBuf, 0, imageBuf.length);
//回传 ACK
byte[] ackBuf = new byte[4];
intToBytes(ackBuf, ack);
DatagramPacket ackPacket = new DatagramPacket(ackBuf, 0,
ackBuf.length, packet.getAddress(), packet.getPort());
System.out.println("ip:" + packet.getAddress().getHostName() + "port:"+packet.getPort());
clientB.send(ackPacket);
//把读取到数据保存到缓冲数组中
if (ack >= receiveCount && ack < maxPacket && !receiveFlag[ack]) {
buffPacket[ack] = imageBuf;
receiveFlag[ack] = true;
receiveCount++;
}
}
//拼接缓冲数组数据
int index = 0;
for (byte[] b : buffPacket) {
System.arraycopy(b, 0, imageData, index, b.length);
index += b.length;
}
return imageData;
}