恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Java RTP客户端实践:从协议解析到GB28181对接
首页
资讯中心
/
Java RTP客户端实践:从协议解析到GB28181对接
Java RTP客户端实践:从协议解析到GB28181对接
发布时间:2026/10/1 17:23:45
简介面向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 字节头里藏着流的一切RTPReal-time Transport Protocol运行在 UDP 之上每个数据包由一个固定 12 字节的头部和可变负载组成。不要被「实时传输」四个字吓到它的头结构非常稳定Java 里完全可以手工解析。固定头字段如下字段位数含义解析时的注意点Version2 bit版本号固定为 2收到非 2 的包直接丢弃Padding1 bit是否在负载末尾有填充字节置 1 时负载最后一个字节是填充长度Extension1 bit是否有头扩展带扩展时跳过扩展头再取负载CSRC count4 bitCSRC 标识符个数头长度 12 4 * CCMarker1 bit标记位常用作帧边界H264 中通常表示一帧的最后一个分片Payload Type7 bit负载类型编号96 表示 H26498 表示 PS需和 SDP 对齐Sequence Number16 bit包序号用作排序和丢包检测会回绕Timestamp32 bit时间戳与采样时钟挂钩H264 用 90000 HzSSRC32 bit同步源标识同一媒体流的所有包 SSRC 相同CSRC list0-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 包你只能看到「包来了」但看不到「链路到底多差」。RTCPRTP Control Protocol就是来干这件事的。客户端周期性发送 RRReceiver 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 MapLong, ConsumerRtpPacket sessionHandlers new HashMap(); private volatile boolean running true; public RtpClient(int port) throws Exception { socket new DatagramSocket(port); } public void addSession(long ssrc, ConsumerRtpPacket 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); ConsumerRtpPacket 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这个 Mapkey 是 SSRCvalue 是回调函数。一个 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, Consumerbyte[] 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 里artpmap: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不需要全部解析只读关键几行mvideo 9000 RTP/AVP 96 artpmap:96 H264/90000 afmtp:96 packetization-mode1; sprop-parameter-setsZ0LAHtkAoQN8RqKhA,aMuMsg; arecvonlym行里的 9000 是 RTP 接收端口。artpmap告诉我们 96 号 payload type 对应 H264、时钟 90000。afmtp里的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(mvideo)) { String[] parts line.split( ); info.rtpPort Integer.parseInt(parts[1]); } else if (line.startsWith(artpmap:)) { String[] parts line.substring(artpmap:.length()).split( ); info.payloadType Integer.parseInt(parts[0]); info.codec parts[1].split(/)[0]; } else if (line.startsWith(afmtp:)) { 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 StreamMPEG-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 服务器发 INVITESIP 服务器回 100 Trying然后回 200 OK里面携带 SDP。客户端收到 200 OK 后再发 ACK这时候设备才开始推流。如果你用的是 GB28181 客户端需要确认三件事INVITE 的 SDP 里mvideo行的端口必须是客户端本地监听的那个 UDP 端口。200 OK 返回的 SDP 里asetup和aconnection是否与预期一致。ACK 的 CSeq 要与 INVITE 一致否则 SIPP 会认为事务未结束。下面是一个简化的 INVITE SDP 构造注意端口要和本地监听对齐INVITE sip:34020000001320000001127.0.0.1:5060 SIP/2.0 Via: SIP/2.0/UDP 127.0.0.1:7000 From: sip:34020000002000000001127.0.0.1;tag12345 To: sip:34020000001320000001127.0.0.1 Call-ID: 20250101127.0.0.1 CSeq: 1 INVITE Content-Type: application/SDP Content-Length: 152 v0 o34020000002000000001 0 0 IN IP4 127.0.0.1 sPlay cIN IP4 127.0.0.1 t0 0 mvideo 9000 RTP/AVP 96 artpmap:96 PS/90000 arecvonly y0100000001这里的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 值是不是 96SSRC 是不是和y行换算出来的一致。国标场景里很容易出现媒体流实际 SSRC 与信令不符平台侧的 RTP session 判定为 false直接终止预览。这里我整理了一个排查对照表现象抓包特征处理方式端口收不到任何 UDP 包无 RTP 也无 RTCP检查防火墙、NAT 映射、SDP 端口是否一致有 UDP 包但解析包头失败包长度小于 12可能收的是 RTCP按 SSRC 过滤有 RTP 包但 PT 不是 9696 号 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 排序后再送给解码器。我的做法是维护一个小的TreeMapInteger, byte[]按序列号排序当缓存里出现连续 5 个包时开始输出确保帧内顺序稳定。注意不要等太久否则时延增加实时性就没了。5.2 现象能收到 RTP 包但播放器不出图用DatagramSocket收到的包数量正常日志里payloadLen也有值但播放器一直黑屏。去检查负载格式。抓包文件里如果看到 RTP payload 以0x000001BA开头说明这是 PS 流而播放器配置的是裸 H264 解码。反过来如果你用 PS 解析器去解裸 H264 的流同样没有输出。必须从 SDP 的artpmap确认到底是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 时明确指定网卡 IPDatagramSocket socket new DatagramSocket(null); socket.bind(new InetSocketAddress(192.168.1.20, 9000));还有一次是防火墙只放行了 TCP 5060UDP 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 TreeMapInteger, 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.EntryInteger, 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 或 PLIPicture Loss Indication图片丢失指示请求对端尽快发一个关键帧。要验证你的 RTP 客户端做得好不好我有两个习惯。第一用tcpdump或 Wireshark 抓包把客户端收到的 RTP 序号导出来检查有没有乱序和丢包看 jitter buffer 是否在正常工作。第二用一个本地 UDP 发包工具模拟丢包丢 1%、5%、10% 分别观察画面表现这样你能直观看到自己的掩饰逻辑有没有生效。写这套 javartp 客户端之前我用的也是别人封装的 SDK遇到rtp session false时只能干瞪眼连日志都看不懂。后来我痛下决心把 RTP 头解析、H264 解包、jitter buffer 这三层全部自己写之后再没被黑盒卡过脖子。做流媒体客户端亲手掌握协议细节就是最好的后悔药。希望这份笔记能帮你少踩几个坑快速跑通自己的 Java RTP 客户端。本文还有配套的精品资源点击获取