信令与视频通话

WebRTC 允许两个设备之间进行实时的点对点媒体交换。连接通过一个称为信令的发现和协商过程建立。本教程将指导你构建一个双向视频通话。

WebRTC 是一种完全点对点技术,用于实时交换音频、视频和数据,但有一个核心注意事项。正如其他地方讨论的,为了让不同网络上的两个设备相互定位,必须进行某种形式的发现和媒体格式协商。这个过程称为信令,涉及两个设备连接到一个第三方、双方都同意的服务器。通过这个第三方服务器,两个设备可以相互定位并交换协商消息。

在本文中,我们将进一步增强以支持用户之间开启双向视频通话。你可以在 Render 上试用此示例,以便进行实验。你也可以在 GitHub 上查看完整项目

信令服务器

在两个设备之间建立 WebRTC 连接需要使用信令服务器来解决如何通过互联网连接它们。信令服务器的任务是充当中间人,让两个对等端在尽可能减少潜在私人信息暴露的情况下找到并建立连接。我们如何创建这个服务器,以及信令过程实际上是如何工作的?

首先,我们需要信令服务器本身。WebRTC 没有指定信令信息的传输机制。你可以使用任何你喜欢的方式,从 WebSocketfetch(),甚至信鸽,来交换两个对等端之间的信令信息。

重要的是要记住,服务器不需要理解或解释信令数据内容。尽管它是 SDP,但这并不那么重要:通过信令服务器的消息内容实际上是一个黑盒。重要的是当 ICE 子系统指示你向另一个对等端发送信令数据时,你照做,并且另一个对等端知道如何接收此信息并将其传递给其自己的 ICE 子系统。你所要做的就是来回传递信息。内容对信令服务器来说根本不重要。

准备聊天服务器以进行信令

我们的 聊天服务器 使用 WebSocket API 在每个客户端和服务器之间以 JSON 字符串形式发送信息。服务器支持多种消息类型来处理任务,例如注册新用户、设置用户名和发送公共聊天消息。

为了让服务器支持信令与 ICE 协商,我们需要更新代码。我们必须允许将消息定向到特定的一个用户,而不是广播给所有连接的用户,并确保未识别的消息类型被传递和送达,而无需服务器了解它们是什么。这使我们能够使用相同的服务器发送信令消息,而不是需要一个单独的服务器。

让我们看看我们需要对聊天服务器进行哪些更改以支持 WebRTC 信令。这在文件 chatserver.js 中。

首先是添加函数 sendToOneUser()。顾名思义,它向特定用户名发送一个字符串化的 JSON 消息。

js
function sendToOneUser(target, msgString) {
  connectionArray.find((conn) => conn.username === target).send(msgString);
}

此函数遍历连接用户列表,直到找到与指定用户名匹配的用户,然后向该用户发送消息。参数 msgString 是一个字符串化的 JSON 对象。我们可以让它接收我们的原始消息对象,但在这个示例中,这种方式更高效。由于消息已经字符串化,我们可以直接发送它,无需进一步处理。connectionArray 中的每个条目都是一个 WebSocket 对象,所以我们可以直接调用其 send() 方法。

我们最初的聊天演示不支持向特定用户发送消息。下一个任务是更新主 WebSocket 消息处理程序以支持此功能。这涉及 "connection" 消息处理程序末尾附近的更改。

js
if (sendToClients) {
  const msgString = JSON.stringify(msg);

  if (msg.target && msg.target.length !== 0) {
    sendToOneUser(msg.target, msgString);
  } else {
    for (const connection of connectionArray) {
      connection.send(msgString);
    }
  }
}

此代码现在查看待处理消息,以检查它是否具有 target 属性。如果该属性存在,它会指定要发送消息的客户端的用户名,然后我们调用 sendToOneUser() 将消息发送给他们。否则,通过迭代连接列表,将消息广播给所有用户,并向每个用户发送消息。

由于现有代码允许发送任意消息类型,因此不需要额外更改。我们的客户端现在可以将未知类型的消息发送给任何特定用户,让他们可以根据需要来回发送信令消息。

这就是我们在服务器端需要更改的所有内容。现在让我们考虑我们将要实现的信令协议。

设计信令协议

现在我们已经建立了消息交换机制,我们需要一个协议来定义这些消息的外观。这可以通过多种方式完成;此处展示的只是构建信令消息的一种可能方式。

此示例的服务器使用字符串化的 JSON 对象与其客户端通信。这意味着我们的信令消息将采用 JSON 格式,其内容指定消息类型以及处理消息所需的任何附加信息。

交换会话描述

