Spring Boot+WebRTC多人视频会议:信令与ICE调度实战
2026/9/16 20:25:21 网站建设 项目流程

简介:资源是基于 Spring Boot 与 WebRTC 的多人视频在线会议系统前端源码包,面向计算机相关专业正在做毕业设计、课程设计的学生,以及需要 Java 项目实战的开发者。前端使用 Vue + Element UI 构建,依托 WebRTC 实现图像实时传输,支持视频通话、语音通话、共享桌面、大屏预览、聊天室,并提供管理员控制成员摄像头与麦克风的能力,且不限制参会人数。压缩包共 253 个文件,以 js、vue、svg、scss 等前端资源为主,也包含使用说明、环境配置与项目文档,整体大小约 2.29MB,便于快速部署与二次开发。该资源已有 2644 人浏览学习,可直接作为毕业设计项目或期末大作业的参考实现。通过源码与配套说明,读者可以梳理多人音视频通信前端的状态管理、信令交互和界面布局思路,了解 WebRTC 连接建立与媒体流控制的实现细节,也可结合后端源码完成前后端联调,是学习 WebRTC 与 Vue 集成开发的实用材料。

1. 多人视频会议真正的难点不在摄像头,而在信令与 ICE 调度

拿到“基于 Spring Boot 实现的多人视频在线会议服务器前端”这套源码时,能直接解压运行的人不多。多数人卡在同一个地方:摄像头能开、麦克风能响,但几台电脑凑在一起就是互相看不见。这不是设备问题,而是没有理解 WebRTC 的职责边界。本标题背后的完整方案是:Spring Boot 负责信令转发、房间管理和前端静态资源托管,浏览器端负责采集音视频、协商编码、建立 P2P 通道并渲染远端画面。对于 5 年以上的后端工程师,重点不在 WebRTC 的 API 怎么调,而在信令服务器与媒体服务器的边界怎么划;对前端开发者,重点在如何把 RTCPeerConnection 的生命周期和房间状态绑到一起。这篇博文会沿着这条主线把整套源码拆开,让你看完就能在本地复现并部署出去。

2. WebRTC 单呼叫最小闭环:Spring Boot 只做信令转发

2.1 RTCPeerConnection 与 SDP/ICE 的基本角色

WebRTC 是一个浏览器内置的实时通信能力合集,核心对象是 RTCPeerConnection。它负责音视频编解码协商、网络路径探测和数据传输。但它有一个关键缺陷:它不负责“找到对方”。两个浏览器要建立连接,必须先交换两类元数据——SDP(Session Description Protocol)和 ICE Candidate。SDP 描述的是“我要发什么格式的音频、什么分辨率的视频、用什么编解码器”,ICE Candidate 描述的是“我有哪些可用的网络路径”。

这里的信令(Signaling)就是专门搬运这两类元数据的通道。WebRTC 规范没有规定信令必须用什么协议,HTTP 轮询、WebSocket、甚至飞鸽传书都行。在这套源码里,Spring Boot 的职责就是提供一个 WebSocket 服务端,把 A 浏览器的 SDP 原样转给 B 浏览器,把 B 的 answer 转回给 A。媒体数据本身不经过 Spring Boot,而是浏览器之间直接传输。

这就是理解整套源码的第一把钥匙:Spring Boot 是“传达室”,不是“会议室”。如果媒体数据走了服务器,服务器既要处理上行推流又要处理下行分发,一台 2 核 4G 的机器带不动几个参会者;但只做信令转发,一台小机器就能支撑几百个会话。

2.2 用 Spring Boot 写最小信令接口

源码里的信令服务通常以一个 WebSocket 端点出现。常见做法是用 Spring 的 WebSocketHandler 或 STOMP 协议。STOMP 的优点是天然支持订阅/发布模型,适合房间广播;原生 WebSocket 则更轻量,消息格式完全自定义。这里推荐用原生 WebSocket,因为信令消息本身就是一个 JSON 对象,不需要协议层的额外语义。

