一、实现方案
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
当有人加入房间的时候发送向服务器发送这个信令
var jsonMsg = {
'cmd': 'join',
'roomId': roomId,
'uid': localUserId,
};
2.2 回复加入房间的人 resp-join
这个信令主要是服务器告知加入房间的那个人,房间里面另一个人的信息,如果房间里面只有自己,则不会发送:
jsonMsg = {
'cmd': 'resp‐join',
'remoteUid': remoteUid
};
2.3 告知在房间里的人 new_peer
这个信令和resp-join
不一样的是,它是告诉房间里面的人加入者的信息,即服务器发送加入者的信息给房间里面的人:
var jsonMsg = {
'cmd': 'new‐peer',
'remoteUid': uid
};
2.4 主动离开房间 leave
主动离开房间的时候,向服务器发送leave
信令,服务器告知另一个人他已经离开了,如果房间没人就不需要了:
var jsonMsg = {
'cmd': 'leave',
'roomId': roomId,
'uid': localUserId,
};
2.5 告知在房间里面的人 peer_leave
有人离开的时候,如果房间里面还有人,那么服务器就会发送这条信令到客户端,告知另一个人已经离开了:
var jsonMsg = {
'cmd': 'peer‐leave',
'remoteUid': uid
};
2.6 转发 offer
这个信令是客户端发送offer到服务端,服务端转发给对端的:
var jsonMsg = {
'cmd': 'offer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};
2.7 转发 answer
和offer
类似,服务端收到offer
,需要回复一个answer
,这个也是由服务器进行转发的:
var jsonMsg = {
'cmd': 'answer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};
2.8 转发 candidate
candidate
是进行网络协商,同样需要服务端转发,其中一端收集到candidate
之后,会通过服务器转发到客户端,协商后会将candidate
存在客户端
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
:
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
实例中,从而完成网络路径的验证与选择。
基本格式
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 格式)可直接用于构造对象:
// 从信令服务器接收的候选者数据(示例)
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 类型):
// 从信令服务器接收的候选者数据(示例)
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端示例
<!-- 用于显示远端视频的元素 -->
<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 网络连接、媒体流传输等全过程,构造函数可通过配置参数自定义连接行为。
基本语法
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 |
配置示例
// 典型的 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 网络协商。
示例:
pc.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
数组(若对端关联了流)。
示例:
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()
后)。
示例:
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 连接已关闭。
示例:
pc.oniceconnectionstatechange = (event) => {
console.log("ICE 连接状态:", pc.iceConnectionState);
if (pc.iceConnectionState === "failed") {
console.error("ICE 连接失败,可能需要重启 ICE 协商");
// 可尝试调用 pc.restartIce() 重启协商
}
};