在开始信令过程时,由发起呼叫的用户创建一个提议。此提议包含一个 SDP 格式的会话描述,需要传递给接收用户,我们称之为被叫方。被叫方以应答消息响应提议,其中也包含一个 SDP 描述。我们的信令服务器将使用 WebSocket 传输类型为 "video-offer" 的提议消息和类型为 "video-answer" 的应答消息。这些消息具有以下字段:

type

消息类型;可以是 "video-offer""video-answer"

name

发送者的用户名。

目标

接收描述的人的用户名(如果呼叫方正在发送消息,则此项指定被叫方,反之亦然)。

SDP

SDP(会话描述协议)字符串,从发送者(或接收者视角下的连接远端)的角度描述连接的本地端。

此时,两位参与者知道此次通话将使用哪些编解码器编解码器参数。但他们仍然不知道如何传输媒体数据本身。这就是交互式连接建立 (ICE) 的用武之地。

交换 ICE 候选者

两个对等端需要交换 ICE 候选者以协商它们之间的实际连接。每个 ICE 候选者描述了发送对等端能够使用的通信方法。每个对等端都按照发现的顺序发送候选者,并持续发送候选者直到没有更多建议,即使媒体已经开始流式传输。

当使用 pc.setLocalDescription(offer) 完成添加本地描述的过程时,会向 RTCPeerConnection 发送一个 icecandidate 事件。

一旦两个对等端就一个相互兼容的候选者达成一致,每个对等端就会使用该候选者的 SDP 来构建和打开连接,然后媒体数据就开始流动。如果他们稍后就一个更好的(通常性能更高)候选者达成一致,则流可能会根据需要更改格式。

虽然目前不支持,但理论上,在媒体已经流动之后收到的候选者也可以用于在需要时降级到较低带宽的连接。

每个 ICE 候选者都通过信令服务器向远程对等端发送类型为 "new-ice-candidate" 的 JSON 消息来发送给另一个对等端。每个候选者消息包含以下字段:

type

消息类型:"new-ice-candidate"

目标

正在协商的人的用户名;服务器将只将消息定向到此用户。

候选者

SDP 候选字符串,描述了提议的连接方法。你通常不需要查看此字符串的内容。你的所有代码只需通过信令服务器将其路由到远程对等端。

每条 ICE 消息都建议一个通信协议(TCP 或 UDP)、IP 地址、端口号、连接类型(例如,指定的 IP 是对等端本身还是中继服务器),以及连接两台计算机所需的其他信息。这包括 NAT 或其他网络复杂性。

注意:需要注意的重要事项是:在 ICE 协商过程中,你的代码唯一需要负责的是,当你的 onicecandidate 处理程序执行时,接收 ICE 层发出的出站候选者,并通过信令连接将它们发送给另一个对等端;以及从信令服务器接收 ICE 候选者消息(当收到 "new-ice-candidate" 消息时),并通过调用 RTCPeerConnection.addIceCandidate() 将它们传递给你的 ICE 层。仅此而已。

在几乎所有情况下,SDP 的内容都与你无关。避免尝试将其变得比这更复杂的诱惑,直到你真正知道自己在做什么。那条路上充满了疯狂。

现在,你的信令服务器只需要发送它被要求发送的消息。你的工作流程可能还需要登录/认证功能,但这些细节会有所不同。

注意:onicecandidate 事件和 createAnswer() Promise 都是异步调用,它们是单独处理的。请确保你的信令不会改变顺序!例如,必须在通过 setRemoteDescription() 设置应答之后调用 addIceCandidate() 来添加服务器的 ice 候选者。

信令事务流程

信令过程涉及两个对等端之间使用中介(信令服务器)进行消息交换。确切的过程当然会因情况而异,但通常有几个关键点会处理信令消息:

  • 每个用户在其网络浏览器中运行的客户端
  • 每个用户的网络浏览器
  • 信令服务器
  • 托管聊天服务的 Web 服务器

假设 Naomi 和 Priya 正在使用聊天软件进行讨论,Naomi 决定开启两人之间的视频通话。以下是预期的事件序列:

Diagram of the signaling process

我们将在本文中更详细地介绍这一点。

ICE 候选者交换过程

当每个对等端的 ICE 层开始发送候选者时,它会进入一个链中各个点之间的交换,看起来像这样:

Diagram of ICE candidate exchange process

每一方都会在从本地 ICE 层接收到候选者后立即将其发送给对方;没有轮流或批量处理候选者。一旦双方就一个可用于交换媒体的候选者达成一致,媒体就会开始流动。即使媒体已经开始流动,每一方仍会继续发送候选者,直到其耗尽所有选项。这样做是为了希望能找到比最初选择的更好的选项。

如果条件发生变化(例如,网络连接恶化),一个或两个对等端可能会建议切换到较低带宽的媒体分辨率,或切换到替代编解码器。这会触发新的候选者交换,之后可能会发生另一种媒体格式和/或编解码器更改。在WebRTC 使用的编解码器指南中,你可以了解更多关于 WebRTC 要求浏览器支持的编解码器、哪些浏览器支持哪些附加编解码器以及如何选择最佳编解码器的信息。

