☰
Java实现P2P视频通话:NAT穿透与WebSocket信令完整解析
2026/10/16 9:25:19 网站建设 项目流程

简介:这是一款基于Java开发的P2P视频通话应用VChat,面向Android学习者与即时通讯开发者,可帮助理解局域网内设备发现、呼叫接听及音视频传输流程。压缩包共69个文件,约19.22MB,包含可直接安装的apk、Java源码、Android布局xml、Gradle构建配置、so原生库与jar依赖等,结构完整且分层清楚,方便导入Android Studio后直接运行。项目通过登录用户名实现用户列表展示,并支持两台设备间点击拨号、接听通话,适合作为移动端P2P通信课程设计或毕业设计的参考原型。目前已吸引266人学习下载,对于想快速跑通视频通话Demo、研究WebRTC或Socket信令交互的开发者来说,具有不错的动手实践价值。

1. P2P视频通话项目VChat:Java写的点对点连接完整实现

VChat不是一个普通的视频会议Demo,它最大的特征是媒体流不经过任何中心服务器。项目里信令服务端用Java NIO写了完整的WebSocket消息处理,客户端负责SDP协商、STUN探测、UDP打洞和媒体数据收发,整个P2P链路从拨号到挂断全部自己控制。我完整跑通过这套代码,也动手改过里面的传输逻辑,最大的感受是:这项目把NAT穿透和媒体协商这两块最难啃的骨头拆成了清晰的小模块,读代码的过程基本等于补完了一门P2P网络编程实战课。适合两类人:一类是公司内网要自建视频方案的后端开发,另一类是研究多媒体传输想做二次开发的客户端工程师。

2. 项目架构与技术选型:信令、穿透、媒体三层如何协同

2.1 整体架构:信令控制和媒体流完全分离

VChat的逻辑结构分成三个独立平面:信令平面、穿透平面和媒体平面。信令平面跑在TCP/WebSocket上,负责用户上线、呼叫请求、SDP交换和ICE候选的转发;穿透平面独立于业务,通过STUN协议探测本机公网映射;媒体平面跑在UDP上,只接收和发送封装好的音视频帧。这三个平面在代码里也拆成了独立的包,互不依赖,调试的时候可以单独用单元测试把穿透模块拎出来跑。

这里的核心设计理念是:信令服务端不碰媒体数据。哪怕两个客户端最终没打通P2P,降级到TURN中继模式,媒体流也只是经过中继服务器的UDP端口转发,而不会进WebSocket处理逻辑。这个分层让整个系统的并发瓶颈只集中在信令通道上,媒体传输完全靠客户端自己的NIO线程处理。实际压测时,一台轻量级云主机跑信令服务端就能撑住上万的长连接,因为每条连接大部分时间只有心跳包在走。

2.2 目录结构:每个Java包承担什么职责

vchat-project/ ├── server/ │ ├── src/main/java/com/vchat/server/ │ │ ├── signaling/ # WebSocket信令处理 │ │ │ ├── WsServer.java │ │ │ ├── MessageRouter.java │ │ │ └── RoomRegistry.java │ │ ├── transport/ # TURN中继的UDP转发 │ │ │ ├── RelayServer.java │ │ │ └── RelaySession.java │ │ └── config/ │ └── src/main/resources/application.properties ├── client/ │ └── src/main/java/com/vchat/client/ │ ├── core/ # 状态机和会话控制 │ │ ├── CallSession.java │ │ └── CallStateMachine.java │ ├── signaling/ # 客户端信令收发 │ │ └── SignalingClient.java │ ├── ice/ # NAT穿透逻辑 │ │ ├── StunClient.java │ │ ├── CandidatePair.java │ │ └── IceAgent.java │ ├── media/ # 媒体采集、编码和传输 │ │ ├── AudioCapturer.java │ │ ├── VideoCapturer.java │ │ ├── MediaEncoder.java │ │ └── MediaSender.java │ └── ui/ # 极简控制台界面 └── common/ └── src/main/java/com/vchat/common/ ├── message/ # 信令消息定义 └── codec/ # 二进制编解码

