一起来学习 WebRTC (篇一)| 掘金技术征文

3,067 阅读8分钟

前言

作为一个认为啥都想懂一点的小开发,一直都对WebRTC很感兴趣,这个兴趣来源于几年前公司希望做一个即时通讯的小功能在APP上,不过最终由于项目最终需求更改而搁置。虽然如此,但是我还是了解了一些关于该技术的技术背景,例如P2P通讯、内网打洞等等。通过几个晚上的学习和实验,大体上了解WebRTC的原理和使用方法,现在分享一下我的学习过程吧。

准备工作

作为一个文档党,从来都要先看官方文档和文章,这样才能保证自己拿到最新,最好的一手信息。WebRTC官网文档也还算是比较全面,不过貌似都好久没更新了。推测是,大概很久没有做功能升级了吧。我这次学习,参考了一些官方例子,加上了自己的理解。有错误的地方大家可以指出来呀,一起学习。参考的文章会在文章结尾加上。废话不多说了,开始吧。

打开我们的摄像头

WebRTC是谷歌开发的,目标是创造一个高质量的、可靠的通讯框架,从字面的意我们可以拆分为了WebRTC两部分,Web很好理解啊,就是基于网络,而RTC全称为Real Time Communications(实时通讯),因此它的作用就是让我们可以利用浏览器(也能用于APP),进行实时的通讯的一个框架。既然是通讯媒介当然是多种的,包括视频,语音,文本等多种多媒体信息,甚至你还能利用它来传输各种文件。下面,我们用最直观的,视频通讯来开始我们的学习吧。

用浏览器打开摄像头很简单,我们可以直接调用JS API 实现。

  • HTML
<!DOCTYPE html>
<html lang="en">
<head>
    ...
</head>
<body>
    <h1>获得视频流</h1>

    <!-- 设置自动播放 -->
    <video autoplay playsinline></video>
    <script src="js/main.js"></script>
</body>
</html>
  • JavaScript
// 媒体流配置
const mediaStreamConstraints = {
    video: true
};

// 获得 video 标签元素
const localVideo = document.querySelector("video");

// 媒体流对象
let localStream;

// 回调保存视频流对象并把流传到 video 标签
function gotLocalMediaStream(mediaStream) {
    localStream = mediaStream;
    localVideo.srcObject = mediaStream;
}

// handle 错误信息
function handleLocalMediaStreamError(error) {
    console.log("打开本地视频流错误: ", error)
}

// fire!!
navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
    .then(gotLocalMediaStream)
    .catch(handleLocalMediaStreamError);

代码主要分2步

  1. navigator.mediaDevices.getUserMedia 中获得视频设备。
  2. then 的回调中把视频流传到 video 标签。

非常简单吧

获得摄像头

值得注意的是,我用的是Chrome 浏览器,新版本的Chrome加强了获取设备的安全策略。如果你想要打开摄像头等设备,你的域名如果不是本地文件或者 localhost 那必须通过https 访问。

使用 RTC 进行 P2P 传输

既然视频流我们得到了,第二步,我们来使用WebRTCRTCPeerConnection 来进行本地传输吧。这个Demo 不是真实的使用场景,因为不涉及到真实世界的网络传输,我们仅仅是在同一个页面,打开了两个 RTCPeerConnection 把一个的内容传输到另一个,从而进行通讯。在贴代码之前,我们先来简单的描述一下创建连接的过程吧。

假设现在是A想跟B视频。他们的 offer/answer (申请?/ 应答?), 机制是这样的:

1. `A `创建了一个 `RTCPeerConnection` 对象

2. `A` 利用`RTCPeerConnection` 的 `createOffer()` 方法创建了一个 `offer` (一个` SDP` 的会话描述)

3. `A` 在 `offer` 的回调中使用 `setLocalDescription()` 方法存储他的 `offer` 

4. `A` 把他的 `offer` 字符串化,然后通过某一种信令机制发给 `B`

5. `B` 收到 `A` 的 `offer` 后用`setRemoteDescription()` 存起来,如此一来他的 `RTCPeerConnection` 就知道了 `A` 的配置。

6. `B` 调用 `createAnswer()` 并用他的成功回调的传送他的本地会话描述:这就是 `B` 的`answer`

7. `B` 用 `setLocalDescription()` 设置了他的 `answer` 到本地的会话描述