另外,如果想更深入了解 ICE 层内部此过程的完成方式,可选地请参阅 RFC 8445: Interactive Connectivity Establishment第 2.3 节(“协商候选对并结束 ICE”)。你应注意,一旦 ICE 层满意,候选者就会进行交换,媒体就会开始流动。这一切都在幕后完成。我们的角色是,通过信令服务器,来回发送候选者。

客户端应用程序

任何信令过程的核心是其消息处理。信令不一定需要使用 WebSocket,但它是一种常见的解决方案。当然,你应该选择适合你的应用程序的信令信息交换机制。

让我们更新聊天客户端以支持视频通话。

更新 HTML

我们客户端的 HTML 需要一个用于显示视频的位置。这需要视频元素和一个挂断电话的按钮。

html
<div class="flexChild" id="camera-container">
  <div class="camera-box">
    <video id="received_video" autoplay></video>
    <video id="local_video" autoplay muted></video>
    <button id="hangup-button" disabled>Hang Up</button>
  </div>
</div>
js
document.getElementById("hangup-button").addEventListener("click", hangUpCall);

这里定义的页面结构使用 <div> 元素,通过启用 CSS 的使用,使我们能够完全控制页面布局。我们将在本指南中跳过布局细节,但请查看 GitHub 上的 CSS,了解我们是如何处理的。请注意两个 <video> 元素,一个用于你的自视图,一个用于连接,以及 <button> 元素。

带有 idreceived_video<video> 元素将显示从已连接用户接收到的视频。我们指定了 autoplay 属性,确保视频一旦开始传输,就会立即播放。这消除了在代码中显式处理播放的需要。local_video<video> 元素显示用户摄像头的预览;我们指定了 muted 属性,因为我们不需要在此预览面板中听到本地音频。

最后,用于断开呼叫的 hangup-button <button> 被定义并配置为开始时禁用(将其设置为未连接呼叫时的默认值),并在点击时应用 hangUpCall() 函数。此函数的作用是关闭呼叫,并向其他对等方发送信令服务器通知,请求其也关闭。

JavaScript 代码

我们将此代码划分为功能区域,以便更轻松地描述其工作原理。此代码的主体位于 connect() 函数中:它在端口 6503 上打开一个 WebSocket 服务器,并建立一个处理程序来接收 JSON 对象格式的消息。此代码通常像以前一样处理文本聊天消息。

向信令服务器发送消息

在我们的代码中,我们调用 sendToServer() 以向信令服务器发送消息。此函数使用 WebSocket 连接来完成其工作:

js
function sendToServer(msg) {
  const msgJSON = JSON.stringify(msg);

  connection.send(msgJSON);
}

传入此函数的消息对象通过调用 JSON.stringify() 转换为 JSON 字符串,然后我们调用 WebSocket 连接的 send() 函数将消息传输到服务器。

用于发起呼叫的 UI

处理 "user-list" 消息的代码会调用 handleUserListMsg()。在这里,我们为聊天面板左侧显示的用户列表中的每个连接用户设置处理程序。此函数接收一个消息对象,其 users 属性是一个字符串数组,指定每个已连接用户的用户名。

js
function handleUserListMsg(msg) {
  const listElem = document.querySelector(".user-list-box");

  while (listElem.firstChild) {
    listElem.removeChild(listElem.firstChild);
  }

  msg.users.forEach((username) => {
    const item = document.createElement("li");
    item.appendChild(document.createTextNode(username));
    item.addEventListener("click", invite);

    listElem.appendChild(item);
  });
}

在获取到包含用户名列表的 <ul> 元素的引用并将其存储到变量 listElem 后,我们通过移除其每个子元素来清空列表。

注意:显然,通过添加和删除单个用户而不是每次更改时重建整个列表来更新列表会更高效,但这对于本示例的目的来说已经足够了。

然后,我们使用 forEach() 遍历用户名数组。对于每个名称,我们创建一个新的 <li> 元素,然后使用 createTextNode() 创建一个包含用户名的文本节点。该文本节点作为 <li> 元素的子元素添加。接下来,我们为列表项上的 click 事件设置一个处理程序,即点击用户名会调用我们的 invite() 方法,我们将在下一节中查看该方法。

最后,我们将新项附加到包含所有用户名的 <ul> 中。

开始通话

当用户点击他们想要呼叫的用户名时,invite() 函数作为该 click 事件的事件处理程序被调用。

js
const mediaConstraints = {
  audio: true, // We want an audio track
  video: true, // And we want a video track
};