@Component public class SignalingHandler extends TextWebSocketHandler { // sessionId -> WebSocketSession,保存所有在线连接 private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String sessionId = session.getId(); sessions.put(sessionId, session); // 通知其他用户:有人上线(用于刷新参会者列表) broadcastToRoom(sessionId, MessageBuilder.join(sessionId)); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode node = new ObjectMapper().readTree(message.getPayload()); String type = node.get("type").asText(); String target = node.get("target").asText(); // 目标会话ID // offer / answer / ice 三种消息都是转发语义,不解析业务内容 WebSocketSession targetSession = sessions.get(target); if (targetSession != null && targetSession.isOpen()) { targetSession.sendMessage(message); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session.getId()); broadcastToRoom(session.getId(), MessageBuilder.leave(session.getId())); } }

这段代码里最有价值的设计思路是:服务端不关心消息体内部是什么。offer、answer、ice-candidate 三类消息在信令服务器眼里都是“需要转交给指定会话的字符串”,服务器只做路由。这样信令服务就保持无状态,房间内 N 个人同时发起呼叫,服务器只需要按 target 字段分发即可,不参与业务决策。

参数说明:ConcurrentHashMap 保证并发环境下 add/remove 的线程安全;TextWebSocketHandler 是 Spring WebSocket 提供的基础处理器,只需覆写连接建立、收到消息、连接关闭三个回调。target 字段由前端在发起呼叫时从服务端返回的在线列表里取得,通常对应目标用户的 sessionId。

2.3 前端发起呼叫的完整步骤

前端信令流程是整个源码里最容易写乱的部分,核心是状态机。正确的顺序是:先创建 RTCPeerConnection,再添加本地流,然后创建 offer,最后通过 WebSocket 发送。不能反过来,也不能在收到 answer 之前再次发送 offer。

const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); // 本地流加入连接 localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); // ICE 候选收集完成后发送给远端 pc.onicecandidate = (e) => { if (e.candidate) { sendToTarget({ type: 'ice', target: remoteId, candidate: e.candidate.toJSON() }); } }; // 收到远端媒体流时渲染到 <video> 标签 pc.ontrack = (e) => { const video = document.getElementById(`remote-${remoteId}`); video.srcObject = e.streams[0]; }; // 创建 offer 并本地 setLocalDescription,再通过信令发送 const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }); await pc.setLocalDescription(offer); sendToTarget({ type: 'offer', target: remoteId, sdp: pc.localDescription });

收到远端 answer 的一侧执行 pc.setRemoteDescription(message.sdp),收到 ice-candidate 的一侧执行 pc.addIceCandidate(message.candidate)。注意 onicecandidate 回调在 setLocalDescription 之后会密集触发,一定要等 WebSocket 连接建立成功后再开始呼叫,否则候选会丢在连接建立之前。很多项目调试时“偶尔能连上、刷新就失败”,就是这个时序问题。

2.4 SDP、ICE Candidate 和连接状态的关系

这三个概念容易被混在一起,实际对应三个独立阶段:

概念谁产生传递方式作用
SDP offer呼叫方WebSocket 转发描述媒体能力与协商参数
SDP answer被呼叫方WebSocket 转发应答并声明自己的媒体能力
ICE Candidate双方各自产生WebSocket 转发收集可用网络路径并尝试连通

connectionState 的流转是连接是否健康的直接指标。监听 pc.onconnectionstatechange,取值为 new、connecting、connected、disconnected、failed、closed。前端调试时最有用的一招是:状态变为 failed 时,不要在页面里反复 “重新加入”,而是先执行 pc.getStats() 看候选类型,区分是主机候选、反射候选还是中继候选。三个都是 host 但连不上,说明双方在同一个局域网但端口被封;出现 relay 才说明流量走 TURN 服务器了。

3. 从一对一升级到多人会议:房间模型与会话膨胀控制

3.1 为什么多人会议不能用全网状连接

一对一呼叫的模型是 A 和 B 各建一条 P2P 连接。10 个人开会如果每个人都要看到其他 9 个人,直接思路是每个人都与其它 9 人建立 RTCPeerConnection,这就是全网状(Mesh)架构。但这里有一个带宽数学题:每个人需要上行推送给 9 个远端,同时下行接收 9 个流。假设每个人上行码率是 1.5 Mbps,总上行就是 13.5 Mbps。普通家用宽带的上行只有 10~30 Mbps,4 个人还能勉强撑住,8 个人基本全断。

源码里处理这个问题的方式是服务器端用 SFU(Selective Forwarding Unit),即选择性转发单元。SFU 的核心思想是:每个参会者只向服务器推一路流,服务器再把这路流复制给房间里的其他人。参会者上行固定是 1 路,下行变成 N−1 路。下行带宽需求仍然存在,但这部分压力转嫁给了接收端的带宽,而企业内网或家庭宽带的下载带宽远高于上传,所以 20 人会议在纯 Mesh 下不可能,在 SFU 下是常规操作。

网关层设计上,Spring Boot 在这套架构里的角色仍然是信令服务,不承担媒体转发。媒体转发由额外的媒体服务器完成。但由于本项目标的是“服务器前端”,更常见的方案是每个浏览器端同时维护多条 PeerConnection,一条对应一路远端流。这里的 Spring Boot 会负责维护房间-参会者映射,把新加入者的 ID 广播给房间内所有人,让每个前端自行创建新的 PeerConnection。