8. 然后 `B` 用某一种信令机制把他的 `answer` 字符串化之后返回给 `A`

9. `A` 把 `B` 的 `answer` 利用`setRemoteDescription()`方法存取为远程会话描述

过程看上去很麻烦,不过其实他们就做了个事情

  1. 创建会话描述(SDP
  2. 交换会话描述(SDP
  3. 存储自己跟对方的会话描述

有关 SDP的格式,可以参看文章后面的链接

下面让我们看代码,走起

  • HTML
<!DOCTYPE html>
<html lang="en">
<head>
    ...
</head>
<body>
    <h1>RTCPeerConnection 传输视频流</h1>
    <!-- 设置自动播放 -->
    <video autoplay playsinline id="localVideo"></video>
    <video autoplay playsinline id="remoteVideo"></video>
    <div>
        <button id="startBtn">开始</button>
        <button id="callBtn">拨打</button>
        <button id="hangupBtn">挂机</button>
    </div>

    <!-- 垫片,用于统一浏览器 API -->
    <script src="js/adapter.js"></script>
    <script src="js/main.js"></script>
</body>
</html>

HTML 代码比较简单,我们创建了两个 video,一个显示远程一个显示本地,并且加入了三个按钮进行模拟拨打。细心的同学可能已经发现了,我们引入了一个垫片adapter.js。经常写前端的同学对垫片可能熟悉不过了,因为世界上不仅仅只有谷歌的浏览器,还有各种各样别的。然后命名,API也是各种各样,所以我们会利用各种垫片,统一我们的API。不再忍受兼容之苦。adapter.js就是这样的存在。他是谷歌官方提供给我们的。引入它我们便可以用统一套API操作。

  • JavaScript

由于代码比较长,就只贴关键代码了。全部代码链接我会在文章后面贴上。

// 开始按钮,打开本地媒体流
function startAction() {
    startButton.disabled = true;
    navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
        .then(gotLocalMediaStream).catch(handleLocalMediaStreamError);
    trace('本地媒体流打开中...');
}

这是响应开始按钮的函数。跟第一个例子一样,主要是用来打开摄像头,并且把视频流传到idlocalVideo的视频标签。

// 拨打按钮, 创建 peer connection
function callAction() {
    callButton.disabled = true;
    hangupButton.disabled = false;

    trace("开始拨打...");
    startTime = window.performance.now();
    
    // ...

    const servers = null;  // RTC 服务器配置

    // 创建 peer connetcions 并添加事件
    localPeerConnection = new RTCPeerConnection(servers);
    trace("创建本地 peer connetcion 对象");

    localPeerConnection.addEventListener('icecandidate', handleConnection);
    localPeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);

    remotePeerConnection = new RTCPeerConnection(servers);
    trace("创建远程 peer connetcion 对象");

    remotePeerConnection.addEventListener('icecandidate', handleConnection);
    remotePeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);
    remotePeerConnection.addEventListener('addstream', gotRemoteMediaStream);

    // 添加本地流到连接中并创建连接
    localPeerConnection.addStream(localStream);
    trace("添加本地流到本地 PeerConnection");

    trace("开始创建本地 PeerConnection offer");
    localPeerConnection.createOffer(offerOptions)
        .then(createdOffer).catch(setSessionDescriptionError);
}

这部份是拨打按钮的响应函数。在这个方法中,我们做了个事情。

  1. 创建了用于通讯的一对RTCPeerConnection对象,localPeerConnectionremotePeerConnection

  2. 分别给两个RTCPeerConnection对象注册了icecandidate(重要)iceconnectionstatechange 事件的响应函数

  3. remotePeerConnection注册了addstream事件的响应。

  4. 把本地视频流添加到localPeerConnection

  5. localPeerConnection创建offer

这里有一个上面没有提及的东西ICE CandidateICE是啥呢?哈哈,他的全称是 Interactive Connectivity Establishment交互式连接的建立。他是一个规范,说白了就是建立连接用的规范,由于我们的WebRTC是要进行P2P连接的,而我们的网络是非常复杂的,而且大部分都是在内网(需要打洞或者穿越防火墙)。所以我们需要一个机制来建立内网连接。这个我会在后面的文章详细来说说。现在,简单理解成就是建立连接用的就好了。而icecandidate 的响应方法,则是当网络可用的情况下,用于存储和交换各种网络信息。