function invite(evt) {
  if (myPeerConnection) {
    alert("You can't start a call because you already have one open!");
  } else {
    const clickedUsername = evt.target.textContent;

    if (clickedUsername === myUsername) {
      alert(
        "I'm afraid I can't let you talk to yourself. That would be weird.",
      );
      return;
    }

    targetUsername = clickedUsername;
    createPeerConnection();

    navigator.mediaDevices
      .getUserMedia(mediaConstraints)
      .then((localStream) => {
        document.getElementById("local_video").srcObject = localStream;
        localStream
          .getTracks()
          .forEach((track) => myPeerConnection.addTrack(track, localStream));
      })
      .catch(handleGetUserMediaError);
  }
}

这首先进行了一个基本的健全性检查:用户是否已经连接?如果已经有一个 RTCPeerConnection,他们显然无法进行呼叫。然后,从事件目标的 textContent 属性中获取被点击用户的名称,我们检查以确保它不是正在尝试发起呼叫的同一个用户。

然后,我们将要呼叫的用户的姓名复制到变量 targetUsername 中,并调用 createPeerConnection() 函数,该函数将创建并进行 RTCPeerConnection 的基本配置。

一旦创建了 RTCPeerConnection,我们通过调用 MediaDevices.getUserMedia() 请求访问用户的摄像头和麦克风,该方法通过 MediaDevices.getUserMedia 属性暴露给我们。当此操作成功,兑现返回的 Promise 时,我们的 then 处理程序将执行。它接收一个 MediaStream 对象作为输入,该对象表示包含用户麦克风音频和网络摄像头视频的流。

注意:我们可以通过调用 navigator.mediaDevices.enumerateDevices() 获取设备列表,然后根据我们所需的条件过滤结果列表,最后在传递给 getUserMedia()mediaConstraints 对象的 deviceId 字段中使用所选设备的 deviceId 值,从而限制允许的媒体输入设备集。在实践中,这很少甚至从不需要,因为 getUserMedia() 会为你完成大部分工作。

我们通过设置元素的 srcObject 属性,将传入流附加到本地预览 <video> 元素。由于元素配置为自动播放传入视频,因此流开始在我们的本地预览框中播放。

然后,我们迭代流中的轨道,调用 addTrack() 将每个轨道添加到 RTCPeerConnection。即使连接尚未完全建立,你也可以在认为合适的时候开始发送数据。在 ICE 协商完成之前接收到的媒体可用于帮助 ICE 决定采用最佳连接方法,从而有助于协商过程。

请注意,对于原生应用程序,例如手机应用程序,你至少应在两端都接受连接后才开始发送,以避免在用户尚未准备好时无意中发送视频和/或音频数据。

一旦媒体附加到 RTCPeerConnection,连接处就会触发一个 negotiationneeded 事件,以便开始 ICE 协商。

如果在尝试获取本地媒体流时发生错误,我们的 catch 子句会调用 handleGetUserMediaError(),该函数会根据需要向用户显示适当的错误。

处理 getUserMedia() 错误

如果 getUserMedia() 返回的 Promise 以失败告终,我们的 handleGetUserMediaError() 函数将执行。

js
function handleGetUserMediaError(e) {
  switch (e.name) {
    case "NotFoundError":
      alert(
        "Unable to open your call because no camera and/or microphone" +
          "were found.",
      );
      break;
    case "SecurityError":
    case "PermissionDeniedError":
      // Do nothing; this is the same as the user canceling the call.
      break;
    default:
      alert(`Error opening your camera and/or microphone: ${e.message}`);
      break;
  }

  closeVideoCall();
}

除了一个例外,所有情况下都会显示错误消息。在此示例中,我们忽略 "SecurityError""PermissionDeniedError" 结果,将拒绝授予使用媒体硬件权限与用户取消呼叫视为相同。

无论尝试获取流失败的原因是什么,我们都会调用 closeVideoCall() 函数来关闭 RTCPeerConnection,并释放尝试呼叫过程中已分配的任何资源。此代码旨在安全地处理部分启动的呼叫。

创建对等连接

createPeerConnection() 函数由呼叫方和被呼叫方用于构建他们的 RTCPeerConnection 对象,即 WebRTC 连接的各自端点。它在呼叫方尝试发起呼叫时由 invite() 调用,并在被呼叫方收到呼叫方发出的提议消息时由 handleVideoOfferMsg() 调用。

js
function createPeerConnection() {
  myPeerConnection = new RTCPeerConnection({
    iceServers: [
      // Information about ICE servers - Use your own!
      {
        urls: "stun:stun.stunprotocol.org",
      },
    ],
  });

  myPeerConnection.onicecandidate = handleICECandidateEvent;
  myPeerConnection.ontrack = handleTrackEvent;
  myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent;
  myPeerConnection.onremovetrack = handleRemoveTrackEvent;
  myPeerConnection.oniceconnectionstatechange =
    handleICEConnectionStateChangeEvent;
  myPeerConnection.onicegatheringstatechange =
    handleICEGatheringStateChangeEvent;
  myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent;
}

