简介:这是一份面向Java开发者的RTP实时传输协议客户端实现资源包,基于jlibrtp库封装完整的RTP/RTCP通信机制,可帮助理解实时音视频与流媒体数据传输的核心流程。资源共45个文件,以39个Java源码为主,辅以3个HTML说明文档和3个TXT指南,整体仅108KB,结构紧凑,便于快速定位。包内包含RTP会话管理、数据包收发、RTCP报告处理等核心类,并提供SoundReceiverDemo、UnicastExample等可运行示例,以及用于验证协议栈的测试代码。已有327人学习下载。通过阅读README与对照源码,读者可快速掌握在Java环境中创建RTPSession、设置SSRC、绑定本地端口、发送及监听RTP数据包的完整流程,并理解时间戳、序列号与同步机制,适合正在开发音视频通话、直播推流或实时数据交互应用的开发者参考。
1. RTP 客户端是什么:先搞清楚你要解决的是推流、拉流还是双向会话
RTP 客户端在 Java 项目里通常不是个独立系统,而是整个音视频链路里的媒体面组件。做监控平台的人拿到「javartp 客户端」这个检索词,多半是要从 GB28181 设备或国标平台拉视频流;做呼叫中心的人,则是要接 SIP 呼叫里的 RTP 媒体;做分析服务的人,可能只是想把摄像头 RTP 流收下来喂给解码器。三拨人最终都落在同一件事上:用 Java 完成 UDP 上报文的收发、RTP 头解析、序列号和时间戳的维护,以及和 RTCP 的配合。这个方案能解决的是在不引入 WebRTC 全家桶的前提下,让业务系统直接接入标准 RTP 流,适合有一定 Java 网络编程基础、想自己掌控媒体面的开发者。先把你要做的是推流、拉流还是双向会话想清楚,后面所有选型和编码才不会走偏。
2. 选型先于编码:Java 里做 RTP 客户端的三条路,各自代价在哪
2.1 纯 java.net 手写 RTP:协议透明,坑也透明
我在实际项目里很少直接引入重量级多媒体框架,因为业务系统往往只关心流里的那一点数据:监控平台的告警截图、呼叫中心的话路状态、质检系统的音频转写。这个时候,自己用 DatagramSocket 实现 RTP 收发是性价比最高的做法。RTP 的标准在 RFC 3550 里写得很清楚,12 字节的固定头部是所有实现的地基:版本号(2 bit)、填充标志(Padding)、扩展标志(Extension)、CSRC 计数、Marker、负载类型(Payload Type,7 bit)、序列号(16 bit)、时间戳(32 bit)、SSRC(32 bit)。
解析头部的代码不长,但每一行都值得逐字确认:
ByteBuffer buf = ByteBuffer.wrap(packet.getData(), packet.getOffset(), packet.getLength()); byte first = buf.get(); byte second = buf.get(); int version = (first >> 6) & 0x03; int padding = (first >> 5) & 0x01; int extension = (first >> 4) & 0x01; int cc = first & 0x0F; int marker = (second >> 7) & 0x01; int payloadType = second & 0x7F; int seq = buf.getShort() & 0xFFFF; long timestamp = buf.getInt() & 0xFFFFFFFFL; long ssrc = buf.getInt() & 0xFFFFFFFFL;这段逻辑里最容易被忽略的有两处。第一,Java 的 getShort 和 getInt 是有符号的,必须用 & 0xFFFF、& 0xFFFFFFFFL 转成无符号数,否则序列号大于 32767 时你会看到负数,丢包统计直接崩掉。第二,padding 和 extension 位一直存在,payload 的起点不是固定的 12 字节;如果对方在包尾加了填充,而你直接按 12 字节切负载,解码器会收到脏数据。处理方式是先读固定头,再看 padding 位和 cc 值,算出扩展头和填充的长度,最后从正确位置切负载。
纯手写路线能让你完全掌握协议行为,调试时心里很有底。代价是你需要自己处理的事变多了:SIP 或别的信令协议和媒体面怎么衔接、RTCP 怎么发、序列号断了怎么办、网络地址转换场景下端口怎么守。如果只是内部系统之间传流,这条路最稳,也最容易定位问题。
2.2 基于现成 Java RTP 库:Jitsi / jlibrtp / Netty 组合的取舍
如果不想从头啃 RFC,常见的做法是引入现成的 Java RTP 库。老项目里见得最多的是 jlibrtp,它把 RTP 会话、参与者和 RTCP 调度都封装好了,文档虽少但代码直白,适合学习协议的时候对照着读。问题在于它维护得并不勤快,新的 JDK 版本上偶尔会遇到模块化或安全策略相关的兼容问题。另一个方向是 Jitsi 的 libjitsi,里面包含完整的 RTPManager、SRTP、丢包重传和音视频会话管理,能力全,但依赖很重,对一个只想拉 GB28181 视频流的服务来说有些过度。
Netty 不算 RTP 库,但它提供的 DatagramChannel 和 EventLoop 很适合做高吞吐的媒体通道。我会用 Netty 搭收发骨架,再自己写 RTP 编解码 Handler,这样既不用背整个多媒体框架,又能顺畅处理多路并发。选型这件事的答案完全取决于你的场景,我把它整理成一张对比表:
| 方案 | 依赖规模 | 协议完整度 | 维护成本 | 最适合的场景 |
|---|---|---|---|---|
| 纯 DatagramSocket | 零 | 自控 | 高,但可控 | 单路或少量路数、内部拉流 |
| jlibrtp | 小 | 完整但维护少 | 中,需改兼容 | 学习协议、原型验证 |
| libjitsi | 大 | 完整 | 低 | 需要 SRTP、多路会话的独立项目 |
| Netty + 自研 Handler | 中 | 按需 | 中 | 多路聚合、自定义转发 |
做 GB28181 客户端的时候我最后一般会选 Netty 或纯 Socket,原因是国标的媒体面还能切到 TCP 封装,Netty 的传输层抽象能让你在 UDP 和 TCP 之间来回切换,不用重写业务代码。
2.3 RTP 与 RTCP 必须成对出现:SR/RR 报文能告诉你什么
初学者常犯的错误是只实现了 RTP 接收,完全忽略 RTCP。RFC 3550 把 RTP 和 RTCP 设计成必须协同工作的协议:RTP 传媒体数据,RTCP 传服务质量信息,包括 Sender Report(发送方报告,SR)和 Receiver Report(接收方报告,RR)。SR 由发送方周期发出,里面有 NTP 时间戳、RTP 时间戳、累计发送包数和累计发送字节数;RR 由接收方发出,包含丢包率、累计丢包数、最高序列号、到达时间抖动,以及指向最近 SR 的 LSR 和 DLSR。
如果你只做一个拉流客户端,至少要周期回 RTCP RR,否则不少国标平台和流媒体服务会判定这个客户端会话异常,表现就是信令流程走完但媒体很快断,报错信息往往指向 rtp session false。构造 RR 的关键在于复制对方 SR 里的 NTP 时间戳中间 32 位作为 LSR,记录从收到 SR 到此刻经过的时间作为 DLSR,单位是 1/65536 秒:
ByteBuffer rtcp = ByteBuffer.allocate(32); rtcp.put((byte) 0x81); // V=2,P=0,RC=1 rtcp.put((byte) 201); // PT=201 表示 RR,接收方报告 rtcp.putShort((short) 7); // 长度字段:整包 32 字节除以 4,再减 1,等于 7 rtcp.putInt((int) ssrc); // 本端 SSRC rtcp.putInt((int) remoteSsrc); rtcp.put((byte) 0); // fraction lost,这里先填 0 rtcp.put((byte) 0); // cumulative lost 的高 8 位 rtcp.putShort((short) 0); // cumulative lost 的低 16 位 rtcp.putInt(extendedMaxSeq); rtcp.putInt((int) jitter); rtcp.putInt((int) lsr); // 对方 SR 里的 NTP 时间戳中间 32 位 rtcp.putInt((int) dlsr); // 从收到 SR 到此刻的延时,1/65536 秒为单位RR 里最容易填错的是 length 字段和 LSR/DLSR。length 必须以 32 位字为单位再减 1,整个 RR 固定头 8 字节加接收报告块 24 字节,合计 32 字节,所以 length 是 7。LSR 取的是对方 SR 里 NTP 时间戳的中间 32 位,不是全部 64 位;DLSR 是本地系统时间差,换算成 1/65536 秒。这两处错了不会立刻报错,但平台侧算出来的往返时延会乱,丢包率统计也不准。
RTCP 的发送周期按会话带宽比例计算,RFC 3550 建议 RTCP 占用带宽约为会话带宽的 5%,发送间隔有最小限制,客户端里固定按 5 秒一次是安全的选择。五秒钟一个 32 字节的报文,对网络的压力可以忽略不计,却能让对端一直认为这个会话是活的。
3. 从零跑通 javartp 客户端:SDP 通知下的 UDP 会话建立与 RTP 接管
3.1 先解决 SDP:从 INVITE/200 OK 里拿端口、编码格式和 SSRC
一个 RTP 客户端不会凭空知道该往哪个端口收发。在 SIP 和 GB28181 场景里,媒体参数通过 SDP 在信令报文中传递。我见过不少人在这一步翻车:拿着网上找的 RTP 示例,绑死一个端口就开始收,结果发现设备把流发到了 SDP 里约定的另一个端口。SDP 里真正要解析的关键行是 m、c 和以 a= 开头的媒体属性。典型的 GB28181 响应里会有类似下面的片段:
v=0 m=video 20000 RTP/AVP 96 c=IN IP4 192.168.1.64 a=rtpmap:96 PS/90000 a=ssrc:123456789m 行里的 20000 是媒体收流端口,c 行里是对端 IP,a=rtpmap 说明 payload type 96 对应 PS 封装、时钟 90000Hz,a=ssrc 在部分实现里会带上发送端的同步源标识。解析时我习惯用最简单的逐行匹配,正则写复杂了反而容易踩多空格和换行符的坑:
String sdp = "..."; // 来自 200 OK 的 body int mediaPort = 0; String mediaIp = null; for (String line : sdp.split("\\r?\\n")) { if (line.startsWith("m=video")) { String[] parts = line.split(" "); mediaPort = Integer.parseInt(parts[1]); // parts 是 ["m=video","20000","RTP/AVP","96"] payloadType = Integer.parseInt(parts[3]); } else if (line.startsWith("c=IN")) { mediaIp = line.split(" ")[2]; } else if (line.startsWith("a=rtpmap:")) { String[] pair = line.substring(9).split(" "); // pair[0] = "96",pair[1] = "PS/90000" } }这段逻辑有几个边界要留意。m 行可能不是 video 而是 audio,如果你的客户端只处理视频,不能看到 m= 就取。c 行可能出现在会话级或媒体级,媒体级优先,所以后出现的 c 行应该覆盖先出现的。rtpmap 里的时钟频率不总是 90000,音频常见 8000 或 48000,视频 PS 和 H.264 一般是 90000,后面所有时间戳换算都要基于这个值。
拿到这几个参数后,本地 DatagramSocket 的绑定端口在拉流场景下有两种选择:显式指定为信令协商好的端口,或者用 0 让系统分配随机端口再把实际端口写进响应 SDP。这里有个常见的理解偏差:SDP 里 m 行的端口是对方告诉你去哪里收流,不是你本机要监听的端口。把这两者搞混,是 GB28181 联调时最常见的失败原因之一。
3.2 最小可用的 RTP 接收客户端:绑定端口、收包、解析、丢包统计
业务上我倾向于把 RTP 接收做成一个独立的循环线程,跟解码、告警等业务解耦。这样接收线程只负责收包和统计,业务线程从队列里取数据,即使业务方处理慢了,最多队列积压,不会阻塞 UDP 收包导致内核缓冲区溢出。
try (DatagramSocket socket = new DatagramSocket(recvPort)) { socket.setSoTimeout(1000); byte[] buffer = new byte[65535]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); int expectedSeq = -1; long lostCount = 0; long totalCount = 0; while (!Thread.currentThread().isInterrupted()) { socket.receive(packet); RtpHeader header = parseHeader(packet.getData(), packet.getLength()); if (expectedSeq != -1) { int diff = (header.seq - expectedSeq) & 0xFFFF; if (diff > 0 && diff < 10000) { lostCount += (diff - 1); // 中间丢了多少包 } else if (diff < 0) { // 乱序或重传,这里只计数,不立刻当新包处理 } } expectedSeq = (header.seq + 1) & 0xFFFF; totalCount++; payloadQueue.offer(new RtpPacket(header, Arrays.copyOfRange( packet.getData(), header.payloadOffset, packet.getLength()))); } }这里的几个参数值得细说。setSoTimeout(1000) 是为了让 receive 能超时退出,如果不设,线程关闭时只能靠 interrupt 碰运气,很多线上服务就是因为这个退出不干净导致端口一直占着。丢包判断用 & 0xFFFF 把差值转成无符号数,规则是差值大于 0 且小于 10000 才认为是丢包;如果差值超过 10000,更可能是乱序或对端重启后序列号归零,强行按丢包统计会把告警系统刷爆。
buffer 大小 65535 是 UDP 的理论上限,实际 RTP 包一般在 1200 到 1400 字节之间。GB28181 的 PS 封装经常超过网络链路 MTU,发送端会做分片,所以接收端不要期待一个 RTP 包就是一整帧画面。队里存的应该是带 header 的完整封装,而不是只存 payload,因为后面做抖动和去重还要用序列号和时间戳。
3.3 PS 封装和 H.264 裸流:GB28181 场景下的 payload 处理
如果接的是标准 RFC 3550 RTP 里的 H.264,负载通常是单 NAL 单元或 RTP 分片,处理起来相对直观。但 GB28181 设备大多是 PS over RTP:每个 RTP 包里装的是 MPEG-2 Program Stream 的一小段,多个 RTP 包拼起来才还原出一个完整的 PS 包。PS 包以 0x000001BA 起始码开头,后续的 PES 包以 0x000001E0 开头,H.264 数据藏在 PES 负载里。
我第一次接国标流时在这里栽过跟头:直接把每个 RTP payload 当成一帧 H.264 裸流送去解码器,出来的画面全是花屏。正确的做法是先把 payload 按起始码切片,只保留从 0x000001BA 开始的完整 PS 包,再跳过 PS 系统头找到第一个 PES,从 PES 里剥出 ES 数据后按 Annex-B 格式拼接送给解码器。
byte[] payload = rtpPacket.getPayload(); int offset = 0; while (offset < payload.length - 3) { if ((payload[offset] & 0xFF) == 0x00 && (payload[offset + 1] & 0xFF) == 0x00 && (payload[offset + 2] & 0xFF) == 0x01 && (payload[offset + 3] & 0xFF) == 0xBA) { // 找到 PS 包头,记录起始位置 int psStart = offset; // 解析 PS 头长度,跳到第一个 PES 起始码 0x000001E0 // 取出 PES 里的 ES 数据,写入 esBuffer offset += 4 + psHeaderLength; } else { offset++; } }这段代码的关键是 psHeaderLength 的计算。PS 包头里有 program stream map 和 system header 信息,起始码后面的字节里用低 6 位表示后续头长度,因此从起始码到负载数据之间的总长度需要按那个字段解析,而不是固定值。实际项目中每家设备在这个封装上未必严格遵守规范,有的设备还会在关键帧前塞入额外填充字节。调试时我会把 RTP payload 前 64 字节 dump 成 hex,对着起始码检查位置对不对,这一步能过滤掉九成以上的解码黑屏问题。
提示:GB28181 平台经常在关键帧前插入多包填充数据,解析时要用「先找起始码再跳长度」的方式,不能假设 PS 头长度固定为 14 或 16 字节。
4. 避坑指南:5 个让 RTP 客户端「看起来死了」的真实问题
4.1 设备端报 start preview failed,rtp session false:会话从没被激活
现象:信令流程走完,收包线程也在跑,但画面黑屏,设备端日志出现 start preview failed maybe rtp session false or preview links' nun,看起来像是平台认为预览会话根本不存在。
原因:这个报错几乎都指向 RTP 会话没有被正确激活。常见具体原因有三个:一是只收 RTP 不回 RTCP,平台侧在几十秒内判定客户端不可达;二是客户端实际监听端口和应答 SDP 里写的端口不一致;三是 SSRC 对不上,客户端用它自己的 SSRC 过滤包,把设备的包全丢了。
解决:先抓包确认流有没有到本机,有流再看 SSRC 是否与 SDP 或包头部一致,最后补上周期 RTCP RR。按这个顺序排查,大概率十分钟内定位。不要一上来就怀疑解码器,黑屏问题里有一半是 RTP 会话本身没建好。
4.2 拉流几分钟后必断:UDP 通道被中间网元老化掉了
现象:拉流正常三到五分钟必断,程序没有任何异常,重新 INVITE 又能恢复,恢复后又是几分钟断一次,极其规律。
原因:典型的是会话保活缺失。UDP 是无连接的,中间网元包括防火墙和 NAT 表项会在一段时间没有交互后老化掉媒体通道。很多协议栈默认只做 SIP 层保活,但媒体面没有流量时通道照样被回收。
解决:在客户端里启动一个 5 秒定时器,周期发送 RTCP RR 或 RTCP APP 保活包。发 RR 时把收到的媒体包统计结果带上,一发两用,既保活了会话又上报了质量。如果对端支持 RTP over TCP,也可以切换到 TCP 封装,从根本上绕开 UDP 会话老化问题。
4.3 seq 回绕被当成丢包:误告警是怎么刷屏的
现象:丢包率被算成百分之几十,监控页面告警刷屏,但实际画面只是轻微卡顿,甚至完全正常。
原因:序列号回绕是正常现象,65535 后归零;另外 GB28181 设备断线重连后 SSRC 可能变化,序列号也会从 0 重新开始。误告警多半是因为代码里没有区分正常回绕和真丢包,直接拿新序列号减旧序列号,回绕瞬间变成一个巨大的差值。
解决:丢包判断必须用无符号差值(newSeq - lastSeq) & 0xFFFF,而不是直接比较大小。拿到新包时如果序列号明显小于上个包且时间戳发生了合理变化,按新会话重新初始化状态机,而不是叠加到旧统计上。简单说,丢包统计要对会话状态敏感,对跨会话的数据睁一只眼闭一只眼。
4.4 重启即报 Address already in use:socket 没关干净
现象:程序第一次启动正常,停掉再启动就报端口绑定失败,有时候要等几十秒才能重新绑定成功。
原因:RTP 是 UDP,没有 TCP 的 TIME_WAIT 状态,端口占用通常是上一次进程没有完全关闭 socket,或一个进程里多个实例绑了同一端口。另一个隐蔽原因是 DatagramSocket 默认不设置 SO_REUSEADDR,某些系统环境下快速重启会碰到内核里的残留绑定。
解决:创建 socket 前先调用setReuseAddress(true);退出时显式关闭并让接收线程 join,别依赖垃圾回收兜底。如果业务上允许动态端口,就直接用 0 获取临时端口,再把实际端口通过信令带回对端,这是最不会撞车的做法。生产环境多实例部署时,一定要用这种方案。
4.5 时间戳恒为 0 或乱跳:别把对端时间戳当基准
现象:按 RTP 时间戳换算播放时间,得到的值完全不可用,音频视频对不上,画面时而快进时而暂停。
原因:部分中低端设备和自研平台的 RTP 实现不按实际采样时钟填时间戳,更常见的是时间戳初始值随机但增量正确。如果客户端直接拿第一个包的时间戳当基准,后面所有换算都会偏移一个固定值,看起来就是声音画面不同步。
解决:不要信任对端时间戳的绝对值,只信任差值。以第一个有效包到达的系统时间作为参考零点,之后的播放时间按(timestamp - firstTimestamp) / clockRate累加。如果差值也不稳,就退回按包到达间隔做抖动缓冲。这个策略对付杂牌设备特别有效,属于典型的血泪经验,值得在客户端里做成可配置项。
5. 进阶验证与调优:把客户端从「能跑」推到「能上线」
5.1 用抓包数据反推客户端实现是否正确
Wireshark 里过滤 rtp 或 rtcp,看流的方向、payload type 和时间戳增量。如果视频是 90000Hz 时钟、25 帧每秒,帧间时间戳增量应该在 3600 附近;帧内分片包的增量会非常小。如果看到序列号大量乱序,先怀疑接收线程消费太慢,导致系统 UDP 缓冲区溢出。
命令行下建议把包落盘再分析:
tcpdump -i eth0 udp port 20000 -w rtp.pcap对着抓包文件确认三件事:RTCP RR 是否每五秒出现一次;SDP 里的媒体端口与实际收包端口是否一致;对端断开重连后 SSRC 是否变化。这三项都正常,客户端网络层基本可以判定健康。
5.2 抖动缓冲:用到达间隔而不是包序号说话
简单有效的策略是统计最近 N 个包的到达间隔,把超过三倍平均间隔的到达看作一次抖动事件,根据抖动事件的频率动态调整缓冲深度。缓冲不是越大越好,100 毫秒以内通常可接受;超过 200 毫秒交互体验会明显变差。监控场景可以偏大,对讲场景必须偏小,这个参数要在运行时暴露出来。
5.3 上线前的十分钟检查清单
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 端口可达 | 抓包或直接发流验证 | 能稳定收到 RTP 包 |
| RTCP 在发 | 抓包过滤 rtcp | 约每 5 秒一个 RR |
| 重启不占端口 | 连续重启 5 次 | 无 Address already in use |
| 长时间稳定性 | 保持拉流 8 小时 | 丢包率平稳,内存无增长 |
我自己每次上线前都会把抓包文件留一份,标好时间戳和会话 ID。出错时先看包,再看代码,能省掉大量「到底是谁的问题」的拉扯。这个习惯救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取