一、实现方案
1.1 Web端
Web端使用html+JavaScript
实现,因为WebRTC
提供了一套完整的js
接口可以调用,我们只需要调用RTCPeerConnection
类就可以实现音视频通话,Web端主要实现以下部分:
- 界面设计
- 信令交互
- 媒体协商
- 网络协商
- 音视频流处理
1.2 服务器端
1.2.1 信令服务器
信令服务器这边我们使用C++
开发,与Web
端使用WebSocket
进行通讯,使用JSON
封装消息,因此在服务端我们引入WebSocketpp
和nlonlohmann
两个开源库,简化项目的实现难度,服务端实现以下功能:
- 维护一个通话房间,保持一对一通话逻辑
- 转发信令、媒体协商、网络协商消息
1.2.2 stun+turn服务器
stun服务器主要用于P2P
打洞,turn
服务器用于在打洞失败后的中继服务器,我们这里使用的是开源的服务器coturn
,内部集成了stun
和turn
服务器
1.3 测试环境
Web端使用的是微软的Edge
浏览器,两个服务器都搭建在虚拟机Ubuntu
上,局域网内通讯
二、信令设计
信令采取JSON
格式封装,主要实现下面8
个信令
-
join 加入房间
-
resp_join 当join房间后发现房间已经存在另一个人时则返回另一个人的uid;如果只有自己则不返回
-
leave 离开房间,服务器收到leave信令则检查同一房间是否有其他人,如果有其他人则通知他有人离开
-
new_peer 服务器通知客户端有新人加入,收到newpeer则发起连接请求
-
peer_leave 服务器通知客户端有人离开
-
offer 转发offer sdp
-
answer 转发answer sdp
-
candidate 转发candidate sdp
2.1 加入房间 join
当有人加入房间的时候发送向服务器发送这个信令
json
var jsonMsg = {
'cmd': 'join',
'roomId': roomId,
'uid': localUserId,
};
2.2 回复加入房间的人 resp-join
这个信令主要是服务器告知加入房间的那个人,房间里面另一个人的信息,如果房间里面只有自己,则不会发送:
json
jsonMsg = {
'cmd': 'resp‐join',
'remoteUid': remoteUid
};
2.3 告知在房间里的人 new_peer
这个信令和resp-join
不一样的是,它是告诉房间里面的人加入者的信息,即服务器发送加入者的信息给房间里面的人:
json
var jsonMsg = {
'cmd': 'new‐peer',
'remoteUid': uid
};
2.4 主动离开房间 leave
主动离开房间的时候,向服务器发送leave
信令,服务器告知另一个人他已经离开了,如果房间没人就不需要了:
json
var jsonMsg = {
'cmd': 'leave',
'roomId': roomId,
'uid': localUserId,
};
2.5 告知在房间里面的人 peer_leave
有人离开的时候,如果房间里面还有人,那么服务器就会发送这条信令到客户端,告知另一个人已经离开了:
json
var jsonMsg = {
'cmd': 'peer‐leave',
'remoteUid': uid
};
2.6 转发 offer
这个信令是客户端发送offer到服务端,服务端转发给对端的:
json
var jsonMsg = {
'cmd': 'offer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};
2.7 转发 answer
和offer
类似,服务端收到offer
,需要回复一个answer
,这个也是由服务器进行转发的:
json
var jsonMsg = {
'cmd': 'answer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};
2.8 转发 candidate
candidate
是进行网络协商,同样需要服务端转发,其中一端收集到candidate
之后,会通过服务器转发到客户端,协商后会将candidate
存在客户端
json
var jsonMsg = {
'cmd': 'candidate',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(candidateJson)
};
2.9 信令交互
完整的信令交互如下:

三、媒体协商
-
首先,呼叫方创建 Offer 类型的 SDP 消息。创建完成后,调用 setLocalDescriptoin 方法将该 Offer 保存到本地 Local 域,然后通过信令将 Offer 发送给被呼叫方。
-
被呼叫方收到 Offer 类型的 SDP 消息后,调用 setRemoteDescription 方法将 Offer 保存到它的 Remote 域。
-
作为应答,被呼叫方要创建 Answer 类型的 SDP 消息,Answer 消息创建成功后,再调用 setLocalDescription方法将 Answer 类型的 SDP 消息保存到本地的 Local 域。最后,被呼叫方将 Answer 消息通过信令发送给呼叫方。至此,被呼叫方的工作就完部完成了。
-
接下来是呼叫方的收尾工作,呼叫方收到 Answer 类型的消息后,调用 RTCPeerConnecton 对象的setRemoteDescription 方法,将 Answer 保存到它的 Remote 域。至此,整个媒体协商过程处理完毕。
-
当通讯双方拿到彼此的 SDP 信息后,就可以进行媒体协商了。媒体协商的具体过程是在 WebRTC 内部实现的,我们就不去细讲了,你只需要记住本地的 SDP 和远端的 SDP 都设置好后,协商就算成功了。

createOffer
-
作用:由发起方(通常是呼叫方)创建一个 SDP(会话描述协议)提议,用于描述本地媒体能力(如是否接收音视频等),以启动 WebRTC 连接协商。
-
基本格式 :
aPromise = myPeerConnection.createOffer([options]);
可选参数options
:
js
var options = {
offerToReceiveAudio: true, // 是否希望接收音频,默认true
offerToReceiveVideo: true, // 是否希望接收视频,默认true
iceRestart: false // 是否在连接活跃时重启ICE网络协商(仅活跃状态下false有效)
};
createAnswer
-
作用 :由接收方(通常是被叫方)根据收到的
Offer
创建应答 SDP,回应自身的媒体能力,完成协商过程。 -
基本格式 :
aPromise = RTCPeerConnection.createAnswer([options]);
-
注意 :目前
options
参数无效,无需传入配置。
setLocalDescription
-
作用 :设置本地的会话描述(本地生成的
Offer
或Answer
),使本地 PeerConnection 知晓自身的媒体配置。 -
基本格式 :
aPromise = RTCPeerConnection.setLocalDescription(sessionDescription);
(
sessionDescription
为createOffer
或createAnswer
返回的 SDP 对象)
setRemoteDescription
-
作用 :设置远程的会话描述(对方发送的
Offer
或Answer
),使本地 PeerConnection 知晓对方的媒体配置,从而完成双方能力匹配。 -
基本格式 :
aPromise = pc.setRemoteDescription(sessionDescription);
(
sessionDescription
为对方通过信令服务器传递的 SDP 对象)
addTrack
-
作用:向 PeerConnection 中添加本地媒体轨(音频 / 视频),使该媒体流能被传输到对端。
-
基本格式 :
rtpSender = rtcPeerConnection.addTrack(track, stream...);
-
参数说明:
track
:要添加的媒体轨(如getUserMedia
获取的音频轨audioTrack
或视频轨videoTrack
)。stream
:媒体轨所属的流(通常是getUserMedia
返回的MediaStream
对象)。
-
返回值 :
rtpSender
对象,用于控制媒体轨的发送过程。
四、网络协商
主要是使用addIceCandidate
方法:
-
addIceCandidate
是 WebRTC 中用于完成 ICE(Interactive Connectivity Establishment,交互式连接建立) 网络协商的核心方法。ICE 的作用是在复杂网络环境(如 NAT、防火墙后)中,帮助两个端点(Peer)发现彼此可达的网络路径(IP 地址 + 端口),最终建立直接的媒体传输通道。 -
当本地或远端生成 ICE 候选者(Candidate)时,需要通过信令服务器将候选者传递给对方,对方再通过
addIceCandidate
方法将其添加到本地的RTCPeerConnection
实例中,从而完成网络路径的验证与选择。
基本格式
js
aPromise = pc.addIceCandidate(candidate);
- 返回值:一个 Promise 对象,用于处理添加候选者的成功或失败(如候选者无效、超时等)。
- 参数
candidate
:ICE 候选者对象,包含网络路径的关键信息。
candidate
对象的属性
ICE 候选者由底层网络栈生成,包含以下关键属性(不同平台可能有细微差异):
属性 | 说明 |
---|---|
candidate |
候选者的核心描述字符串,包含传输协议(如 UDP/TCP)、IP 地址、端口、优先级等信息(格式遵循 SDP 规范)。 |
sdpMid |
媒体流标识标签,与 SDP 中 m= 行的媒体流(音频 / 视频)关联,用于区分候选者属于哪路媒体。 |
sdpMLineIndex |
数字索引,对应 SDP 中 m= 行的位置(如 0 表示第一路媒体,通常是音频;1 表示第二路,通常是视频),用于精准匹配媒体类型。 |
usernameFragment |
(可选)远端的唯一标识片段(ufrag),用于验证候选者的来源合法性,避免接收无效或恶意的候选者。 |
Android 与 Web 端的差异
ICE 候选者的处理逻辑在本质上一致,但由于平台 API 设计不同,存在以下细节差异:
Web 端(浏览器):
- 浏览器会自动生成
RTCIceCandidate
对象,通过信令服务器接收的候选者数据(通常是 JSON 格式)可直接用于构造对象:
javascript
// 从信令服务器接收的候选者数据(示例)
const candidateData = {
candidate: "candidate:1 1 UDP 2130706431 192.168.1.100 5678 typ host",
sdpMid: "audio",
sdpMLineIndex: 0
};
// 构造 ICE 候选者并添加
const iceCandidate = new RTCIceCandidate(candidateData);
pc.addIceCandidate(iceCandidate)
.then(() => console.log("候选者添加成功"))
.catch(err => console.error("添加失败:", err));
Android 端(WebRTC 原生 API):
- 需要通过
IceCandidate
类手动构造对象,注意参数类型匹配(如sdpMLineIndex
为 int 类型):
java
// 从信令服务器接收的候选者数据(示例)
String candidateStr = "candidate:1 1 UDP 2130706431 192.168.1.100 5678 typ host";
String sdpMid = "audio";
int sdpMLineIndex = 0;
// 构造 IceCandidate 对象
IceCandidate iceCandidate = new IceCandidate(sdpMid, sdpMLineIndex, candidateStr);
// 添加到 PeerConnection
peerConnection.addIceCandidate(iceCandidate);
注意:Android 端的 addIceCandidate
方法没有返回 Promise,而是通过 PeerConnection.Observer
的回调(如 onIceCandidateError
)处理结果。
显示远端媒体流
ICE 协商完成、媒体通道建立后,远端的音视频流会通过 RTCPeerConnection
传输到本地。本地需要监听流事件,获取远端媒体轨(Track),并通过 HTML 元素(如 <video>
、<audio>
)显示或播放。
-
监听媒体轨事件 :
远端媒体流传输时,本地
RTCPeerConnection
会触发track
事件(替代了旧版的addstream
事件,addstream
已废弃),事件中包含远端的媒体轨(MediaStreamTrack
)。 -
关联媒体流与元素 :
将获取到的媒体轨添加到
MediaStream
中,再将流绑定到<video>
或<audio>
元素的srcObject
属性,即可显示或播放。
Web端示例
html
<!-- 用于显示远端视频的元素 -->
<video id="remoteVideo" autoplay playsinline></video>
<script>
const pc = new RTCPeerConnection(config);
const remoteVideo = document.getElementById('remoteVideo');
// 监听远端媒体轨事件
pc.ontrack = (event) => {
// event.streams 可能包含远端的 MediaStream(若对方通过 addTrack 关联了流)
const remoteStream = event.streams[0];
if (remoteStream) {
// 将远端流绑定到 video 元素
remoteVideo.srcObject = remoteStream;
} else {
// 若没有关联流,手动创建流并添加轨道
const newStream = new MediaStream();
newStream.addTrack(event.track);
remoteVideo.srcObject = newStream;
}
};
</script>
五、RTCPeerConnection
构造函数
RTCPeerConnection
是 WebRTC 端到端连接的核心对象,用于管理媒体协商、ICE 网络连接、媒体流传输等全过程,构造函数可通过配置参数自定义连接行为。
基本语法
javascript
pc = new RTCPeerConnection([configuration]);
- 参数
configuration
:可选配置对象,用于自定义连接策略(如媒体传输方式、ICE 服务器等),缺省时使用浏览器默认配置。
核心配置项(configuration
)
配置项 | 含义与可选值 | 常用设置 | |
---|---|---|---|
bundlePolicy |
定义媒体轨(音频/视频)的传输通道绑定策略,影响传输效率(减少端口占用)。 - balanced :音频和视频轨使用各自的传输通道; - max-compat :每个媒体轨单独使用一个传输通道(兼容性优先,效率低); - max-bundle :所有媒体轨绑定到同一个传输通道(效率最高,现代浏览器推荐)。 |
max-bundle |
|
iceTransportPolicy |
指定 ICE 候选者的筛选策略,控制网络路径选择。 - relay :仅使用中继候选者(依赖 TURN 服务器,适合严格防火墙环境); - all :允许使用所有类型的候选者(包括本地局域网、公网直连、中继,兼容性最好)。 |
all |
|
iceServers |
由 RTCIceServer 对象组成的数组,指定 ICE 代理服务器(STUN/TURN),用于穿透 NAT、防火墙。 每个 RTCIceServer 包含以下属性: - urls :服务器地址数组(如 stun:stun.example.org 或 turn:turn.example.org:3478 ); - username :TURN 服务器的认证用户名(STUN 无需); - credential :TURN 服务器的认证凭据(STUN 无需); - credentialType :凭据类型("password" 或 "oauth" ,默认 password )。 |
示例: [{ urls: "stun:stun.l.google.com:19302" }] (公共 STUN 服务器) |
|
rtcpMuxPolicy |
定义 RTCP(实时传输控制协议)与 RTP(实时传输协议)的复用策略(RTCP 用于监控媒体传输质量)。 - negotiate :尝试复用,若无法复用则分开传输; - require :强制复用,若无法复用则连接失败(减少端口占用,推荐)。 |
require |
配置示例
javascript
// 典型的 RTCPeerConnection 配置
const configuration = {
bundlePolicy: "max-bundle",
iceTransportPolicy: "all",
iceServers: [
{ urls: "stun:stun.l.google.com:19302" }, // 公共 STUN 服务器(用于获取公网地址)
{
urls: "turn:turn.example.com:3478",
username: "user123",
credential: "pass456"
} // TURN 服务器(用于中继,当直连失败时)
],
rtcpMuxPolicy: "require"
};
const pc = new RTCPeerConnection(configuration);
重要事件
RTCPeerConnection
通过事件机制反馈连接状态、媒体流变化等关键信息,是实现实时通信逻辑的核心依据。
1. onicecandidate
-
触发时机 :本地 ICE 框架生成新的 ICE 候选者(网络路径信息)时触发;当所有候选者收集完成后,会触发一次
candidate
为null
的事件。 -
作用:获取本地候选者并通过信令服务器发送给对端,用于完成 ICE 网络协商。
-
示例:
javascriptpc.onicecandidate = (event) => { if (event.candidate) { // 发送候选者给对端(通过信令服务器) signalingServer.send({ type: "candidate", candidate: event.candidate }); } else { // 所有候选者收集完成 console.log("ICE 候选者收集完毕"); } };
2. ontrack
-
触发时机 :远端通过
addTrack
添加的媒体轨(音频/视频)传输到本地时触发(替代了旧版的onaddstream
事件,onaddstream
已废弃)。 -
作用:获取远端媒体轨,用于显示或播放远端音视频。
-
事件对象属性:
track
:远端媒体轨(MediaStreamTrack
实例,如音频轨AudioTrack
或视频轨VideoTrack
);streams
:远端媒体轨所属的MediaStream
数组(若对端关联了流)。
-
示例:
javascript
const remoteVideo = document.getElementById("remoteVideo");
pc.ontrack = (event) => {
const [remoteStream] = event.streams;
// 将远端流绑定到 video 元素播放
if (remoteStream) {
remoteVideo.srcObject = remoteStream;
} else {
// 若对端未关联流,手动创建流并添加轨道
const newStream = new MediaStream();
newStream.addTrack(event.track);
remoteVideo.srcObject = newStream;
}
};
3. onconnectionstatechange
-
触发时机 :
RTCPeerConnection
的整体连接状态变化时触发(反映端到端连接的最终状态)。 -
状态值(
pc.connectionState
):new
:初始状态,连接尚未建立;connecting
:正在建立连接(SDP 协商或 ICE 连接中);connected
:连接已完全建立,媒体可正常传输;disconnected
:连接中断(如网络临时故障,可能恢复);failed
:连接失败(无法恢复,需重新创建连接);closed
:连接已被主动关闭(调用pc.close()
后)。
-
示例:
javascript
pc.onconnectionstatechange = (event) => {
switch (pc.connectionState) {
case "connected":
console.log("连接已建立,可传输媒体");
break;
case "disconnected":
console.warn("连接中断,尝试重连...");
break;
case "failed":
console.error("连接失败,需重新初始化");
// 处理失败逻辑(如重新创建 RTCPeerConnection)
break;
case "closed":
console.log("连接已关闭");
break;
}
};
4. oniceconnectionstatechange
-
触发时机 :ICE 连接状态变化时触发(专注于网络层连接状态,比
connectionState
更细致)。 -
状态值(
pc.iceConnectionState
):new
:ICE 开始初始化;checking
:正在检查本地与远端的候选者,尝试建立连接;connected
:ICE 已找到可用路径,媒体开始传输(但可能仍在收集更多候选者);completed
:ICE 候选者收集完成,且已确定最佳路径;failed
:ICE 无法找到可用路径(需检查 ICE 服务器配置);disconnected
:ICE 连接中断(如网络变化);closed
:ICE 连接已关闭。
-
示例:
javascript
pc.oniceconnectionstatechange = (event) => {
console.log("ICE 连接状态:", pc.iceConnectionState);
if (pc.iceConnectionState === "failed") {
console.error("ICE 连接失败,可能需要重启 ICE 协商");
// 可尝试调用 pc.restartIce() 重启协商
}
};