在使用 RTCPeerConnection() 构造函数时,我们将指定一个对象,提供连接的配置参数。在此示例中,我们只使用其中一个:iceServers。这是一个描述 STUN 和/或 TURN 服务器的对象数组,供 ICE 层在尝试在呼叫方和被呼叫方之间建立路由时使用。这些服务器用于确定在对等端之间通信时使用的最佳路由和协议,即使它们位于防火墙之后或使用 NAT

注意:你应始终使用你拥有的或已获得特定授权的 STUN/TURN 服务器。此示例使用了一个已知的公共 STUN 服务器,但滥用这些服务器是不好的做法。

iceServers 中的每个对象至少包含一个 urls 字段,提供可访问指定服务器的 URL。如果需要,它还可以提供 usernamecredential 值以进行身份验证。

创建 RTCPeerConnection 后,我们为对我们重要的事件设置处理程序。

其中前三个事件处理程序是必需的;你必须处理它们才能使用 WebRTC 进行任何涉及流媒体的操作。其余的并非严格要求,但可能有用,我们将对此进行探讨。此示例中还有一些其他可用事件我们未使用。以下是我们将要实现的每个事件处理程序的摘要:

onicecandidate

当本地 ICE 层需要你通过信令服务器向其他对等端传输 ICE 候选者时,它会调用你的 icecandidate 事件处理程序。有关更多信息和查看此示例的代码,请参阅发送 ICE 候选者

onTrack

当轨道被添加到连接时,本地 WebRTC 层会调用 track 事件的此处理程序。这允许你将传入媒体连接到元素以显示它,例如。有关详细信息,请参阅接收新流

onnegotiationneeded

每当 WebRTC 基础设施需要你重新启动会话协商过程时,就会调用此函数。它的作用是创建并向被呼叫方发送提议,请求其与我们连接。请参阅开始协商以了解我们如何处理此问题。

onremovetrack

这个 ontrack 的对应项被调用来处理 removetrack 事件;当远程对等方从正在发送的媒体中移除轨道时,它被发送到 RTCPeerConnection。请参阅处理轨道的移除

oniceconnectionstatechange

ICE 层发送 iceconnectionstatechange 事件,以通知你 ICE 连接状态的变化。这可以帮助你了解连接何时失败或丢失。我们将在下面的ICE 连接状态中查看此示例的代码。

onicegatheringstatechange

当 ICE 代理收集候选者的过程从一种状态变为另一种状态(例如开始收集候选者或完成协商)时,ICE 层会向你发送 icegatheringstatechange 事件。请参阅下面的ICE 收集状态

onsignalingstatechange

当信令进程状态改变(或与信令服务器的连接改变)时,WebRTC 基础设施会向你发送 signalingstatechange 消息。请参阅信令状态以查看我们的代码。

开始协商

一旦呼叫方创建了其 RTCPeerConnection,创建了媒体流,并如开始呼叫中所示将其轨道添加到连接中,浏览器将向 RTCPeerConnection 传递一个 negotiationneeded 事件,表示它已准备好开始与另一对等端进行协商。以下是我们处理 negotiationneeded 事件的代码:

js
function handleNegotiationNeededEvent() {
  myPeerConnection
    .createOffer()
    .then((offer) => myPeerConnection.setLocalDescription(offer))
    .then(() => {
      sendToServer({
        name: myUsername,
        target: targetUsername,
        type: "video-offer",
        sdp: myPeerConnection.localDescription,
      });
    })
    .catch(window.reportError);
}

要开始协商过程,我们需要创建并向我们要连接的对等端发送一个 SDP 提议。此提议包括连接支持的配置列表,包括我们已添加到本地连接的媒体流(即,我们希望发送到呼叫另一端的视频)的信息,以及 ICE 层已收集的任何 ICE 候选者。我们通过调用 myPeerConnection.createOffer() 来创建此提议。

createOffer() 成功(兑现 Promise)时,我们将创建的提议信息传递给 myPeerConnection.setLocalDescription(),这会配置连接和媒体配置状态,用于呼叫方的连接端。

注意:从技术上讲,createOffer() 返回的字符串是一个 RFC 3264 提议。

setLocalDescription() 返回的 Promise 被兑现时,我们知道描述是有效的并已设置。此时,我们通过创建一个包含本地描述(现在与提议相同)的新 "video-offer" 消息,然后通过我们的信令服务器将其发送给被叫方,从而将我们的提议发送给其他对等方。该提议包含以下成员:

type

消息类型:"video-offer"