3.2 房间与参会者的数据模型

多人会议与一对一最大的差异在于:连接关系是动态的。A 进入房间时,房间里已有 B、C、D,A 需要与三人分别建连;随后 E 进入,A 又要与 E 建连。如果数据结构设计不好,信令服务器就得频繁全量广播状态。

public class ConferenceRoom { private String roomId; private Map<String, Participant> participants = new ConcurrentHashMap<>(); // 新参会者加入时,只广播增量变化 public void addParticipant(Participant p) { participants.put(p.getSessionId(), p); broadcastExcept(p.getSessionId(), EventType.USER_JOINED, p.toViewObject()); } // 离会时通知其他人,并清理该连接持有的媒体流标记 public void removeParticipant(String sessionId) { Participant removed = participants.remove(sessionId); broadcastExcept(sessionId, EventType.USER_LEFT, removed.toViewObject()); } }

设计要点有两个:一是增量广播而不是全量刷新,10 人房间里一个人进出只推送一条消息给其余 9 人,而不是把 10 人列表整个重发;二是以 sessionId 作为 key,以 userId 作为业务标识,二者分离。用户可能因断网重连而更换 sessionId,但 userId 不变,前端 UI 层依据 userId 更新画面,不会出现“换个人又加入一遍”的闪烁。

前端收到 USER_JOINED 事件后的动作是:为该新用户创建新的 RTCPeerConnection,发起 offer;本地维护 Map<userId, RTCPeerConnection>。收到 USER_LEFT 则关闭对应连接、移除 video 标签。这里有个常见的坑:不要把 Map 直接绑定到 Vue 或 React 的响应式对象上,RTCPeerConnection 实例包含大量浏览器内部状态,放进响应式代理里会被反复劫持,轻则性能下降,重则触发 IllegalInvocation 错误。

3.3 信令层面需要扩展的消息类型

一对一场景只需要 offer、answer、ice 三类消息,多人会议至少要多出四类:join-room、user-joined、user-left、leave-room。这四类消息的消息体设计决定了前端逻辑的复杂度。

{ "type": "join-room", "roomId": "room-001", "userId": "u-1001", "sdp": null }

join-room 由进入会议页面时发送,Spring Boot 收到后返回当前参会者列表。这里注意:服务端不要设置“会议室满员自动拒绝”的逻辑,交给业务层处理——因为会议容量上限通常由媒体服务器决定,信令服务器并不知道每路媒体的实际码率。

收到 join-room 响应后,前端遍历列表中的已有用户,逐个创建 PeerConnection 并发送 offer。同时被叫方的 SourceHandler 会在 onicecandidate 回调里把候选发给新加入者。源码里如果看到 join-room 响应里除了用户列表还带了一个“当前轮次状态”字段,说明项目支持“举手发言”或“摄像头开关同步”等业务功能,这些不是 WebRTC 的必要部分,但属于产品层常见扩展。

3.4 前端把多个远端流渲染成动态网格

多人画面布局与单人视频渲染的差异在于:容器大小是动态变化的。4 人会议是 2×2,9 人会议是 3×3,中间还要处理 5 人、7 人这种非整数的布局。用 CSS Grid 的 auto-fill 能解决大部分问题,但直播间模式的“主讲人放大、其他人缩小”需要额外标记。

<div class="grid-container"> <video id="remote-u-1001" autoplay playsinline></video> <video id="remote-u-1002" autoplay playsinline></video> <video id="remote-u-1003" autoplay playsinline></video> </div>
.grid-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px; width: 100%; height: 100%; } video { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #1e1e1e; border-radius: 8px; }

auto-fit 和 minmax 的组合效果是:容器宽度足够大时自动增加列数,不足时自动换行,视频不会拉伸变形。object-fit: cover 会裁掉边缘画面,适合多人会议,因为参会者主要看画面中央的人脸;如果是屏幕共享场景,应该改成 object-fit: contain 保证内容完整可见。

性能上有一个容易忽略的问题:远端每个流都是独立解码的,每路 1080p 视频的解码 CPU 占用在 15%~30% 之间。5 路视频同时渲染时,低配笔记本的 CPU 会跑到 80% 以上,导致画面卡顿。源码里如果开启了“不活跃成员自动冻结画面”,通常是在 ontrack 之后用定时器检测帧间隔,超过一定时间就暂停解码或把 video 的 srcObject 置空,这是值得留意的优化点。

4. 前端工程化:摄像头采集、网格布局与信令重连

4.1 getUserMedia 获取本地流的参数选择

本地流采集的 API 很简单,但约束参数的设置直接影响多路视频的 CPU 压力与带宽占用。很多人直接调 getUserMedia({ video: true, audio: true }),摄像头会按默认的 1280×720@30fps 采集。如果 10 个人全开 720p 推流,信令链路没问题,但端侧解码和网络传输都会过载。

const constraints = { video: { width: { ideal: 1280 }, // 理想宽度,允许浏览器降级 height: { ideal: 720 }, frameRate: { ideal: 15, max: 24 }, // 会议场景 15fps 足够 // 带宽控制写在 RTCRtpSender 上,不在 getUserMedia 里 }, audio: { echoCancellation: true, // 开启回声消除 noiseSuppression: true, // 降噪 autoGainControl: true // 自动增益 } }; const localStream = await navigator.mediaDevices.getUserMedia(constraints);

参数取舍上,frameRate 设置 15 而不是 30 是关键的省带宽手段,画面里主要是静态的人脸,15fps 与 30fps 的主观差异远小于带宽差异。码率控制必须在 RTCRtpSender 层设置,因为 getUserMedia 不负责编码码率。

const sender = pc.getSenders().find(s => s.track && s.track.kind === 'video'); sender.setParameters({ degradationPreference: 'maintain-framerate', // 优先保帧率,降低分辨率 encodings: [{ maxBitrate: 800_000 }] // 上限 800kbps });

degradationPreference 有三个值:maintain-framerate 表示带宽不足时优先降分辨率,适合人脸近景;maintain-resolution 相反;balanced 是折中。这个参数面试里经常被问到,底层逻辑就是编码器的码控策略。

4.2 声音播放的自动播放策略限制

多人会议里最常见的“没声音”问题不是采集失败,而是浏览器的自动播放策略拦截。Chrome 要求与用户产生交互后才能出声,如果页面加载后自动播放远端音频,会被浏览器拒之门外。处理方式是在创建远端 video 标签时不要设置 muted=false,而是把 autoplay 和 playsinline 都写进去,再在用户点击“加入会议”按钮的回调里执行一次播放。

async function playRemoteVideo(videoElement) { try { await videoElement.play(); } catch (e) { // 捕获 NotAllowedError,提示用户点击一次页面 showToast('请点击页面后重试'); } }

这个逻辑要注意的细节是:video.play() 返回一个 Promise,必须 await。很多源码忽略了这个 Promise 导致 Unhandled Promise Rejection,控制台里看起来像 bug,实际只是事件时序问题。

4.3 信令断线重连与房间重建

网络抖动时 WebSocket 会断开,但 RTCPeerConnection 可能还活着。直接刷新页面会丢失连接状态,优雅的恢复方式是:WebSocket 断线后,保持 PeerConnection 不销毁,尝试重新连接信令服务器;连接成功后,重发 join-room 消息,服务端返回当前用户列表,对每个用户检查本地是否已有对应连接,没有才补建。

let reconnectAttempts = 0; function connectSignaling() { ws = new WebSocket(WS_URL); ws.onclose = () => { if (reconnectAttempts < 5) { setTimeout(() => { reconnectAttempts++; connectSignaling(); }, 1000 * reconnectAttempts); // 退避重连 } }; ws.onopen = () => { reconnectAttempts = 0; ws.send(JSON.stringify({ type: 'join-room', roomId })); }; }

这里有一个明显的坑:重连时如果房间的参与者列表里已经有自己,服务端要把自己过滤掉,否则前端会给自己创建一条 PeerConnection。在 ConferenceRoom 的 addParticipant 方法里通常要加一个校验:如果 participants 中已存在相同 userId,先移除旧 session 再新增,避免同一个人出现在两个位置。

4.4 回声与啸叫的音频参数陷阱

音频参数里,echoCancellation 是浏览器内置的回声消除能力,但它只在 getUserMedia 调用时生效,后续修改无效。多人会议中远端声音通过扬声器放出,又被本地麦克风采集,回声消除算法需要区分“本地说话”和“远端播放”两种信号。如果参会者戴着耳机,可以关掉 AEC 减少音质损失;如果用扬声器外放,必须保持 AEC 开启。

还有一个容易被忽略的采集约束:deviceId。用户可能插了多个麦克风或摄像头,如果 getUserMedia 里不指定 deviceId,浏览器默认选择第一个设备,经常出现“选好了设备但会议里还是默认麦克风”的问题。正确的做法是先用 enumerateDevices 获取设备列表,由用户选择后把 deviceId 写进 constraints 再重新初始化媒体流,同时要调用 pc.getSenders()[0].replaceTrack(newTrack) 替换轨道而不是重建连接。

5. 部署与验证:用一台服务器把多人会议跑起来

5.1 Spring Boot 打包与前端静态资源合并部署

部署形态上,Spring Boot 同时承担信令服务和静态资源服务器是这套源码最方便的地方。前端工程构建后,把 dist 目录下的文件拷贝到 Spring Boot 的 src/main/resources/static 下,打包出的 jar 只有一个,直接 java -jar 启动即可,不需要额外配置 Nginx。

# 前端构建 npm run build # 拷贝到 Spring Boot 静态目录 cp -r dist/* ../src/main/resources/static/ # 后端打包 mvn clean package -DskipTests # 启动 java -jar target/conference-server-0.0.1-SNAPSHOT.jar --server.port=8080

WebSocket 端点和静态资源的路径要避免冲突。常见配置是 WebSocket 走 /ws 路径,静态资源通过 路径访问,两者互不干扰。部署到反向代理后面时,要注意开启 WebSocket 代理支持,Nginx 里需要设置 Upgrade 请求头,Spring Boot 内置的 Tomcat 对 WebSocket 支持不需要额外配置,但如果放在云负载均衡后面,负载均衡层必须支持 WebSocket 协议。

5.2 必须正确配置的 WebRTC 三项参数

成功部署不等于能开会,WebRTC 有四项参数必须在部署前配置清楚:ICE Servers、信令路径、HTTPS、端口范围。前两项容易理解,这里重点说后两项。

{ "iceServers": [ { "urls": ["stun:stun.l.google.com:19302"] }, { "urls": "turn:your-server.com:3478", "username": "conference", "credential": "your-password" } ] }

HTTPS 是必须的,因为 getUserMedia 只在安全上下文(localhost 或 HTTPS)下可用。局域网测试用 http://localhost 可以,但生产环境用 IP 访问的用户会发现摄像头无法启动,这不是代码问题而是浏览器安全策略。端口范围方面,WebRTC 的 P2P 数据传输会使用 UDP,如果服务器上运行了 TURN 服务,要确保 UDP 端口 3478 和 relay 端口范围 49152-65535 在防火墙和安全组里放行,否则生产环境里会出现“信令通了、媒体不通”的现象。

5.3 多人联调的验证顺序与常见失败表现

验证一套多人会议系统,按顺序走完这四个检查点,基本能定位 90% 的问题:

检查点验证方法常见失败表现
信令连通性打开浏览器控制台,确认 WebSocket 状态为 open状态停在 connecting,检查 /ws 路径和反向代理配置
媒体采集权限调用 getUserMedia 后确认 localVideo 有画面黑屏或摄像头指示灯不亮,检查 HTTPS 与设备占用
P2P 连通性观察 connectionState 最终状态停在 connecting 表示 ICE 未完成,检查 STUN/TURN 配置
音视频渲染确认每个远端 video 的 srcObject 非空srcObject 有值但黑屏,检查自动播放策略

联调时一个实用的工具是 chrome://webrtc-internals,里面能看到完整的 SDP 交换记录、ICE 候选类型和连接状态变化时间线。如果 ICE 候选全是 relay 类型,说明 host 和 srflx 候选都失败了,需要检查防火墙;如果候选里没有 relay 但 TURN 已配置,说明配置没生效,检查前端加载 ICE Servers 的时机是否早于 RTCPeerConnection 创建。

5.4 用 getStats 验证丢包与端到端延迟

部署完成后不要只看画面流畅就收工。用 RTCPeerConnection 的 getStats 接口能拿到每个媒体流的发送/接收统计,一般用于验证带宽设置是否合理。在发送端调用 sender.getStats(),找到 outbound-rtp 类型的报告,重点看 framesPerSecond、framesEncoded、bytesSent 三个值。如果 framesPerSecond 长时间低于设置的 15,说明带宽不够触发了降帧;如果 bytesSent 增长过快,说明码率失控。

接收端同理,找 inbound-rtp 报告里的 bytesReceived 和 packetsLost。packetsLost 与 packetsReceived 的比值超过 5% 时,画面会出现明显花屏或卡顿,此时要检查是丢包率高还是抖动大。丢包率高的场景,可以尝试把 maxBitrate 调低,让编码器用更小的码率换取更稳定的传输;抖动大的场景,要给接收端 video 标签设置较小的高宽比和缓冲,但 WebRTC 内部的 jitterBuffer 无法从外部调整,只能通过降低分辨率来缓解。这一步做完,一套多人视频会议系统的体验才算真正稳定。

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

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

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

立即咨询