前言
作为一个认为啥都想懂一点的小开发,一直都对WebRTC
很感兴趣,这个兴趣来源于几年前公司希望做一个即时通讯的小功能在APP
上,不过最终由于项目最终需求更改而搁置。虽然如此,但是我还是了解了一些关于该技术的技术背景,例如P2P
通讯、内网打洞等等。通过几个晚上的学习和实验,大体上了解WebRTC
的原理和使用方法,现在分享一下我的学习过程吧。
准备工作
作为一个文档党,从来都要先看官方文档和文章,这样才能保证自己拿到最新,最好的一手信息。WebRTC
官网文档也还算是比较全面,不过貌似都好久没更新了。推测是,大概很久没有做功能升级了吧。我这次学习,参考了一些官方例子,加上了自己的理解。有错误的地方大家可以指出来呀,一起学习。参考的文章会在文章结尾加上。废话不多说了,开始吧。
打开我们的摄像头
WebRTC
是谷歌开发的,目标是创造一个高质量的、可靠的通讯框架,从字面的意我们可以拆分为了Web
跟RTC
两部分,Web
很好理解啊,就是基于网络,而RTC
全称为Real Time Communications
(实时通讯),因此它的作用就是让我们可以利用浏览器(也能用于APP
),进行实时的通讯的一个框架。既然是通讯媒介当然是多种的,包括视频,语音,文本等多种多媒体信息,甚至你还能利用它来传输各种文件。下面,我们用最直观的,视频通讯来开始我们的学习吧。
用浏览器打开摄像头很简单,我们可以直接调用JS API
实现。
- 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步
- 从
navigator.mediaDevices.getUserMedia
中获得视频设备。 - 在
then
的回调中把视频流传到video
标签。
非常简单吧
值得注意的是,我用的是Chrome
浏览器,新版本的Chrome
加强了获取设备的安全策略。如果你想要打开摄像头等设备,你的域名如果不是本地文件或者 localhost
那必须通过https
访问。
使用 RTC 进行 P2P 传输
既然视频流我们得到了,第二步,我们来使用WebRTC
的 RTCPeerConnection
来进行本地传输吧。这个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()`方法存取为远程会话描述复制代码
过程看上去很麻烦,不过其实他们就做了个事情
- 创建会话描述(
SDP
) - 交换会话描述(
SDP
) - 存储自己跟对方的会话描述
有关 SDP
的格式,可以参看文章后面的链接
下面让我们看代码,走起
- HTML
...RTCPeerConnection 传输视频流
复制代码
HTML 代码比较简单,我们创建了两个 video
,一个显示远程一个显示本地,并且加入了三个按钮进行模拟拨打。细心的同学可能已经发现了,我们引入了一个垫片adapter.js
。经常写前端的同学对垫片可能熟悉不过了,因为世界上不仅仅只有谷歌的浏览器,还有各种各样别的。然后命名,API
也是各种各样,所以我们会利用各种垫片,统一我们的API
。不再忍受兼容之苦。adapter.js
就是这样的存在。他是谷歌官方提供给我们的。引入它我们便可以用统一套API
操作。
- JavaScript
由于代码比较长,就只贴关键代码了。全部代码链接我会在文章后面贴上。
// 开始按钮,打开本地媒体流function startAction() { startButton.disabled = true; navigator.mediaDevices.getUserMedia(mediaStreamConstraints) .then(gotLocalMediaStream).catch(handleLocalMediaStreamError); trace('本地媒体流打开中...');}复制代码
这是响应开始
按钮的函数。跟第一个例子一样,主要是用来打开摄像头,并且把视频流传到id
为localVideo
的视频标签。
// 拨打按钮, 创建 peer connectionfunction 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);}复制代码
这部份是拨打
按钮的响应函数。在这个方法中,我们做了个事情。
-
创建了用于通讯的一对
RTCPeerConnection
对象,localPeerConnection
和remotePeerConnection
-
分别给两个
RTCPeerConnection
对象注册了icecandidate(重要)
和iceconnectionstatechange
事件的响应函数 -
给
remotePeerConnection
注册了addstream
事件的响应。 -
把本地视频流添加到
localPeerConnection
-
localPeerConnection
创建offer
这里有一个上面没有提及的东西ICE Candidate
,ICE
是啥呢?哈哈,他的全称是 Interactive Connectivity Establishment
交互式连接的建立。他是一个规范,说白了就是建立连接用的规范,由于我们的WebRTC
是要进行P2P
连接的,而我们的网络是非常复杂的,而且大部分都是在内网(需要打洞或者穿越防火墙)。所以我们需要一个机制来建立内网连接。这个我会在后面的文章详细来说说。现在,简单理解成就是建立连接用的就好了。而icecandidate
的响应方法,则是当网络可用的情况下,用于存储和交换各种网络信息。
// 定义 RTC peer connectionfunction 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
成功后的回调方法。
// 创建 offerfunction 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
来说是本地,所以就是调用 setLocalDescription
把offer
信息存储。而对于对方就是远程remotePeerConnection
就是用setRemoteDescription
进行存储了。这里跟我章节前说的第4步说的不一样,这里没有转成字符串。聪明的同学可能猜到为什么了,因为这里是同一个页面,不需要传输呀。
紧接着马上remotePeerConnection
就调用createAnswer
创建了一个 answer
,让我们继续看,
// 创建 answerfunction 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
存储到双方对应的描述中。
到这里为止双方的连接建好,offer
与 answer
也存储妥当。由于remotePeerConnection
在之前已经已经注册好addStream
的响应方法了gotRemoteMediaStream
,而正如前文说的,因为创建offer
的时候已经把视频流带上了,所以gotRemoteMediaStream
此刻会回调,通过这个方法,把视频流显示在remoteVideo
标签中。
// 回调保存远程媒体流对象并把流传到 video 标签function gotRemoteMediaStream(event) { const mediaStream = event.stream; remoteVideo.srcObject = mediaStream; remoteStream = mediaStream; trace("远程节点链接成功,接收远程媒体流中...");}复制代码
现在,我们应该可以看到两个一模一样的画面了。注意哦,右边那个是通过RTC
传输过来的。撒花~
这一篇先到这里吧,我们下一篇继续。下一篇会继续继续深入WebRTC
架构和ICE
,signling
之类的内容。谢谢大家的阅读,毕竟我也是个初学者,如果文中有不对的地方,大家可以评论一下,然后一起探讨。再次谢过。
代码和参考文档