上期文章中,我们利用 Socket 在客户端之间连续发送图片,初步达到了视频通信的效果。然而,这种传输方式连接开销大、实时性差,带来的画面延迟与卡顿很难满足用户的现实需求。因此,本期文章我们会压缩图片并利用 UDP 协议传输图片数据,实现低延迟的流畅视频通信。
1.DatagramSocket
不同于 TCP 协议的 Soket ,这里我们要使用的 UDP 套接字是 DatagramSocket ,它采用无连接设计,客户端创建时无需手动指定端口,仅负责收发 DatagramPacket 数据包。由于省去了连接建立和断开的流程,系统开销显著降低,特别适合视频传输等对实时性要求高的应用场景。
此处有必要介绍一下 DatagramPacket 数据包,它是 UDP 的数据载体,必须包含 "数据字节数组 + 目标 IP + 目标端口",我们可以在后文的示例代码中看到它的具体使用方法。
我们之前使用的 TCP 是基于字节流的,在通过 Soket 建立连接后,会获取字节流进行读写。但是 UDP 没有"流"的概念,仅支持 "数据包 + 字节数组" 传输,DatagramPacket 这个类的构造方法必须接收字节数组作为数据参数。
2.压缩图片
上文提到 UDP 仅支持 "数据包 + 字节数组" 传输,单个数据包的大小是有限的,同时考虑到提升传输效率,我们需要将图片压缩并转为字节数组,这会用到 ByteArrayOutputStream 。
ByteArrayOutputStream Java IO 体系中内存型字节输出流 ,可以把数据写入**内存中的字节数组。**它的特点是无磁盘 IO 开销、自动扩容。它的toByteArray() 方法会把流中存储的所有字节一次性提取出来,返回完整的 byte[] 数组,这便是我们实现图片转字节数组的关键。
对于压缩,我们会用到ImageIO ,一个 JDK 内置的图片 IO 工具类。它的 write() 方法可以实现图片格式转换与压缩。通过指定输出图片格式,它可以把对于图片数据写入内存流。
java
// 1. 创建内存输出流(内存临时容器)
ByteArrayOutputStream bao = new ByteArrayOutputStream();
// 2. 将BufferedImage压缩为JPG格式,写入内存流
ImageIO.write(bufferedImage,"JPG",bao);
//3.将图片数据转为字节数组
byte[] imageData = bao.toByteArray();
我们的图片格式采用 JPG,因为JPG 是有损压缩格式,会舍弃图片中视觉不敏感的像素细节,以大幅减小数据体积。而 PNG 则是无损压缩,体积大,不适合 UDP 视频传输
在这三行代码中,我们首先创建 bao,相当于在内存中开了一个 "临时缓冲区",用来装压缩后的 JPG 数据;再利用 ImageIO 内部调用的 JPG 压缩算法,把 bufferedImage 的原始像素数据转换为 JPG 编码的二进制数据,并写入 bao;最后 toByteArray() 把缓冲区里的所有 JPG 二进制数据打包成一个连续的 byte[],即 UDP 能传输的唯一格式。
3.视频发送
之前使用 TCP 协议时,我们先发送图片的宽高再发送像素点的 RGB 值,接收方即可接收并正确转换图片数据。TCP 基于字节流传输,数据是连续的,然而 UDP 是离散的数据包传输,服务端无法判断一个图片的数据包何时结束。因此,我们必须先传长度,再传数据,让接收方先获取边界信息并创建对应大小的缓冲数组。
java
//获取数组长度
int dataLen = imageData.length;
System.out.println(dataLen);
String str = String.valueOf(dataLen);
byte[] len = str.getBytes();
//打包成数据包,先发送数组长度
DatagramPacket lenPacket = new DatagramPacket(len,0,len.length,
address,port);
clientA.send(lenPacket);
//再发送数组数据
DatagramPacket imagePacket = new DatagramPacket(imageData,0,dataLen,
address,port);
clientA.send(imagePacket);
我们利用 String 类的.getBytes() 方法可将字符串转为字符数组。
DatagramPacket 构造方法参数含义:(字节数组, 起始偏移量, 传输长度, 目标IP, 目标端口),这里 offset=0、length=len.length 表示传输整个长度字节数组。
4.视频接收
完成发送端代码后,接收端代码就比较容易理解了。
首先创建 UDP 接收端,绑定指定端口(8888),与发送端的目标端口一致。用 DatagramSocket 的阻塞方法.receive() 接收图片长度,创建对应大小的缓存数组。再接收图片的数据包,用内存型字节输入流 ByteArrayInputStream 将字节数组转为输入流,利用 ImageIO 的.read() 方法读取输入流中的 JPG 格式数据,还原为 BufferedImage 对象,最后再把图片绘制出来。
java
public void readImage(Graphics g) {
new Thread(new Runnable() {
@Override
public void run() {
//创建客户端
DatagramSocket clientB = null;
try {
clientB = new DatagramSocket(8888);
} catch (Exception e) {
throw new RuntimeException(e);
}
while (true) {
try {
//读取image 长度
byte[] bLen = new byte[8];
DatagramPacket receiveLen = new DatagramPacket(bLen, 0, bLen.length);
clientB.receive(receiveLen);
//把 byte 数组转成int
String str = new String(bLen);
int len = Integer.parseInt(str.trim());
System.out.println("len = " + len);
//定义缓冲数组大小
byte[] imageData = new byte[len];
DatagramPacket receiveImage = new DatagramPacket(imageData, 0, imageData.length);
clientB.receive(receiveImage);
//把字节数组转成图片绘制出来
ByteArrayInputStream bis = new ByteArrayInputStream(imageData);
//读取输入流中的数据
BufferedImage bufferedImage = ImageIO.read(bis);
g.drawImage(bufferedImage, 0, 0, null);
Thread.sleep(70);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}).start();
}