信令服务端的核心在RoomRegistry,这是一个基于ConcurrentHashMap的房间管理类,每个房间保存两个参与者的Channel引用。消息路由很简单——A发的所有消息,除了offer、answer、ice这类特殊类型要转发给B之外,其余都只做ACK回执。这样做的好处是信令逻辑几乎无状态,服务端崩溃恢复只要客户端重连再重新协商即可。

客户端的CallStateMachine定义了从IDLE到CALLING、RINGING、CONNECTING、CONNECTED、HANGUP六个状态,所有外部事件都走状态机分发。我在读代码时特别注意了CONNECTING阶段的超时处理,它内部有个TimeoutScheduler,5秒内没完成ICE协商就直接触发HANGUP,避免线程永远卡在等待上。

2.3 选型对比:为什么用纯Java而不是框架

作者没有引入Spring Boot或Netty,整个服务端就用了JDK自带的com.sun.net.httpserver做HTTP探测接口,用java.nio手写了WebSocket的帧解析和握手流程。这个选择在社区里有不同看法,但站在作者角度可以理解:信令协议只有5种消息类型,WebSocket握手只需要实现RFC 6455的一个子集,引入Netty反而会把NIO线程模型复杂化。更重要的是,项目想把P2P相关的代码做成交钥匙的学习资源,自然会尽量少依赖外部框架。

Java在这个场景的优势体现在两处:一是NIO的Selector机制处理上千条长连接很干净,二是Java的JVM内存管理让UDP接收缓冲区的调优变得容易。MediaSender里通过ByteBuffer直接读写,避免了byte数组拷贝,减少了GC压力。相比之下用C++写同样的穿透逻辑,内存管理占的精力会远超业务代码本身。

3. 核心模块拆解:一个视频通话从拨号到挂断的完整链路

3.1 信令服务端:WebSocket消息的接收与转发

先看信令服务端最关键的消息处理入口。项目里WebSocket帧解析是自己实现的,所以一切消息都先经过MessageRouter:

public class MessageRouter { private final RoomRegistry registry; private final ExecutorService workerPool = Executors.newFixedThreadPool(8); public void handleFrame(WebSocketConnection conn, Frame frame) { String payload = new String(frame.getPayload(), StandardCharsets.UTF_8); SignalMessage msg = SignalMessage.fromJson(payload); switch (msg.getType()) { case REGISTER: registry.register(conn, msg.getUserId()); conn.sendAck("REGISTER_OK"); break; case CALL: handleCall(conn, msg); break; case OFFER: case ANSWER: case ICE: forwardToPeer(conn, msg); break; case HANGUP: registry.releaseRoom(conn); forwardToPeer(conn, msg); break; } } private void forwardToPeer(WebSocketConnection conn, SignalMessage msg) { WebSocketConnection peer = registry.getPeer(conn); if (peer != null && peer.isOpen()) { // 原样转发,不修改消息内容 peer.sendText(msg.toJson()); } else { conn.sendError("PEER_NOT_FOUND"); } } }

这段代码把信令服务端的职责表达得很清楚:它不解析OFFER里的SDP内容,也不关心ICE候选是什么类型,只做路由。forwardToPeer直接拿原JSON字符串转发,避免序列化损耗。为什么要在信令层加REGISTER?因为公网环境下客户端IP变化频繁,每次重连都要重新注册,服务端才能维护“当前在线”的映射关系。

RoomRegistry内部存储的是Map<String, Room>,Room里保存两个连接和房间创建时间。如果同一房间有第三人呼叫,直接拒绝——VChat只做一对一通话,这个约束在handleCall里体现为room.isFull()判断。顺带一提,服务端用固定线程池处理消息,但转发本身是异步的,因为sendText内部会注册写事件到Selector,不会阻塞业务线程。

3.2 客户端状态机:从拨号到通话的五步转移

客户端的信令逻辑集中在CallSession里,它把网络事件全部转成状态机转移。这里我摘取最核心的onRemoteOffer方法,它处理的是被叫方收到呼叫邀请后的完整流程:

public void onRemoteOffer(String offerJson) { if (state != State.IDLE) { // 非空闲状态直接拒绝,防止重入 sendBusy(); return; } state = State.RINGING; PeerConnection pc = new PeerConnection(); // 创建本地SDP Answer,并联通本地媒体流 String localSdp = pc.createAnswer(offerJson); sendMessage(new SignalMessage(MessageType.ANSWER, localSdp)); // 把远端ICE候选收集器挂上去 pc.addIceCandidateListener(candidate -> sendMessage(new SignalMessage(MessageType.ICE, candidate.toJson()))); // 启动连通性检查 iceAgent.startConnectivityChecks(pc.getCandidates()); state = State.CONNECTING; }

这里有个容易忽略的细节:createAnswer在拿到远端OFFER后立即执行,但此时两个客户端可能都还没完成STUN探测。所以startConnectivityChecks和发送ANSWER是并行的,ICE候选随后通过独立的ICE消息逐条发给对端。这种“先答后验”的顺序是WebRTC标准模型的简化版,如果严格按照标准来,需要先等本地候选集齐再发ANSWER,但那会增加一两个RTT的延迟。

状态机里我最欣赏的是HANGUP的幂等设计:无论当前状态是RINGING还是CONNECTED,收到HANGUP都只做资源清理并转移到IDLE,不会再有后续回调。这个看起来简单的设计在实际联调中帮了大忙,因为UDP环境下消息乱序很常见,重复HANGUP也不会导致异常。

3.3 NAT穿透模块:STUN探测与UDP打洞流程

NAT穿透是整个项目最出彩的部分。每个客户端启动时会向STUN服务器发送一个Binding Request,拿到自己的公网IP和端口映射。之后通过信令通道把这些候选信息交换给对端,双方尝试直连。核心在IceAgent的候选对筛选:

public void onRemoteCandidate(Candidate remote) { // 按优先级排序:host > srflx > relay candidatePairs.add(new CandidatePair(localCandidates, remote)); // 打洞尝试:向对端候选地址发UDP包 for (CandidatePair pair : candidatePairs.getSortedPairs()) { datagramSocket.send(pair.buildBindingRequest()); } // 启动定时重试,最多30秒 retryTimer.scheduleAtFixedRate(() -> { if (!nominated) { candidatePairs.getPairs() .stream() .filter(pair -> pair.getPriority() >= currentBest.getPriority()) .forEach(pair -> datagramSocket.send(pair.buildBindingRequest())); } }, 0, 500, TimeUnit.MILLISECONDS); }

代码里buildBindingRequest生成的是一个类似STUN Binding的探测包,但数据结构自己定义,只要对端能识别就行。这里最关键的策略是“从高优先级往下打”,host候选优先级最高——如果两台机器在同一个局域网,根本不需要经过任何NAT,直接内网IP就能通。srflx是STUN反射地址,relay是TURN中继地址,只有前两种全部失败才轮到relay。

为什么定时500毫秒重发?NAT映射表通常有30秒到2分钟不等的超时,但初期打洞阶段对端可能还没开始监听。重试是为了让双方的打洞包在NAT设备上“撞”出双向映射——常见做法是A给B发包,B的NAT会记录A的地址并放行,同时B给A发,双方在两个方向上都建立了映射。这个阶段如果有一方防火墙丢弃UDP包,就只能降级到TURN中继了。

3.4 媒体传输:UDP帧封装与丢包重传策略

打通之后,音视频数据走MediaSender和MediaReceiver。VChat没有用现成的RTP库,而是自定义了一个精简的帧头:

public class MediaFrame { private final byte[] payload; // 编码后的音视频数据 private final int sequence; // 16位递增序号,用于排序和丢包检测 private final int timestamp; // 采集时间戳,用于播放同步 private final byte mediaType; // 0=音频, 1=视频 public ByteBuffer encode() { ByteBuffer buf = ByteBuffer.allocate(payload.length + 8); buf.putShort((short) sequence); buf.putInt(timestamp); buf.put(mediaType); // payload直接放入,不转base64,省带宽 buf.put(payload); return buf; } }

这边有个很明显的取舍:省掉了RTP的CSRC/SSRC字段,因为P2P场景只有两个参与者,媒体来源不需要多路标记。接收方拿到包后根据sequence排序,如果发现跳号,会立即请求重传。重传机制是简化版NACK——只在音频连续丢包超过3个或视频关键帧缺失时才触发,避免重传风暴。

解码端用的帧率控制在25fps,码率通过MediaEncoder动态调整。VChat在带宽探测上做了一次很实用的实现:每5秒统计一次吞吐量,如果低于当前码率的80%,就把编码器的目标码率往下调一档;反之如果30秒内没有丢包,逐步向上试探。这个逻辑放在Java代码里足够直观,二次开发时替换成更精细的拥塞控制算法也很方便。

4. 本地运行与联调:从源码到两端视频互通

4.1 环境准备:JDK版本和依赖清单

VChat对构建环境要求很宽松。我用的是JDK 11和Maven 3.8,代码里没有用任何Java 8之后的新语法特性,所以JDK 8理论上也能编过,但建议还是用11,NIO和正则的表现会好一点。依赖方面整个项目只有一个org.json库用于信令消息的解析,其余全部用JDK自带类库。

# 克隆代码后直接编整个工程 cd vchat-project mvn clean install -DskipTests

这一步会把server、client、common三个模块打包成三个可执行JAR。如果Maven下载依赖很慢,可以换阿里云镜像源,把~/.m2/settings.xml里的mirror标签改成http://maven.aliyun.com/nexus/content/groups/public/。编译过程中如果看到package org.json does not exist,说明依赖解析失败,先试mvn dependency:resolve再重新编。

4.2 启动服务端和客户端实例

先启动信令服务端,默认监听9000端口:

java -jar server/target/vchat-server.jar --port=9000

看到日志输出Signaling server started on port 9000就说明服务端就绪。这个服务端同时监听WebSocket和HTTP两个端口,9000是WebSocket信令端口,9001是TURN中继的UDP端口,启动时会自动创建两个线程池分别处理。如果9000被占用,可以改启动参数指定其他端口,但注意客户端后续连接必须用同一个端口。

然后启动两个客户端,模拟AB两台机器通话:

# 终端1 java -jar client/target/vchat-client.jar --server 127.0.0.1:9000 --user alice # 终端2 java -jar client/target/vchat-client.jar --server 127.0.0.1:9000 --user bob

客户端启动后会先向127.0.0.1:9000发送REGISTER,然后进入命令行交互模式。输入call bob发出呼叫,对端按回车接听,媒体流就开始走了。--user参数是可以随意指定的,同一个ID重复注册会把旧连接顶掉,这个行为在RoomRegistry注册时有明确处理。

4.3 联调验证:看日志判断P2P是否成功

两个客户端都连上之后,观察服务端和客户端的日志输出是判断状态的核心手段。一个正常的完整流程应该是:

alice: REGISTER_OK alice: call bob -> 状态变CALLING bob: 收到CALL邀请,按回车 bob: 状态变RINGING -> CONNECTING,发出ANSWER alice: 收到ANSWER,状态变CONNECTING alice: ICE候选交换完成,输出 "P2P connected, media streaming" bob: 输出 "Media receiver started, fps=25"

在--debug模式下,客户端会把每一次打洞尝试打印出来。如果一直卡在CONNECTING超过5秒并最终HANGUP,基本可以断定NAT穿透失败。此时检查UDP端口是否被防火墙拦截(firewall-cmd --list-ports,Windows用控制面板的防火墙开关),或者看看服务端的TURN中继是否启动成功——如果9001端口没监听,打洞失败后没有降级通道,通话就直接断了。

5. VChat避坑指南:八个典型问题和排查思路

5.1 现象:同一局域网下两个客户端切到公网地址就连不上

原因很经典:两台机器都在同一个NAT后面,STUN探测拿到的是同一个公网IP但不同端口。A把公网IP:端口发给B时,B尝试连接这个公网IP,请求会被自己的NAT设备拦掉——回程包到不了B。这是NAT回环问题,也是P2P穿透最经典的坑。

解决:在候选对选择阶段增加一条规则,若对端的host候选(内网IP)和自己处于同一网段,优先使用内网IP直连。VChat的IceAgent里对host候选做了子网掩码匹配,192.168.x.x之间自动走内网。如果你自己实现P2P,这个判断务必要加。

5.2 现象:STUN探测成功,媒体流却完全不通

日志显示候选对已经选出来,也输出了P2P connected,但视频帧一直为0。原因往往是STUN探测和媒体传输走的是不同的套接字。VChat里STUN探测用的DatagramSocket端口与MediaSender的UDP发送端口如果不在同一个绑定端口上,NAT映射表中的端口映射关系就对不上,对端收到了第一个包但不认。

解决:必须让媒体发送端口和STUN绑定端口一致。VChat通过一个UdpChannel对象同时负责STUN和媒体收发,监听同一个端口,这样NAT映射记录始终有效。你自己写的时候也要注意,不要图省事用两个Socket。

5.3 现象:WebSocket握手一直411或403

客户端和信令服务端握手时,服务端校验了Sec-WebSocket-Key和Origin头。如果客户端库不自动带Origin或者带错了域名,服务端直接拒绝。VChat的WsServer里留了一个allowedOrigins配置项,默认放行*,但很多二开时改成白名单的版本需要显式把自己的域名或IP加进去。

解决:在服务端配置文件里检查allowedOrigins,或者直接抓包看握手请求头里的Origin字段。这个坑在跨域部署时特别多,Node服务做反向代理也会把Origin改掉。

5.4 现象:视频画面出来是花屏或卡成PPT

花屏几乎都是帧序错乱导致的。VChat传输协议里mediaType只有1个字节,但如果接收端的解码器不支持关键帧后第一个数据包就渲染,会导致参考帧缺失。另一个常见原因是丢包重传逻辑只对音频做了NACK,视频不做任何保护——网络抖动时视频质量会迅速劣化。

解决:如果只是本地联调,把网络模拟器关了就好。上线环境建议在MediaReceiver里增加关键帧请求——发现丢包超过阈值时,通知发送端发一帧IDR关键帧,这样恢复速度远比重传每个分片快得多。

6. 二次开发:给VChat加一个屏幕共享通道

VChat的扩展设计留得比较松,媒体帧头的mediaType字段只定义了0和1,加一种类型就能扩展新能力。这里我以屏幕共享为例,走一遍完整改造流程。核心思路:屏幕帧走和视频一样的UDP通道,但编码方式不同,接收端拿到后直接显示在窗口。

// 新增mediaType=2的处理 if (frame.getMediaType() == 2) { // 屏幕帧不压缩,直接按RGB888封装 BufferedImage img = robot.createScreenCapture(screenRect); byte[] raw = ((DataBufferByte) img.getRaster().getDataBuffer()).getData(); MediaFrame screenFrame = new MediaFrame(raw, sequence.getAndIncrement(), System.currentTimeMillis(), (byte) 2); sender.send(screenFrame); }

改造时要注意三个点:一是屏幕帧比摄像头帧大得多,必须分片传输,VChat现有的帧结构没分片字段,需要在帧头再扩一个4字节的fragmentOffset;二是帧率要降低到10fps以下,否则UDP缓冲区很快被打满;三是接收端要单独开一个渲染线程,不能和视频解码争抢同一把锁。

验证方法很简单:启动两个客户端,A调用shareScreen,B收到帧后保存成PNG文件对比内容。如果B收到的图片和A的屏幕对不上,先看sequence是否有跳号,再看分片拼接逻辑。我在做这个改造时吃过一次亏,忽略了TCP分片和UDP分片的区别——UDP一个包最大64KB,屏幕一帧RGB888可能到好几个MB,不分片直接截断,B端画面就永远只显示左上角一块。从那以后我每次改传输协议,都强制先跑一遍100次的大帧压力测试再谈上线。希望这篇拆解能帮你在P2P视频的路少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询