// 定义 RTC peer connection
function handleConnection(event) {
    const peerConnection = event.target;
    const iceCandidate = event.candidate;

    if (iceCandidate) {
        const newIceCanidate = new RTCIceCandidate(iceCandidate);
        const otherPeer = getOtherPeer(peerConnection);

        otherPeer.addIceCandidate(newIceCanidate)
            .then(() => {
                handleConnectionSuccess(peerConnection);
            }).catch((error) => {
             handleConnectionFailure(peerConnection, error);
            });

        trace(`${getPeerName(peerConnection)} ICE candidate:\n` +
            `${event.candidate.candidate}.`);
    }
}

这段代码正是体现了网络信息(ICE candidate),的保存和交换过程。而保存Candidate是通过调用RTCPeerConnection对象的addIceCandidate方法。这里可能大家有疑问,这里就交换了Candidate信息了吗?是的getOtherPeer方法其实就是用于获得对方的RTCPeerConnection对象,因为我们的 Demo 是在同一页面创建的。所以不需通过其他载体交换。

好的,说完连接创建,我们接着说创建offer。在创建offer前,我们已经留意到,其实已经把本地的视频流添加到RTCPeerConnection对象中了,因此offer所带的SDP会话描述,已经带有相关信息。我们先来createOffer 成功后的回调方法。

// 创建 offer
function createdOffer(description) {
    trace(`Offer from localPeerConnection:\n${description.sdp}`);

    trace('localPeerConnection setLocalDescription 开始.');
    localPeerConnection.setLocalDescription(description)
        .then(() => {
            setLocalDescriptionSuccess(localPeerConnection);
        }).catch(setSessionDescriptionError);

    trace('remotePeerConnection setRemoteDescription 开始.');
    remotePeerConnection.setRemoteDescription(description)
        .then(() => {
            setRemoteDescriptionSuccess(remotePeerConnection);
        }).catch(setSessionDescriptionError);

    trace('remotePeerConnection createAnswer 开始.');
    remotePeerConnection.createAnswer()
        .then(createdAnswer)
}
   

简单明了,对于localPeerConnection来说是本地,所以就是调用 setLocalDescriptionoffer信息存储。而对于对方就是远程remotePeerConnection就是用setRemoteDescription进行存储了。这里跟我章节前说的第4步说的不一样,这里没有转成字符串。聪明的同学可能猜到为什么了,因为这里是同一个页面,不需要传输呀。

紧接着马上remotePeerConnection就调用createAnswer创建了一个 answer,让我们继续看,

// 创建 answer
function createdAnswer(description) {
    trace(`Answer from remotePeerConnection:\n${description.sdp}.`);

    trace('remotePeerConnection setLocalDescription 开始.');
    remotePeerConnection.setLocalDescription(description)
        .then(() => {
            setLocalDescriptionSuccess(remotePeerConnection);
        }).catch(setSessionDescriptionError);

    trace('localPeerConnection setRemoteDescription 开始.');
    localPeerConnection.setRemoteDescription(description)
        .then(() => {
            setRemoteDescriptionSuccess(localPeerConnection);
        }).catch(setSessionDescriptionError);
}

这里跟上面的createOffer回调做的差不多,把answer存储到双方对应的描述中。

到这里为止双方的连接建好,offeranswer也存储妥当。由于remotePeerConnection在之前已经已经注册好addStream的响应方法了gotRemoteMediaStream,而正如前文说的,因为创建offer的时候已经把视频流带上了,所以gotRemoteMediaStream此刻会回调,通过这个方法,把视频流显示在remoteVideo标签中。

// 回调保存远程媒体流对象并把流传到 video 标签
function gotRemoteMediaStream(event) {
    const mediaStream = event.stream;
    remoteVideo.srcObject = mediaStream;
    remoteStream = mediaStream;
    trace("远程节点链接成功,接收远程媒体流中...");
}

现在,我们应该可以看到两个一模一样的画面了。注意哦,右边那个是通过RTC 传输过来的。撒花~

RTC transport

这一篇先到这里吧,我们下一篇继续。下一篇会继续继续深入WebRTC架构和ICEsignling之类的内容。谢谢大家的阅读,毕竟我也是个初学者,如果文中有不对的地方,大家可以评论一下,然后一起探讨。再次谢过。

代码和参考文档

Agora SDK 使用体验征文大赛 | 掘金技术征文,征文活动正在进行中