name

呼叫者的用户名。

目标

我们希望呼叫的用户名称。

SDP

描述提议的 SDP 字符串。

如果发生错误,无论是在最初的 createOffer() 中还是在后续的任何 fulfillment handler 中,都会通过调用我们的 window.reportError() 函数报告错误。

一旦 setLocalDescription() 的 fulfillment handler 运行完毕,ICE 代理就开始向 RTCPeerConnection 发送 icecandidate 事件,每发现一个潜在配置就发送一个。我们负责处理 icecandidate 事件的处理程序,用于将候选者传输到另一个对等端。

会话协商

现在我们已经与另一个对等端开始了协商并传输了一个提议,让我们来看看被呼叫方连接端会发生什么。被呼叫方收到提议并调用 handleVideoOfferMsg() 函数来处理它。让我们看看被呼叫方如何处理 "video-offer" 消息。

处理邀请

当提议到达时,被呼叫方的 handleVideoOfferMsg() 函数会被调用,并传入收到的 "video-offer" 消息。此函数需要完成两件事。首先,它需要创建自己的 RTCPeerConnection,并将包含其麦克风和网络摄像头音频和视频的轨道添加到其中。其次,它需要处理收到的提议,构建并发送其应答。

js
function handleVideoOfferMsg(msg) {
  let localStream = null;

  targetUsername = msg.name;
  createPeerConnection();

  const desc = new RTCSessionDescription(msg.sdp);

  myPeerConnection
    .setRemoteDescription(desc)
    .then(() => navigator.mediaDevices.getUserMedia(mediaConstraints))
    .then((stream) => {
      localStream = stream;
      document.getElementById("local_video").srcObject = localStream;

      localStream
        .getTracks()
        .forEach((track) => myPeerConnection.addTrack(track, localStream));
    })
    .then(() => myPeerConnection.createAnswer())
    .then((answer) => myPeerConnection.setLocalDescription(answer))
    .then(() => {
      const msg = {
        name: myUsername,
        target: targetUsername,
        type: "video-answer",
        sdp: myPeerConnection.localDescription,
      };

      sendToServer(msg);
    })
    .catch(handleGetUserMediaError);
}

此代码与我们在 开始呼叫invite() 函数所做的非常相似。它首先使用我们的 createPeerConnection() 函数创建并配置一个 RTCPeerConnection。然后它从收到的 "video-offer" 消息中获取 SDP 提议,并使用它创建一个新的 RTCSessionDescription 对象,表示呼叫方的会话描述。

然后将该会话描述传递给 myPeerConnection.setRemoteDescription()。这会将收到的提议建立为连接远程(呼叫方)端的描述。如果此操作成功,Promise 实现处理程序(在 then() 子句中)将启动获取被呼叫方摄像头和麦克风访问权限的过程,使用 getUserMedia(),将轨道添加到连接中,等等,正如我们之前在 invite() 中看到的那样。

一旦使用 myPeerConnection.createAnswer() 创建了应答,连接本地端的描述将通过调用 myPeerConnection.setLocalDescription() 设置为应答的 SDP,然后应答通过信令服务器传输给呼叫方,告知他们应答内容。

任何错误都会被捕获并传递给 handleGetUserMediaError(),如处理 getUserMedia() 错误中所述。

注意:与呼叫方一样,一旦 setLocalDescription() 的 fulfillment handler 运行完毕,浏览器就会开始触发 icecandidate 事件,被呼叫方必须处理这些事件,每个需要传输到远程对等端的候选者都有一个。

最后,呼叫方通过创建一个新的 RTCSessionDescription 对象来处理收到的应答消息,该对象表示被呼叫方的会话描述,并将其传递给 myPeerConnection.setRemoteDescription()

js
function handleVideoAnswerMsg(msg) {
  const desc = new RTCSessionDescription(msg.sdp);
  myPeerConnection.setRemoteDescription(desc).catch(window.reportError);
}
发送 ICE 候选者

ICE 协商过程涉及每个对等方反复向另一个对等方发送候选者,直到其耗尽可能支持 RTCPeerConnection 媒体传输需求的潜在方法。由于 ICE 不了解你的信令服务器,因此你的代码会在 icecandidate 事件的处理程序中处理每个候选者的传输。

你的 onicecandidate 处理程序接收一个事件,其 candidate 属性是描述候选者的 SDP(或为 null 表示 ICE 层已耗尽所有潜在配置建议)。candidate 的内容是你需要使用信令服务器传输的内容。以下是我们的示例实现:

js
function handleICECandidateEvent(event) {
  if (event.candidate) {
    sendToServer({
      type: "new-ice-candidate",
      target: targetUsername,
      candidate: event.candidate,
    });
  }
}

