前言
作为一个认为啥都想懂一点的小开发,一直都对WebRTC
很感兴趣,这个兴趣来源于几年前公司希望做一个即时通讯的小功能在APP
上,不过最终由于项目最终需求更改而搁置。虽然如此,但是我还是了解了一些关于该技术的技术背景,例如P2P
通讯、内网打洞等等。通过几个晚上的学习和实验,大体上了解WebRTC
的原理和使用方法,现在分享一下我的学习过程吧。
准备工作
作为一个文档党,从来都要先看官方文档和文章,这样才能保证自己拿到最新,最好的一手信息。WebRTC
官网文档也还算是比较全面,不过貌似都好久没更新了。推测是,大概很久没有做功能升级了吧。我这次学习,参考了一些官方例子,加上了自己的理解。有错误的地方大家可以指出来呀,一起学习。参考的文章会在文章结尾加上。废话不多说了,开始吧。
打开我们的摄像头
WebRTC
是谷歌开发的,目标是创造一个高质量的、可靠的通讯框架,从字面的意我们可以拆分为了Web
跟RTC
两部分,Web
很好理解啊,就是基于网络,而RTC
全称为Real Time Communications
(实时通讯),因此它的作用就是让我们可以利用浏览器(也能用于APP
),进行实时的通讯的一个框架。既然是通讯媒介当然是多种的,包括视频,语音,文本等多种多媒体信息,甚至你还能利用它来传输各种文件。下面,我们用最直观的,视频通讯来开始我们的学习吧。
用浏览器打开摄像头很简单,我们可以直接调用JS API
实现。
- 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步
- 从
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
<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>
<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('本地媒体流打开中...');
}
复制代码
这是响应开始
按钮的函数。跟第一个例子一样,主要是用来打开摄像头,并且把视频流传到id
为localVideo
的视频标签。
// 拨打按钮, 创建 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);
}
复制代码
这部份是拨打
按钮的响应函数。在这个方法中,我们做了个事情。
-
创建了用于通讯的一对
RTCPeerConnection
对象,localPeerConnection
和remotePeerConnection
-
分别给两个
RTCPeerConnection
对象注册了icecandidate(重要)
和iceconnectionstatechange
事件的响应函数 -
给
remotePeerConnection
注册了addstream
事件的响应。 -
把本地视频流添加到
localPeerConnection
-
localPeerConnection
创建offer
这里有一个上面没有提及的东西ICE Candidate
,ICE
是啥呢?哈哈,他的全称是 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
来说是本地,所以就是调用 setLocalDescription
把offer
信息存储。而对于对方就是远程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
存储到双方对应的描述中。
到这里为止双方的连接建好,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
之类的内容。谢谢大家的阅读,毕竟我也是个初学者,如果文中有不对的地方,大家可以评论一下,然后一起探讨。再次谢过。
代码和参考文档
- DEMO-1 代码
- DEMO-2 代码
- 官方文档
- 官方 codelabs
- Interactive Connectivity Establishment
- Session Description Protocol
Agora SDK 使用体验征文大赛 | 掘金技术征文,征文活动正在进行中