很多同学用过微信、QQ 视频聊天,但一问到底层怎么实现,十有八九只会说一句:"应该是 WebSocket / WebRTC 吧?"——但是:
- WebRTC 到底负责什么?
- WebSocket / Netty 在里面干嘛?
- STUN / ICE / SDP 是啥?为什么一上来就一堆名词?
这篇文章会用一套完整的小项目,从 0 到 1 带你实现一个:
通过完整项目演示基于 WebRTC、Java(SpringBoot + Netty)和 Vue 的点对点视频聊天实现。涵盖 WebRTC 核心概念(STUN/ICE/SDP)、后端信令服务器搭建(Netty WebSocket 处理)、前端音视频采集与 P2P 连接建立。文章详细解析了信令交互流程及常见问题排查,适合初学者理解实时音视频通信架构。
很多同学用过微信、QQ 视频聊天,但一问到底层怎么实现,十有八九只会说一句:"应该是 WebSocket / WebRTC 吧?"——但是:
这篇文章会用一套完整的小项目,从 0 到 1 带你实现一个:
基于 WebRTC + Java(SpringBoot + Netty)+ Vue 的点对点视频聊天 Demo
重点是:
不是只给你一堆代码,而是把每个概念都讲清楚,让小白也能看懂、改得动、举一反三。
先看一张逻辑图(可以脑补成 PPT):
┌────────────────────────────────────────────┐
│ 后端(Java) │
│ ├─ SpringBoot 负责: │
│ │ - 启动项目、整合 Netty、管理 Bean │
│ └─ Netty WebSocket 负责: │
│ - 管理 WebSocket 长连接 │
│ - 维护'用户 ID ⇔ Channel'映射 │
│ - 转发信令:register / call / answer / ice│
└────────────────────────────────────────────┘
▲ ▲
│ WebSocket 信令
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ 前端 A(浏览器,Vue) │ │ 前端 B(浏览器,Vue) │
│ - 采集本地音视频 │ │ - 采集本地音视频 │
│ - WebRTC 建立 P2P │ │ - WebRTC 建立 P2P │
│ - 通过 WebSocket 发信令 │ │ - 通过 WebSocket 发信令 │
└────────────────────────┘ └────────────────────────┘
▲ ▲
│ WebRTC P2P 音视频流(真正传输媒体)
▼ ▼
一句话概括:
WebRTC(Web Real-Time Communication):
浏览器原生支持的实时音视频通信技术,特点:
WebRTC 只负责媒体通道,但它不负责:
这些'撮合、协商、交换信息'的过程,就叫做 '信令(Signaling)'。
我们通常用 WebSocket + 后端服务器 来做信令通道。
可以把 WebRTC 想成'电话线 + 麦克风 + 摄像头',
WebSocket/Netty 相当于'中间的红娘 + 前台接线员'。
日常网络中,绝大部分设备使用的都是内网 IP(如 192.168.x.x),对外通过路由器共享一个公网 IP。
这就导致一个问题:
A 和 B 都在各自的内网里,它们只知道自己的内网 IP,
但对方的内网 IP 在自己的网络范围外根本访问不到。
就像:
STUN(Session Traversal Utilities for NAT) 是一个网络服务,它做的事情很简单:
同理,设备 B 也这么干一遍。
然后二者通过信令服务器互相交换这个'公网地址',就可以尝试点对点打洞通信。
ICE(Interactive Connectivity Establishment) 是 WebRTC 中用于 NAT 穿透的一个框架 / 策略集合:
在代码里,你通常会看到:
const iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.qq.com:3478' },
{ urls: 'stun:stun.aliyun.com:3478' }
]
};
这里的 iceServers 就是传给 RTCPeerConnection 的配置,告诉它:
'你做 ICE 穿透时,可以去这些 STUN 服务器问路。'
当各种 NAT 太恶心、P2P 打洞失败时,就只能退而求其次,让媒体流通过 TURN 服务器中转。
本教程的 Demo 为了简单,只使用 STUN,不涉及 TURN。
RTCPeerConnection 建立 P2P接下来我们按这个顺序,一步一步实现。
<dependencies>
<!-- SpringBoot 核心:提供基础容器、自动配置等 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Netty:高性能网络通信框架,用来实现 WebSocket 服务器 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.94.Final</version>
</dependency>
<!-- FastJSON:JSON 序列化/反序列化,用来解析/构造信令消息 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
</dependencies>
说明:
ws://localhost:8081/ws。Message,也能把对象转回 JSON。Message在 WebRTC 信令交互中,我们需要传递的信息包括:
register(注册)、call(呼叫)、answer(应答)、ice(候选)fromtodata(比如 SDP、ICE 候选的 JSON 字符串)用一个统一的实体类封装,可以让服务端逻辑更清晰。
Message.java 示例实现(带详细注释)package com.qcby.springboot.entity;
/**
* 信令消息实体类
*
* 在 WebRTC + WebSocket 的信令交互中,客户端与服务器之间传递的
* 实际上都是 JSON 文本。为了在后端方便处理,我们把这类消息统一
* 封装成一个 Java 对象。
*
* 一个典型的信令消息 JSON 可能长这样:
*
* {
* "type": "call",
* "from": "user1",
* "to": "user2",
* "data": "{...SDP 或 ICE JSON...}"
* }
*
* 其中:
* - type:消息类型(register / call / answer / ice)
* - from:发送方用户 ID
* - to:接收方用户 ID
* - data:具体的数据内容(字符串形式,一般也是 JSON)
*/
public class Message {
/**
* 消息类型:
* - register:客户端注册自己的 userId
* - call :发起呼叫,携带 SDP offer
* - answer :应答呼叫,携带 SDP answer
* - ice :传递 ICE 候选信息
*/
private String type;
/**
* 发送方用户 ID
*
* 由前端在发送消息时指定,例如 user1、user2。
*/
private String from;
/**
* 接收方用户 ID
*
* 服务端会根据该字段找到目标 Channel,然后进行消息转发。
*/
private String to;
/**
* 消息数据体
*
* 对于不同类型的消息,该字段含义不同:
* - register:通常为空字符串
* - call :存放 SDP offer 字符串(JSON 序列化后的对象)
* - answer :存放 SDP answer 字符串
* - ice :存放 ICECandidate 的 JSON 字符串
*/
private String data;
// ======= Getter / Setter(省略 IDE 自动生成注释) =======
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getFrom() { return from; }
public void setFrom(String from) { this.from = from; }
public String getTo() { return to; }
public void setTo(String to) { this.to = to; }
public String getData() { return data; }
public void setData(String data) { this.data = data; }
@Override
public String toString() {
return "Message{" +
"type='" + type + '\'' +
", from='" + from + '\'' +
", to='" + to + '\'' +
", data='" + data + '\'' +
'}';
}
}
WebSocketHandlerWebSocketHandler 是整个服务端的核心类,主要负责:
register:记录用户 ID 与 Channel 的映射。call / answer / ice:根据 to 字段找到目标用户 Channel,转发消息。package com.qcby.springboot.handler;
import com.alibaba.fastjson.JSON;
import com.qcby.springboot.entity.Message;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocketHandler
*
* 作用:处理基于 WebSocket 的文本消息(TextWebSocketFrame),
* 配合 SpringBoot 与 Netty 实现一个简单的'信令服务器'。
*
* 关键点:
* - 使用 ConcurrentHashMap 存储用户 ID 与 Channel 的映射关系。
* - 根据消息类型进行不同的业务处理。
*/
@Configuration
@ChannelHandler.Sharable
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
/**
* 用户 ID 到 Channel 的全局映射表
*
* key:String 类型的用户 ID(如 "user1")
* value:该用户对应的 Netty Channel(一个 WebSocket 连接)
*
* 使用 ConcurrentHashMap 保证多线程场景下的线程安全。
*/
public static final ConcurrentHashMap<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
/**
* 当与客户端建立连接时触发
*
* 注意:此时客户端还未发送 register 消息,我们只知道有一个新连接,
* 但暂时不知道它对应的 userId。
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("与客户端建立连接,通道开启:" + ctx.channel().id());
}
/**
* 处理接收到的文本消息(核心逻辑)
*
* @param ctx 上下文对象,包含 Channel、Pipeline 等信息
* @param msg 客户端发送的文本帧(TextWebSocketFrame)
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 1. 获取文本消息内容(JSON 字符串)
String text = msg.text();
System.out.println("收到原始消息文本:" + text);
// 2. 反序列化为 Message 对象
Message message = JSON.parseObject(text, Message.class);
System.out.println("解析后的 Message 对象:" + message);
// 3. 根据消息类型进行处理
switch (message.getType()) {
case "register":
// 注册消息:将用户 ID 与当前 Channel 绑定
handleRegister(message, ctx.channel());
break;
case "call":
case "answer":
case "ice":
// 呼叫 / 应答 / ICE 候选:需要转发给指定接收方
handleForward(message, text);
break;
default:
System.out.println("未知消息类型:" + message.getType());
}
}
/**
* 处理用户注册逻辑
*
* @param message 包含 from 字段的注册消息
* @param channel 当前用户的 Channel
*/
private void handleRegister(Message message, Channel channel) {
String userId = message.getFrom();
if (userId == null || userId.trim().isEmpty()) {
System.out.println("注册失败:userId 为空");
return;
}
// 将 userId 与 Channel 放入映射表 USER_CHANNEL_MAP.put(userId, channel);
System.out.println("用户 " + userId + " 注册成功,ChannelId=" + channel.id());
}
/**
* 将消息转发给接收方用户
*
* @param message 解析后的 Message 对象
* @param rawText 原始 JSON 文本(直接转发给对方)
*/
private void handleForward(Message message, String rawText) {
String targetUserId = message.getTo();
if (targetUserId == null || targetUserId.trim().isEmpty()) {
System.out.println("转发失败:目标用户 ID 为空");
return;
}
Channel targetChannel = USER_CHANNEL_MAP.get(targetUserId);
if (targetChannel != null && targetChannel.isActive()) {
// 通过对方的 Channel 发送消息
targetChannel.writeAndFlush(new TextWebSocketFrame(rawText));
System.out.println("已将消息转发给用户:" + targetUserId);
} else {
System.out.println("用户 " + targetUserId + " 不在线或 Channel 不可用");
}
}
/**
* 处理连接断开事件
*
* 注意:这里我们简单地打印日志,更完善的做法是:
* - 从 USER_CHANNEL_MAP 中移除对应的 userId
* (可以反向根据 Channel 找到 userId)
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.out.println("与客户端断开连接,通道关闭:" + ctx.channel().id());
// 从映射表中移除对应的 Channel
USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue().id().equals(ctx.channel().id()));
}
/**
* 处理异常情况
*
* 常见异常:
* - 网络中断
* - 客户端强制关闭
* - 解码错误等
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("连接异常:" + cause.getMessage());
// 出现异常时,清理映射表中的 Channel
USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel());
// 关闭连接
ctx.close();
}
}
8004),对外提供 ws://localhost:8004/ws。WebSocketHandlerpackage com.qcby.springboot.commun;
import com.qcby.springboot.handler.WebSocketHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
/**
* Netty WebSocket 服务端
*
* 与 SpringBoot 集成的方式:
* - 声明为 @Configuration,让 Spring 管理
* - 在 SpringBoot 启动后,通过 CommandLineRunner 调用 start() 方法
*/
@Configuration
public class NettyWebSocketServer {
/**
* 自定义的 WebSocketHandler,由 Spring 自动注入
*/
@Autowired
private WebSocketHandler webSocketHandler;
/**
* 启动 Netty WebSocket 服务器
*
* @throws Exception 启动异常
*/
public void start() throws Exception {
// bossGroup:专门用来接收客户端连接的线程组
EventLoopGroup bossGroup = new NioEventLoopGroup();
// workerGroup:处理已建立连接的读写事件
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap
// 设置服务端的参数,例如等待队列大小
.option(ChannelOption.SO_BACKLOG, 1024)
// 绑定两个线程组
.group(bossGroup, workerGroup)
// 指定使用 NIO 模型的服务端 Channel
.channel(NioServerSocketChannel.class)
// 指定本地监听端口
.localAddress(8004)
// 子通道(客户端连接)初始化逻辑
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// WebSocket 基于 HTTP 协议,因此先添加 HTTP 编解码器
ch.pipeline().addLast(new HttpServerCodec());
// 支持大数据流的写入
ch.pipeline().addLast(new ChunkedWriteHandler());
// 聚合 HTTP 消息,方便处理完整请求
ch.pipeline().addLast(new HttpObjectAggregator(8192));
// WebSocket 协议处理器:
// - 路径为 /ws
// - 支持 WebSocket 升级握手
ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", "WebSocket", true, 65536 * 10));
// 最后添加我们自己的业务处理器
ch.pipeline().addLast(webSocketHandler);
}
});
// 异步绑定端口,sync() 会阻塞直到绑定完成
ChannelFuture channelFuture = serverBootstrap.bind().sync();
System.out.println("Netty WebSocket 服务器启动成功,监听端口:8004");
// 关闭通道(会阻塞,直到服务器通道关闭)
channelFuture.channel().closeFuture().sync();
} finally {
// 关闭线程组,释放资源
workerGroup.shutdownGracefully().sync();
bossGroup.shutdownGracefully().sync();
}
}
}
CommandLineRunner 启动 NettySpringBoot 提供了 CommandLineRunner 接口,当应用启动完成后,会自动调用其中的 run() 方法,非常适合我们在其中启动 Netty。
package com.qcby.springboot;
import com.qcby.springboot.commun.NettyWebSocketServer;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* SpringBoot 启动类
*
* 负责:
* - 启动 Spring 容器
* - 在项目启动后,自动启动 Netty WebSocket 服务器
*/
@SpringBootApplication
@MapperScan("com.qcby.springboot.dao") // 如果暂时没有 MyBatis,可去掉
public class Application implements CommandLineRunner {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
/**
* 自动注入 NettyWebSocketServer
*/
@Autowired
private NettyWebSocketServer nettyServer;
/**
* SpringBoot 启动完成后会自动回调该方法
*/
@Override
public void run(String... args) throws Exception {
// 启动 Netty WebSocket 服务
nettyServer.start();
}
}
到此为止,后端信令服务器就搭好了:
ws://localhost:8004/ws 建立连接。WebSocketHandler 转发。下面是一份完整的页面模板,已经包含:
<template>
<div>
<h2>WebRTC 视频聊天</h2>
<!-- 1. 用户 ID 输入与连接服务器区域 -->
<div>
<input v-model="userId" placeholder="输入你的用户 ID(如 user1)" type="text" />
<button @click="connect">连接服务器</button>
</div>
<!-- 2. 呼叫功能区域(仅连接成功后显示) -->
<div v-if="socketConnected">
<input v-model="targetUserId" placeholder="输入对方用户 ID(如 user2)" type="text" />
<button @click="call">发起视频呼叫</button>
</div>
<!-- 3. 挂断按钮(仅在有远程流时显示,可根据业务调整条件) -->
<div v-if="socketConnected">
<button @click="hangUp">挂断通话</button>
</div>
<!-- 4. 视频展示区域 -->
<div>
<div>
<p>本地视频</p>
<video ref="localVideo" autoplay muted></video>
</div>
<div>
<p>远程视频</p>
<video ref="remoteVideo" autoplay></video>
</div>
</div>
</div>
</template>
<style scoped>
.container {
width: 900px;
margin: 20px auto;
text-align: center;
font-family: "Microsoft YaHei", sans-serif;
}
.input-group {
margin: 25px 0;
}
input {
padding: 10px 15px;
width: 220px;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 10px 20px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #359469;
}
.video-container {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 30px;
}
.video-item {
text-align: center;
}
{
: ;
: ;
: ;
}
{
: ;
: ;
: solid ;
: ;
: ;
}
</style>
<script setup>
// 1. 从 Vue 中导入响应式 API 和生命周期钩子
import { ref, onUnmounted } from 'vue';
// 2. 响应式变量:会影响页面显示
const userId = ref(''); // 本地用户 ID(如 user1)
const targetUserId = ref(''); // 对方用户 ID(如 user2)
const socketConnected = ref(false); // WebSocket 是否已连接
// 3. 视频 DOM 引用,用于绑定媒体流
const localVideo = ref(null); // 本地视频 <video>
const remoteVideo = ref(null); // 远程视频 <video>
// 4. 普通变量:仅脚本内部使用,不需要响应式
let socket = null; // WebSocket 连接实例
let peerConnection = null; // WebRTC RTCPeerConnection 实例
let localStream = null; // 本地音视频流(MediaStream)
// 5. STUN 服务器配置(ICE 服务器),用于 NAT 穿透
const iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.qq.com:3478' },
{ urls: 'stun:stun.aliyun.com:3478' }
]
};
</script>
<script setup>
// ... 上面的代码省略 ...
// 通用消息发送函数:所有 register / call / answer / ice 都通过它发送
const sendMessage = (message) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
console.log('📤 发送消息:', message);
} else {
console.error('❌ WebSocket 未连接,无法发送消息');
alert('未连接服务器,请先点击'连接服务器'!');
}
};
// 点击'连接服务器'按钮时触发
const connect = () => {
if (!userId.value.trim()) {
alert('请输入你的用户 ID!(不能为空)');
return;
}
// 注意:这里的端口和路径要与后端 Netty 配置一致
socket = new WebSocket('ws://localhost:8004/ws');
// 连接成功
socket.onopen = () => {
console.log('✅ WebSocket 连接成功');
socketConnected.value = true;
// 向服务器发送 register 消息,注册当前用户 ID
sendMessage({ type: 'register', from: userId.value, to: '', data: '' });
};
// 收到服务器转发的信令消息
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log('📥 收到服务端消息:', message);
handleMessage(message);
} catch (err) {
console.error('❌ 消息解析失败:', err);
}
};
socket.onclose = () => {
console.log('❌ WebSocket 连接关闭');
socketConnected.value = false;
};
socket.onerror = (err) => {
console.error('❌ WebSocket 连接出错:', err);
socketConnected.value = false;
};
};
</script>
<script setup>
// ... 前面代码省略 ...
// 根据消息类型处理:call / answer / ice
const handleMessage = async (message) => {
switch (message.type) {
case 'call':
// 收到对方发起的呼叫(包含 SDP offer)
await answerCall(message);
break;
case 'answer':
// 对方已应答,设置远程 SDP
await setRemoteSDP(message.data);
break;
case 'ice':
// 收到对方的 ICE 候选
await addIceCandidate(message.data);
break;
default:
console.log('📌 未处理的消息类型:', message.type);
}
};
</script>
<script setup>
// ... 继续补充在同一个 <script setup> 内 ...
// 初始化 RTCPeerConnection
const initPeerConnection = async () => {
// 如果已有连接,先关闭,避免重复
if (peerConnection) {
peerConnection.close();
}
// 创建新的 PeerConnection,传入 ICE 服务器配置
peerConnection = new RTCPeerConnection(iceServers);
// 监听本地 ICE 候选生成事件:每生成一个地址,就通过信令发给对方
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendMessage({
type: 'ice',
from: userId.value,
to: targetUserId.value,
data: JSON.stringify(event.candidate)
});
}
};
// 监听远程媒体流事件:当对方的音视频流到达时挂载到 <video>
peerConnection.ontrack = (event) => {
remoteVideo.value.srcObject = event.streams[0];
console.log('🎥 收到远程音视频流');
};
};
</script>
<script setup>
// ... 继续在同一个 <script setup> 中 ...
// 点击'发起视频呼叫'
const call = async () => {
if (!targetUserId.value.trim()) {
alert('请输入对方用户 ID!');
return;
}
try {
// 1. 初始化 PeerConnection
await initPeerConnection();
// 2. 获取本地音视频流(浏览器会弹出权限申请)
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
// 3. 将本地流显示在本地视频上
localVideo.value.srcObject = localStream;
// 4. 将本地流的每一条轨道添加到 PeerConnection
localStream.getTracks().forEach((track) => {
peerConnection.addTrack(track, localStream);
});
// 5. 创建 SDP offer(描述本地的媒体能力)
const offer = await peerConnection.createOffer();
// 6. 将 offer 设置为本地描述
await peerConnection.setLocalDescription(offer);
// 7. 通过信令把 offer 发送给对方
sendMessage({
type: 'call',
from: userId.value,
to: targetUserId.value,
data: JSON.stringify(offer)
});
console.log('📞 已向用户发起呼叫:', targetUserId.value);
} catch (err) {
console.error('❌ 发起呼叫失败:', err);
alert('发起呼叫失败,请检查摄像头/麦克风权限或网络环境');
}
};
</script>
<script setup>
// ... 继续 ...
// 收到对方的 call 消息后自动应答(简化逻辑,实际可加'是否接听'弹窗)
const answerCall = async (message) => {
// 记录对方 ID,后续发送 answer / ice 时需要用
targetUserId.value = message.from;
try {
// 1. 初始化 PeerConnection
await initPeerConnection();
// 2. 获取本地音视频流
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.value.srcObject = localStream;
localStream.getTracks().forEach((track) => {
peerConnection.addTrack(track, localStream);
});
// 3. 设置对方的 SDP offer 为远程描述
await peerConnection.setRemoteDescription(JSON.parse(message.data));
// 4. 创建 SDP answer
const answer = await peerConnection.createAnswer();
// 5. 设置本地 SDP
await peerConnection.setLocalDescription(answer);
// 6. 通过信令把 answer 发送回呼叫方
sendMessage({
type: 'answer',
from: userId.value,
to: targetUserId.value,
data: JSON.stringify(answer)
});
console.log('📞 已应答视频呼叫:', targetUserId.value);
} catch (err) {
console.error('❌ 应答呼叫失败:', err);
alert('应答呼叫失败!');
}
};
</script>
<script setup>
// ... 继续 ...
// 设置远程 SDP(无论是 Caller 还是 Callee,在收到 answer 或 offer 后都要调用)
const setRemoteSDP = async (sdpStr) => {
try {
const sdp = JSON.parse(sdpStr);
await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
console.log('✅ 设置远程 SDP 成功');
} catch (err) {
console.error('❌ 设置远程 SDP 失败:', err);
}
};
// 添加 ICE 候选
const addIceCandidate = async (iceStr) => {
try {
const ice = JSON.parse(iceStr);
await peerConnection.addIceCandidate(new RTCIceCandidate(ice));
console.log('✅ 添加 ICE 候选成功');
} catch (err) {
console.error('❌ 添加 ICE 候选失败:', err);
}
};
</script>
<script setup>
// ... 继续 ...
// 挂断通话
const hangUp = () => {
// 停止本地流
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
localVideo.value.srcObject = null;
localStream = null;
}
// 清空远程视频
if (remoteVideo.value) {
remoteVideo.value.srcObject = null;
}
// 关闭 PeerConnection
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
// 重置对方 ID
targetUserId.value = '';
console.log('📞 已挂断通话');
alert('已挂断通话!');
};
// 页面销毁时自动清理资源,防止摄像头被占用、连接泄漏
onUnmounted(() => {
if (socket) {
socket.close();
console.log('🔌 关闭 WebSocket 连接');
}
if (peerConnection) {
peerConnection.close();
console.log('🔌 关闭 PeerConnection');
}
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
console.log('🔇 停止本地音视频流');
}
});
</script>
到这里,前端完整逻辑就已经搭建完成。
8004 已监听)。user1,点击'连接服务器'。user2,点击'连接服务器'。user2,点击'发起视频呼叫'。可能原因:
8004。ws://localhost:8004/ws。常见错误:
setRemoteDescription called in wrong state:
setRemoteDescription 再 createAnswer。addIceCandidate failed:
参考答案:
参考答案:
iceServers(STUN 列表)让浏览器可以获取自己的公网候选地址,从而尝试建立 P2P 连接。参考答案:
createOffer() / createAnswer() 生成 SDP,并通过信令通道(本项目中是 WebSocket)在两个端之间交换。简单理解:
SDP 就是一张'合同',双方约定好'怎么说话、用什么语言、在什么频道说',然后再真正开始传输。
参考答案:
参考答案(思路型回答):
from / to 建立单对单信令。roomId,让服务器管理房间内成员列表,并进行广播式信令分发。n 人则最多有 n*(n-1)/2 条连接,浏览器负担较大。通过本篇文章与配套 Demo,你应该已经掌握:
接下来可以进一步扩展:
如果你把这套 Demo 完整跑通,并能向别人清楚讲解其中的每一个名词与步骤,那么在大多数初中级面试中,关于实时音视频 / WebRTC / Netty 长连接相关的问题,你已经可以比较自信地应对了。希望这篇文章能成为你学习 WebRTC + Java 全栈实时通讯的一个良好起点。 🎯

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online