这会构建一个包含候选者的对象,然后使用向信令服务器发送消息中先前描述的 sendToServer() 函数将其发送给另一个对等方。消息的属性是:

type

消息类型:"new-ice-candidate"

目标

需要将 ICE 候选者发送到的用户名。这允许信令服务器路由消息。

候选者

表示 ICE 层希望传输到另一个对等端的候选者的 SDP。

此消息的格式(与你在处理信令时所做的一切一样)完全取决于你,具体取决于你的需求;你可以根据需要提供其他信息。

注意:重要的是要记住,icecandidate 事件会在 ICE 候选者从呼叫的另一端到达时发送。相反,它们由你的呼叫端发送,以便你可以承担通过你选择的任何通道传输数据的工作。当你刚接触 WebRTC 时,这可能会令人困惑。

接收 ICE 候选者

信令服务器使用其选择的任何方法将每个 ICE 候选者传递给目标对等端;在我们的示例中,这表现为 JSON 对象,其中包含一个 type 属性,其值为字符串 "new-ice-candidate"。我们的主 WebSocket 传入消息代码会调用 handleNewICECandidateMsg() 函数来处理这些消息:

js
function handleNewICECandidateMsg(msg) {
  const candidate = new RTCIceCandidate(msg.candidate);

  myPeerConnection.addIceCandidate(candidate).catch(window.reportError);
}

此函数通过将其接收到的 SDP 传递给其构造函数来构造一个 RTCIceCandidate 对象,然后通过将其传递给 myPeerConnection.addIceCandidate() 将候选者传递给 ICE 层。这会将新的 ICE 候选者交给本地 ICE 层,至此,我们在处理此候选者过程中的职责就完成了。

每个对等方都会向另一个对等方发送针对其认为可能适用于正在交换的媒体的每种可能的传输配置的候选者。在某个时刻,两个对等方同意某个候选者是一个不错的选择,它们就会打开连接并开始共享媒体。然而,重要的是要注意,一旦媒体开始流动,ICE 协商并不会停止。相反,在对话开始后,候选者可能仍会继续交换,这可能是为了找到更好的连接方法,也可能是因为在对等方成功建立连接时它们已经在传输中。

此外,如果发生导致流媒体场景变化的情况,协商将再次开始,negotiationneeded 事件将发送到 RTCPeerConnection,并且整个过程将如前所述再次开始。这可能发生在各种情况下,包括:

  • 网络状态变化,例如带宽变化,从 Wi-Fi 切换到蜂窝网络连接等等。
  • 在手机的前置和后置摄像头之间切换。
  • 流媒体配置的更改,例如其分辨率或帧率。
接收新流

当新轨道添加到 RTCPeerConnection 时——无论是通过调用其 addTrack() 方法还是因为流媒体格式的重新协商——都会为添加到连接的每个轨道向 RTCPeerConnection 设置一个 track 事件。利用新添加的媒体需要实现 track 事件的处理程序。一个常见的需求是将传入媒体附加到适当的 HTML 元素。在我们的示例中,我们将轨道的流添加到显示传入视频的 <video> 元素中。

js
function handleTrackEvent(event) {
  document.getElementById("received_video").srcObject = event.streams[0];
  document.getElementById("hangup-button").disabled = false;
}

传入流被附加到 "received_video" <video> 元素,并且“挂断”<button> 元素被启用,以便用户可以挂断电话。

这段代码完成后,另一个对等方发送的视频终于在本地浏览器窗口中显示了!

处理轨道的移除

当远程对等端通过调用 RTCPeerConnection.removeTrack() 从连接中移除轨道时,你的代码会收到一个 removetrack 事件。我们处理 "removetrack" 的代码是:

js
function handleRemoveTrackEvent(event) {
  const stream = document.getElementById("received_video").srcObject;
  const trackList = stream.getTracks();

  if (trackList.length === 0) {
    closeVideoCall();
  }
}

此代码从 "received_video" <video> 元素的 srcObject 属性中获取传入视频 MediaStream,然后调用流的 getTracks() 方法以获取流的轨道数组。

如果数组的长度为零,这意味着流中没有剩余轨道,我们通过调用 closeVideoCall() 结束呼叫。这会干净地将我们的应用程序恢复到可以再次开始或接收呼叫的状态。请参阅结束呼叫以了解 closeVideoCall() 的工作原理。

结束通话

通话可能因多种原因结束。通话可能已完成,一方或双方已挂断。也许发生了网络故障,或者一个用户退出了浏览器,或者系统崩溃了。无论如何,所有美好的事物都必须结束。

挂断

当用户点击“挂断”按钮结束通话时,会调用 hangUpCall() 函数:

js
function hangUpCall() {
  closeVideoCall();
  sendToServer({
    name: myUsername,
    target: targetUsername,
    type: "hang-up",
  });
}

