一、实现方案
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() 重启协商
	}
};