简介:面向Java开发者的RTP实时通信实践资料包,以jlibrtp开源库为核心,汇集了客户端与服务端的可运行示例,帮助解决Java环境中RTP/RTCP协议集成、音视频数据实时传输等实践难题。压缩包共45个文件,包括39个Java源码、3个HTML说明和3个TXT文档,全部内容仅108KB,轻量精炼,便于逐行研读。已有327人浏览学习,适合正在研究多媒体通信、实时音视频开发,或需要快速搭建RTP传输链路的开发者。资源中的SoundSenderDemo、SoundReceiverDemo、UnicastExample等Demo覆盖了RTP会话创建、SSRC与传输参数配置、RTPListener监听、数据包封装与发送等关键流程;同时,README与readme文档对jlibrtp库的API和报文处理机制做了补充说明,可辅助读者理解时间戳同步、序列号校验、RTCP反馈等协议细节。无论是学习RTP规范还是开展二次开发,这一资源包都能提供直观的参考价值。
1. 自己写 Java RTP 客户端:不是造轮子,是拿回协议主动权
去年我接手一个国标平台对接任务,平台日志里反复出现start priview failed maybe rtp session false or preview links' nun,用现成的 Java 流媒体库怎么调都调不通,最后发现是库对 RTP 的 SSRC 处理和你信令里协商的不一致。那一刻我意识到,做 Java RTP 客户端,真正值钱的部分不是发几个 UDP 包,而是你能直接看懂每一字节的 RTP 头、能亲手解析 H264 或 PS 负载、能定位「包来了但不出图」的中间层问题。这篇笔记就是围绕javartp这个客户端方向,把 RTP/RTCP 协议拆开,给你一套能用 Java 从零跑通的最小方案,包括代码、参数和一堆血泪坑。适合正在对接 GB28181、RTSP 或自建流媒体服务,且不想被黑盒 SDK 卡住的人。
2. 先拆 RTP 协议:Java 里最该拿捏的音视频基石
2.1 RTP 包长什么样:12 字节头里藏着流的一切
RTP(Real-time Transport Protocol)运行在 UDP 之上,每个数据包由一个固定 12 字节的头部和可变负载组成。不要被「实时传输」四个字吓到,它的头结构非常稳定,Java 里完全可以手工解析。
固定头字段如下:
| 字段 | 位数 | 含义 | 解析时的注意点 |
|---|---|---|---|
| Version | 2 bit | 版本号,固定为 2 | 收到非 2 的包直接丢弃 |
| Padding | 1 bit | 是否在负载末尾有填充字节 | 置 1 时负载最后一个字节是填充长度 |
| Extension | 1 bit | 是否有头扩展 | 带扩展时跳过扩展头再取负载 |
| CSRC count | 4 bit | CSRC 标识符个数 | 头长度 = 12 + 4 * CC |
| Marker | 1 bit | 标记位,常用作帧边界 | H264 中通常表示一帧的最后一个分片 |
| Payload Type | 7 bit | 负载类型编号 | 96 表示 H264,98 表示 PS,需和 SDP 对齐 |
| Sequence Number | 16 bit | 包序号 | 用作排序和丢包检测,会回绕 |
| Timestamp | 32 bit | 时间戳 | 与采样时钟挂钩,H264 用 90000 Hz |
| SSRC | 32 bit | 同步源标识 | 同一媒体流的所有包 SSRC 相同 |
| CSRC list | 0-60 byte | 贡献源列表 | 多数场景为空 |
我在写 javartp 客户端时,把 RTP 头解析写成了一段非常朴素的代码,因为越朴素越不容易被各种边缘情况带偏:
public class RtpHeader { public int version; public boolean padding; public boolean extension; public int csrcCount; public boolean marker; public int payloadType; public int sequence; public long timestamp; public long ssrc; public int headerLength; public static RtpHeader parse(byte[] data, int offset) { RtpHeader h = new RtpHeader(); int b0 = data[offset] & 0xFF; h.version = (b0 >> 6) & 0x03; h.padding = ((b0 >> 5) & 0x01) == 1; h.extension = ((b0 >> 4) & 0x01) == 1; h.csrcCount = b0 & 0x0F; int b1 = data[offset + 1] & 0xFF; h.marker = ((b1 >> 7) & 0x01) == 1; h.payloadType = b1 & 0x7F; h.sequence = ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF); h.timestamp = ((long) (data[offset + 4] & 0xFF) << 24) | ((long) (data[offset + 5] & 0xFF) << 16) | ((long) (data[offset + 6] & 0xFF) << 8) | ((long) (data[offset + 7] & 0xFF)); h.ssrc = ((long) (data[offset + 8] & 0xFF) << 24) | ((long) (data[offset + 9] & 0xFF) << 16) | ((long) (data[offset + 10] & 0xFF) << 8) | ((long) (data[offset + 11] & 0xFF)); h.headerLength = 12 + h.csrcCount * 4; if (h.extension) { int extLen = ((data[offset + h.headerLength + 2] & 0xFF) << 8) | (data[offset + h.headerLength + 3] & 0xFF); h.headerLength += 4 + extLen * 4; } return h; } }sequence用 16 位无符号表示,Java 的short会带符号,所以必须用& 0xFF拼成int。时间戳是 32 位无符号,我直接存成long,后面处理回绕时方便。CSRC在实际的单播收流里几乎用不到,但解析时还是要跳过去,很多客户端只算了 12 字节就去读负载,遇到带 CSRC 或扩展头的包就全错位了。
2.2 为什么走 UDP + RTP 而不是 TCP:时延、丢包容忍与重传策略
做 Java RTP 客户端,第一道选择题是传输层。RTP 默认跑在 UDP 上,端口用偶数的媒体端口,RTCP 用相邻奇数端口。这个设计不是拍脑袋,而是音视频流对时延极度敏感,TCP 的重传机制在丢包时会把后续所有包都堵住,导致整个画面冻住等重传,时延反而更高。
RTP 对丢包的容忍策略是完全不同的路子:丢了就丢了,解码器靠前一帧的关键帧或当前帧的错误掩盖来顶过去。真正需要可靠传输的场景,比如 RTSP 的 TCP 模式,是在 TCP 连接里用$符号做 interleaved 标记,把 RTP 包嵌进 TCP 流,但那不是 RTP 的默认形态,而是 RTSP 的妥协方案。
我在写客户端时默认只处理 UDP 收流,逻辑很简单:
import java.net.DatagramSocket; import java.net.DatagramPacket; public class UdpRtpReceiver { private DatagramSocket socket; private byte[] buffer = new byte[65535]; public void start(int port) throws Exception { socket = new DatagramSocket(port); System.out.println("RTP receiver listening on udp/" + port); while (true) { DatagramPacket packet = new DatagramPacket(buffer, buffer.length); socket.receive(packet); int length = packet.getLength(); RtpHeader header = RtpHeader.parse(buffer, 0); // 这里已经拿到了 RTP 头,后续就可以把负载交给解码器 System.out.printf("seq=%d pt=%d ssrc=%d ts=%d payloadLen=%d%n", header.sequence, header.payloadType, header.ssrc, header.timestamp, length - header.headerLength); } } }DatagramSocket默认收包缓冲区是 64KB,我直接分配 65535 字节数组,避免receive时BufferUnderflow。UDP 包最大也就 65507 字节,这个缓冲足够。
选 UDP 的另一个原因是组播和广播。GB28181 的媒体流经常是设备往平台或客户端方向单播推流,也有一些级联场景走组播,UDP 天然支持组播,TCP 在这些场景下要复杂得多。
2.3 RTCP 是一面镜子:SR/RR 包能告诉你链路坏在哪
只看 RTP 包,你只能看到「包来了」,但看不到「链路到底多差」。RTCP(RTP Control Protocol)就是来干这件事的。客户端周期性发送 RR(Receiver Report)给对端,报告丢包率、累计丢包数、抖动、收到 SR 的时间差,对端根据这些可以估算往返时延。
RR 包格式大致如下:
public class RtcpReceiverReport { public static void parse(byte[] data, int offset, int length) { int b0 = data[offset] & 0xFF; int packetType = data[offset + 1] & 0xFF; int blockCount = b0 & 0x1F; if (packetType == 200) { System.out.println("收到 SR,来自发送者,可计算往返时延"); } else if (packetType == 201) { System.out.println("收到 RR,来自接收者"); } else if (packetType == 203) { System.out.println("收到 BYE,对端主动结束会话"); } if (packetType != 201) return; // 每个 reception report block 固定 24 字节 int offsetPos = offset + 8; for (int i = 0; i < blockCount; i++) { long ssrc = ((long) (data[offsetPos] & 0xFF) << 24) | ((long) (data[offsetPos + 1] & 0xFF) << 16) | ((long) (data[offsetPos + 2] & 0xFF) << 8) | ((long) (data[offsetPos + 3] & 0xFF)); int fractionLost = data[offsetPos + 4] & 0xFF; int cumulativeLost = ((data[offsetPos + 5] & 0xFF) << 16) | ((data[offsetPos + 6] & 0xFF) << 8) | (data[offsetPos + 7] & 0xFF); long jitter = ((long) (data[offsetPos + 12] & 0xFF) << 24); System.out.printf("RTCP: ssrc=%d, fractionLost=%d/256, cumulativeLost=%d, jitter=%d%n", ssrc, fractionLost, cumulativeLost, jitter); offsetPos += 24; } } }丢包率如果超过 5%,H264 的画面大概率已经开始花屏或卡顿。抖动值用时间戳单位表示,要和采样时钟挂钩解读,对于 90000 Hz 的时钟,抖动 3600 就相当于 40 毫秒的波动。做客户端时,我把 RTCP 解析和 RTP 解析放在同一个接收循环里,通过端口区分,这样可以实时打印链路质量日志,对接国标平台或自建流媒体时排障效率翻倍。
3. 用 Java 写一个能跑通的最小 RTP 客户端:接收、解析、回调
3.1 从 UDP Socket 到 RTP 包解析:第一个可运行的接收循环
很多 Java 开发者写 RTP 客户端时第一个错误是把「收到 UDP 包」当成「收到 RTP 包」。UDP 只是搬运工,RTP 解析才是正事。一个完整的接收循环需要处理三件事:收包、解包头、按 SSRC 分流。
下面这个是 javartp 客户端的主循环骨架,我加了 SSRC 过滤逻辑,这是对接国标平台时最容易翻车的地方:
import java.net.DatagramSocket; import java.net.DatagramPacket; import java.util.HashMap; import java.util.Map; import java.util.function.Consumer; public class RtpClient { private DatagramSocket socket; private Map<Long, Consumer<RtpPacket>> sessionHandlers = new HashMap<>(); private volatile boolean running = true; public RtpClient(int port) throws Exception { socket = new DatagramSocket(port); } public void addSession(long ssrc, Consumer<RtpPacket> handler) { sessionHandlers.put(ssrc, handler); } public void run() { byte[] buffer = new byte[65535]; while (running) { try { DatagramPacket packet = new DatagramPacket(buffer, buffer.length); socket.receive(packet); RtpHeader header = RtpHeader.parse(buffer, 0); Consumer<RtpPacket> handler = sessionHandlers.get(header.ssrc); if (handler != null && header.version == 2) { byte[] payload = new byte[packet.getLength() - header.headerLength]; System.arraycopy(buffer, header.headerLength, payload, 0, payload.length); handler.accept(new RtpPacket(header, payload)); } } catch (Exception e) { if (running) e.printStackTrace(); } } } public void stop() { running = false; socket.close(); } }这段代码的核心在于sessionHandlers这个 Map,key 是 SSRC,value 是回调函数。一个 UDP 端口可能收到多个会话的流,比如 GB28181 的某个设备同时推了主码流和子码流,如果你不按 SSRC 过滤,直接把所有包交给解码器,画面一定是花的。主循环里header.version == 2的判断也很关键,有些设备会往媒体端口发 RTCP 包,RTCP 的首字节版本同样是 2,但后续解析会有差异,这里先通过 PT 和包长做粗过滤。
3.2 处理 H264 payload:从 RTP 负载还原 NALU 的关键几步
H264 在 RTP 里的封装有三种形态:单 NALU 模式、STAP-A 聚合模式、FU-A 分片模式。其中 FU-A 是网络上最常见的,因为单个 NALU 往往超过以太网 MTU 1500 字节,必须分片传输。
解析 H264 负载,第一步是读第一个字节,判断 NAL 头,然后按 NAL type 分流:
public class H264RtpParser { // 从一个 RTP payload 中还原出完整的 NALU(可能跨多个 RTP 包) public static void parse(byte[] payload, int payloadOffset, int payloadLen, Consumer<byte[]> nalConsumer) { int nalHeader = payload[payloadOffset] & 0xFF; int nalType = nalHeader & 0x1F; if (nalType == 28) { // FU-A 分片 int fuHeader = payload[payloadOffset + 1] & 0xFF; int fuStart = (fuHeader >> 7) & 0x01; int fuEnd = (fuHeader >> 6) & 0x01; int fuType = fuHeader & 0x1F; byte[] nalUnitHeader = new byte[] { (byte) ((nalHeader & 0x60) | (fuType & 0x1F)) }; int dataOffset = payloadOffset + 2; int dataLen = payloadLen - 2; if (fuStart == 1) { // 开始分片,触发一次新的 NALU 收集 byte[] nalu = new byte[dataLen + 1]; System.arraycopy(nalUnitHeader, 0, nalu, 0, 1); System.arraycopy(payload, dataOffset, nalu, 1, dataLen); // 交给上层缓存 } else { // 中间分片或结束分片,把负载追加到当前缓存 } } else if (nalType == 24) { // STAP-A 聚合 int pos = payloadOffset + 1; while (pos < payloadOffset + payloadLen) { int naluLen = ((payload[pos] & 0xFF) << 8) | (payload[pos + 1] & 0xFF); pos += 2; byte[] nalu = new byte[naluLen]; System.arraycopy(payload, pos, nalu, 0, naluLen); nalConsumer.accept(nalu); pos += naluLen; } } else { // 单 NALU,直接输出 byte[] nalu = new byte[payloadLen]; System.arraycopy(payload, payloadOffset, nalu, 0, payloadLen); nalConsumer.accept(nalu); } } }FU-A 最容易被忽略的是 NALU header 的重组逻辑。分片后,真实的 NAL header 是第一个字节的高 5 位(nalHeader & 0x60)保留,低 5 位用 FU header 里的 type 替换。很多新手的错误是直接拿nalHeader当 NAL header 去拼,画面上就是「美好的一天从花屏开始」。STAP-A 的解包也是同理,注意它是网络字节序长度。
3.3 时间戳与 SSRC 管理:让流稳定播放的两个幕后参数
H264 over RTP 的时间戳时钟是 90000 Hz,也就是说每秒钟时间戳增加 90000。25 帧每秒的视频,每帧时间戳增量是90000 / 25 = 3600。这个数字不是乱猜的,SDP 里a=rtpmap:96 H264/90000末尾那串数字就是时钟速率。
播放器靠时间戳来决定每帧显示的时间。如果你不管时间戳,直接把 NALU 往解码器塞,解码器会按 CPB 的假设时间跑,但上层音视频同步就会出大问题,尤其是音频视频混流时对不上嘴型。SSRC 的作用则是区分不同的流。同一个 RTP 端口上,不同的 SSRC 意味着完全不同的流,必须分开处理。
我在写客户端时会用一个RtpTimestampUtil来做时间戳换算:
public class RtpTimestampUtil { public static final long CLOCK_RATE = 90000L; // 转成毫秒时间戳,用于上层音视频同步 public static long toMillis(long rtpTimestamp) { return rtpTimestamp * 1000 / CLOCK_RATE; } // 计算两帧之间的真实间隔,注意处理 32 位回绕 public static long delta(long newer, long older) { long diff = newer - older; if (diff < 0) diff += 0x100000000L; // 32 位回绕修正 return diff; } }0x100000000L是2^32,因为 RTP 时间戳是 32 位无符号数,在长时间直播后必然回绕。用long做加减再补回绕值,比Math.abs可靠得多。如果直接取绝对值,回绕瞬间会出现一个近 13 小时的假间隔,播放器会直接卡住。
3.4 最少必要参数清单:SDP 里真正要读的几行
做 RTP 客户端不等于完全不知道流参数,至少要知道 payload type、时钟速率、封装格式。这些信息都在 SDP 里。收到对端发来的 SDP,不需要全部解析,只读关键几行:
m=video 9000 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1; sprop-parameter-sets=Z0LAHtkAoQN8RqKhA=,aMuMsg==; a=recvonlym=行里的 9000 是 RTP 接收端口。a=rtpmap告诉我们 96 号 payload type 对应 H264、时钟 90000。a=fmtp里的sprop-parameter-sets是 SPS/PPS 的 base64 编码,解码器初始化必须先收到这两个参数集,如果客户端不去拿 SDP 里的 SPS/PPS,很多播放器收到的第一个 IDR 帧却解不出图,就是因为缺了参数集。
Java 解析 SDP 我推荐直接按行字符串处理,不需要引入复杂的 SDP 库:
public class SdpParser { public static class MediaInfo { public int rtpPort; public int payloadType; public String codec; public String sprop; } public static MediaInfo parse(String sdp) { MediaInfo info = new MediaInfo(); for (String line : sdp.split("\\r?\\n")) { if (line.startsWith("m=video")) { String[] parts = line.split(" "); info.rtpPort = Integer.parseInt(parts[1]); } else if (line.startsWith("a=rtpmap:")) { String[] parts = line.substring("a=rtpmap:".length()).split(" "); info.payloadType = Integer.parseInt(parts[0]); info.codec = parts[1].split("/")[0]; } else if (line.startsWith("a=fmtp:")) { int idx = line.indexOf("sprop-parameter-sets="); if (idx > 0) { info.sprop = line.substring(idx + "sprop-parameter-sets=".length()); } } } return info; } }注意m=行的端口和实际收流的端口可能不一样,尤其是在 NAT 环境下。SDP 里的端口是会话协商的端口,实际 UDP 包可能从另一个端口发来,所以客户端 Socket 绑定哪个端口,要以信令协商结果为准,不能想当然。
4. 对接 GB28181 与国标平台:RTP 客户端最常见的实战现场
4.1 PS 封装还是裸 H264:国标 RTP 负载的两种形态
GB28181 客户端(也就是国标接入里的媒体接收端)最常遇到的第一个选择题是:来的 RTP 负载到底是 PS 流还是裸 H264。按国标规范,设备推流默认承载 PS 流(Program Stream,MPEG-2 PS 封装),而不是裸 H264。PS 流里包含了 PS 头、系统头、节目流映射(PSM)、PES 包,H264 的 NALU 被包在 PES 里。
PS 流的解析比裸 H264 多一层,你得先把 PS 头剥掉,找到 PES,再在 PES payload 里找 NALU。PS 头的特征是以0x000001BA开头,PES 以0x000001E0开头。很多从 RTSP 转过来的开发者习惯了裸 H264,直接把 RTP 负载喂给解码器,结果解码器报no frame,就是没意识到这是 PS 封装。
解析 PS 流的思路如下:
public class PsParser { private ByteArrayOutputStream psBuffer = new ByteArrayOutputStream(); public void push(byte[] rtpPayload) { psBuffer.write(rtpPayload, 0, rtpPayload.length); byte[] buf = psBuffer.toByteArray(); // 查找 0x000001E0 开头的 PES 头 int pos = findPattern(buf, 0, buf.length, new byte[]{0x00, 0x00, 0x01, (byte)0xE0}); if (pos < 0) return; // PES 头还没凑齐 // 从 PES 头偏移 9 字节处读 PES_packet_length,跳过 PES 头后取出负载 // 然后从负载中提取 NALU,交给 H264 解码器 psBuffer.reset(); psBuffer.write(buf, pos, buf.length - pos); } private int findPattern(byte[] data, int start, int end, byte[] pattern) { // 实现一个朴素的 KMP 或在 byte 数组里逐段匹配 for (int i = start; i <= end - pattern.length; i++) { boolean match = true; for (int j = 0; j < pattern.length; j++) { if (data[i + j] != pattern[j]) { match = false; break; } } if (match) return i; } return -1; } }这个ByteArrayOutputStream的做法不够高效,但胜在简单直观。PS 流是字节流,不是按 RTP 包对齐的,所以你必须有跨包缓冲。它的边界由 PS 头、系统头、PSM 这些起始码来确定,不能简单按 RTP 包切分。
4.2 INVITE 与 SDP 协商:预览失败的起点往往在信令而非 RTP
先看个最常见的报错:start priview failed maybe rtp session false or preview links' nun。这个提示来自国标平台侧,字面意思是预览启动失败,可能原因是 RTP session 异常或预览链接不可用。很多人的第一反应是去抓媒体包,但实际上,这类报错有相当高的概率出在信令阶段,而不是 RTP 收流阶段。
GB28181 的全流程是:客户端向 SIP 服务器发 INVITE,SIP 服务器回 100 Trying,然后回 200 OK,里面携带 SDP。客户端收到 200 OK 后,再发 ACK,这时候设备才开始推流。如果你用的是 GB28181 客户端,需要确认三件事:
- INVITE 的 SDP 里
m=video行的端口,必须是客户端本地监听的那个 UDP 端口。 - 200 OK 返回的 SDP 里,
a=setup和a=connection是否与预期一致。 - ACK 的 CSeq 要与 INVITE 一致,否则 SIPP 会认为事务未结束。
下面是一个简化的 INVITE SDP 构造,注意端口要和本地监听对齐:
INVITE sip:34020000001320000001@127.0.0.1:5060 SIP/2.0 Via: SIP/2.0/UDP 127.0.0.1:7000 From: <sip:34020000002000000001@127.0.0.1>;tag=12345 To: <sip:34020000001320000001@127.0.0.1> Call-ID: 20250101@127.0.0.1 CSeq: 1 INVITE Content-Type: application/SDP Content-Length: 152 v=0 o=34020000002000000001 0 0 IN IP4 127.0.0.1 s=Play c=IN IP4 127.0.0.1 t=0 0 m=video 9000 RTP/AVP 96 a=rtpmap:96 PS/90000 a=recvonly y=0100000001这里的y=行是国标特有的,用十进制表示媒体流类型和发送端 SSRC。我看到不少新手在这一行写错,导致平台下发的 RTP 流 SSRC 和你期望的不匹配,客户端按 SSRC 过滤时把包全丢了。
4.3 start priview failed 的排查路径:RTP session 与端口收敛
当平台真的报start priview failed时,我的排查顺序是固定的:先看信令事务,再看端口可达性,最后才抓 RTP 包。顺序反了会浪费时间。
第一步,确认 INVITE/200 OK/ACK 三件事务完成。用 Wireshark 抓 SIP 报文,看 ACK 是否发出,200 OK 是否被平台收到。
第二步,确认 UDP 9000 端口能收到包。在服务器上执行:
tcpdump -i eth0 -n udp port 9000 -c 100如果这里没有包,那说明平台或设备压根没往这个端口推,问题在信令协商,换个端口重试或检查 NAT 映射。
第三步,如果端口有包但平台还是报错,把抓到的包用 Wireshark 打开,看 RTP 头里的 PT 值是不是 96,SSRC 是不是和y=行换算出来的一致。国标场景里很容易出现媒体流实际 SSRC 与信令不符,平台侧的 RTP session 判定为 false,直接终止预览。
这里我整理了一个排查对照表:
| 现象 | 抓包特征 | 处理方式 |
|---|---|---|
| 端口收不到任何 UDP 包 | 无 RTP 也无 RTCP | 检查防火墙、NAT 映射、SDP 端口是否一致 |
| 有 UDP 包但解析包头失败 | 包长度小于 12 | 可能收的是 RTCP,按 SSRC 过滤 |
| 有 RTP 包但 PT 不是 96 | 96 号 PT 无包,其他 PT 有包 | 用 SDP 实际值覆盖默认配置 |
| RTP 包正常但平台仍报错 | SSRC 与y=不一致 | 修改 INVITE 的y=或客户端不做 SSRC 过滤 |
| 偶发报错且重启后恢复 | 信令事务缺失 ACK | 确保 ACK 的 CSeq 与 INVITE 一致 |
start priview failed maybe rtp session false or preview links' nun这句话里的rtp session false实质上就是媒体会话没建立成功,只要上面五个位置检查完,大概率能定位到具体环节。
5. 避坑:RTP 客户端最常踩的 5 个坑与排查方法
5.1 现象:画面花屏 2 秒后恢复
做 javartp 客户端测试时,画面经常先花屏两秒,然后恢复正常。抓包看,RTP 序列号是连续的,但时间戳跳变不规则,首帧到达间隔忽大忽小。
原因是网络抖动导致 RTP 包不是按发送顺序到达的。UDP 不做排序,先到的包如果后写入解码器,一帧内的 NALU 顺序就乱了。解决方式是在解码器前加一个排序缓冲,按 sequence 排序后再送给解码器。
我的做法是维护一个小的TreeMap<Integer, byte[]>,按序列号排序,当缓存里出现连续 5 个包时开始输出,确保帧内顺序稳定。注意不要等太久,否则时延增加,实时性就没了。
5.2 现象:能收到 RTP 包但播放器不出图
用DatagramSocket收到的包数量正常,日志里payloadLen也有值,但播放器一直黑屏。
去检查负载格式。抓包文件里如果看到 RTP payload 以0x000001BA开头,说明这是 PS 流,而播放器配置的是裸 H264 解码。反过来,如果你用 PS 解析器去解裸 H264 的流,同样没有输出。必须从 SDP 的a=rtpmap确认到底是PS/90000还是H264/90000,两种解析路径完全不同。
另外一个隐蔽原因:SPS/PPS 没有送到解码器。裸 H264 模式下,SPS/PPS 一般通过 SDP 的sprop-parameter-sets带过来,客户端要主动解析并喂给解码器,不能等 RTP 包里带。
5.3 现象:GB28181 平台提示 start priview failed
平台日志报start priview failed maybe rtp session false or preview links' nun,但服务器上tcpdump能看到媒体端口有 UDP 包。
我们遇到过一次,原因是服务器有两个网卡,平台的媒体流从内网网卡进来,但客户端 Socket 绑定到了外网网卡的 IP 上的端口。UDP 包虽然发到了服务器,但端口被其他进程占用,Java 的DatagramSocket绑定失败抛了异常,媒体会话没建立,平台判定 RTP session false。
解决:绑定 Socket 时明确指定网卡 IP:
DatagramSocket socket = new DatagramSocket(null); socket.bind(new InetSocketAddress("192.168.1.20", 9000));还有一次是防火墙只放行了 TCP 5060,UDP 9000 没放行,数据包被静默丢弃。排查时先看ss -lunp确认端口监听状态,再试从另一台机器nc -u发包测试。
5.4 现象:长时间运行后时间戳回绕导致卡顿
连续跑 8 小时后,画面突然卡死,日志里时间戳变成了负值或巨大的正数。这是因为 RTP 时间戳是 32 位无符号,90000 Hz 时钟下大约 13 小时回绕一次。
解决方式是所有时间戳运算都基于long,并用差值而非绝对值判断顺序。就是我上面写的delta()方法。再用播放调度时,如果计算出的帧间隔超过 1 秒,视为异常,用上一帧的时间补正,不做跳变处理。
这里也提醒一点,序列号 (sequence) 也是 16 位回绕,判断丢包的时候不要用seq > lastSeq这种简单比较,而是用相对差值并回绕修正。
5.5 现象:同一端口收到多个会话的 RTP 包
GB28181 平台级联时,一个媒体端口可能同时接收主码流和子码流,或者不同设备的流混到一个端口。我们不按 SSRC 过滤时,画面出现「双影」或「马赛克拼贴」。
原因是每路的 SSRC 不同,但 PT 可能都是 96。解决是按 SSRC 建立独立的RtpSession实例,每个会话有独立的序列号缓存、时间戳基准和解码器实例。RtpClient里我用的sessionHandlersMap 就是干这个的。如果信令里不知道 SSRC,可以先不校验,把包头解析出的 SSRC 作为 key 自动注册,后续按规定 SSRC 接收。
6. 进阶:用 jitter buffer 和丢包掩藏换稳定播放
做 RTP 客户端到了能出图的阶段,接下来的问题就是怎么把画面稳定下来。我认为最值得投入的改进是 jitter buffer,也就是抖动缓冲。它的意义不在提升链路质量,而在吸收网络抖动,给解码器一个稳定的数据节拍。
一个最基础的 jitter buffer 由三部分组成:按序列号排序的缓存队列、一个可调的等待时间、一个输出调度器。我一般这样设计:
public class JitterBuffer { private TreeMap<Integer, RtpPacket> queue = new TreeMap<>(); private int waitMs = 80; // 首包等待时间,可调 private int maxPackets = 200; private long lastOutputTime = System.currentTimeMillis(); public void push(RtpPacket packet) { queue.put(packet.header.sequence, packet); if (queue.size() > maxPackets) { // 缓存溢出,丢弃最老的包 queue.pollFirstEntry(); } } public RtpPacket poll() { long now = System.currentTimeMillis(); if (now - lastOutputTime < waitMs && queue.size() < 10) { return null; // 还没攒够,继续等 } Map.Entry<Integer, RtpPacket> entry = queue.pollFirstEntry(); if (entry == null) return null; lastOutputTime = now; return entry.getValue(); } }这里的waitMs是关键参数。设小了,缓冲形同虚设;设大了,端到端时延增加,国标平台预览时用户会明显感到画面延迟。我通常在局域网内设为 80 到 120 毫秒,跨公网时调到 150 到 200 毫秒。如果链路本身很稳定,甚至可以降到 40 毫秒。
丢包掩藏是另一个进阶方向。H264 没有前向纠错的情况下,丢包后只能等下一个 IDR 帧恢复画面。如果设备的 GOP 很长,比如 50 帧一个 IDR,用户会觉得画面卡了好几秒。常见做法是检测到丢包后主动发 RTCP NACK 或 PLI(Picture Loss Indication,图片丢失指示),请求对端尽快发一个关键帧。
要验证你的 RTP 客户端做得好不好,我有两个习惯。第一,用tcpdump或 Wireshark 抓包,把客户端收到的 RTP 序号导出来,检查有没有乱序和丢包,看 jitter buffer 是否在正常工作。第二,用一个本地 UDP 发包工具模拟丢包,丢 1%、5%、10% 分别观察画面表现,这样你能直观看到自己的掩饰逻辑有没有生效。
写这套 javartp 客户端之前,我用的也是别人封装的 SDK,遇到rtp session false时只能干瞪眼,连日志都看不懂。后来我痛下决心,把 RTP 头解析、H264 解包、jitter buffer 这三层全部自己写,之后再没被黑盒卡过脖子。做流媒体客户端,亲手掌握协议细节就是最好的后悔药。希望这份笔记能帮你少踩几个坑,快速跑通自己的 Java RTP 客户端。
本文还有配套的精品资源,点击获取