hangUpCall() 执行 closeVideoCall() 以关闭并重置连接并释放资源。然后它构建一个 "hang-up" 消息并将其发送到呼叫的另一端,以告知另一对等方干净地关闭自身。

结束通话

如下所示的 closeVideoCall() 函数负责停止流、清理并处理 RTCPeerConnection 对象:

js
function closeVideoCall() {
  const remoteVideo = document.getElementById("received_video");
  const localVideo = document.getElementById("local_video");

  if (myPeerConnection) {
    myPeerConnection.ontrack = null;
    myPeerConnection.onremovetrack = null;
    myPeerConnection.onremovestream = null;
    myPeerConnection.onicecandidate = null;
    myPeerConnection.oniceconnectionstatechange = null;
    myPeerConnection.onsignalingstatechange = null;
    myPeerConnection.onicegatheringstatechange = null;
    myPeerConnection.onnegotiationneeded = null;

    if (remoteVideo.srcObject) {
      remoteVideo.srcObject.getTracks().forEach((track) => track.stop());
    }

    if (localVideo.srcObject) {
      localVideo.srcObject.getTracks().forEach((track) => track.stop());
    }

    myPeerConnection.close();
    myPeerConnection = null;
  }

  remoteVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");
  localVideo.removeAttribute("src");
  localVideo.removeAttribute("srcObject");

  document.getElementById("hangup-button").disabled = true;
  targetUsername = null;
}

在获取到两个 <video> 元素的引用后,我们检查 WebRTC 连接是否存在;如果存在,我们继续断开并关闭呼叫:

  1. 所有事件处理程序都被移除。这可以防止在连接关闭过程中触发无关的事件处理程序,从而可能导致错误。
  2. 对于远程和本地视频流,我们都会遍历每个轨道,调用 MediaStreamTrack.stop() 方法来关闭每个轨道。
  3. 通过调用 myPeerConnection.close() 关闭 RTCPeerConnection
  4. myPeerConnection 设置为 null,确保我们的代码知道没有正在进行的通话;当用户在用户列表中点击一个姓名时,这很有用。

然后,对于传入和传出 <video> 元素,我们使用它们的 removeAttribute() 方法移除其 srcsrcObject 属性。这完成了流与视频元素的分离。

最后,我们将“挂断”按钮的 disabled 属性设置为 true,使其在没有通话进行时无法点击;然后我们将 targetUsername 设置为 null,因为我们不再与任何人通话。这允许用户呼叫其他用户,或接收来电。

处理状态变化

你可以为许多附加事件设置监听器,以通知你的代码各种状态更改。我们使用了其中三个:iceconnectionstatechangeicegatheringstatechangesignalingstatechange

ICE 连接状态

当连接状态发生变化(例如,当呼叫从另一端终止时)时,ICE 层会向 RTCPeerConnection 发送 iceconnectionstatechange 事件。

js
function handleICEConnectionStateChangeEvent(event) {
  switch (myPeerConnection.iceConnectionState) {
    case "closed":
    case "failed":
      closeVideoCall();
      break;
  }
}

在这里,当 ICE 连接状态变为 "closed""failed" 时,我们应用 closeVideoCall() 函数。这会处理关闭我们端的连接,以便我们再次准备好发起或接受呼叫。

注意:我们在此处不监视 disconnected 信令状态,因为它可能表示临时问题,并可能在一段时间后恢复到 connected 状态。监视它会在任何临时网络问题时关闭视频通话。

ICE 信令状态

同样,我们监听 signalingstatechange 事件。如果信令状态变为 closed,我们也会关闭通话。

js
function handleSignalingStateChangeEvent(event) {
  switch (myPeerConnection.signalingState) {
    case "closed":
      closeVideoCall();
      break;
  }
}

注意:closed 信令状态已被弃用,取而代之的是 closed iceConnectionState。我们在此处监视它以增加一些向后兼容性。

ICE 收集状态

icegatheringstatechange 事件用于让你知道 ICE 候选者收集过程状态何时更改。我们的示例中没有使用它,但监视这些事件对于调试以及检测候选者收集何时完成可能很有用。

js
function handleICEGatheringStateChangeEvent(event) {
  // Our sample just logs information to console here,
  // but you can do whatever you need.
}

后续步骤

你现在可以尝试此示例,看看它在实践中如何运行。在两台设备上打开 Web 控制台,查看日志输出——尽管你在上面显示的代码中看不到它,但服务器上的代码(以及 GitHub 上的代码)有大量的控制台输出,这样你就可以看到信令和连接过程的运作。

另一个显而易见的改进是添加“振铃”功能,这样就不会只是请求用户允许使用摄像头和麦克风,而是首先出现“用户 X 正在呼叫。您想接听吗?”的提示。

另见