音视频通话的前端技术实现

我们通过敲代码实现一个 Demo 来真实感受下 WebRTC 实时通讯的魅力。

RTCPeerConnection

RTCPeerConnection 类是在浏览器下使用 WebRTC 实现实时互动音视频系统中最核心的类,它代表一个由本地计算机到远端的 WebRTC 连接。该接口提供了创建、保持、监控及关闭连接的方法的实现。

想要对这个类了解更多可以移步这个链接, https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection

其实,如果你有做过 socket 开发的话,你会更容易理解 RTCPeerConnection,它其实就是一个加强版本的 socket。

我们了解了 WebRTC 的通信原理,在真实场景下需要进行媒体协商、网络协商、架设信令服务器等操作,我画了一张图,将 WebRTC 的通信过程总结如下:

通信过程

不过今天我们为了单纯的搞清楚 RTCPeerConnection,先不考虑开发架设信令服务器的问题,简单点,我们这次尝试在同一个页面中模拟两端进行音视频的互通。

在此之前,我们先了解一些将要用到的 API 以及 WebRTC 建立连接的步骤。

相关 API

  • RTCPeerConnection 接口代表一个由本地计算机到远端的 WebRTC 连接。该接口提供了创建、保持、监控、关闭连接的方法的实现。* PC.createOffer 创建提议 Offer 方法,此方法会返回 SDP Offer 信息。* PC.setLocalDescription 设置本地 SDP 描述信息。* PC.setRemoteDescription 设置远端 SDP 描述信息,即对方发过来的 SDP 数据。* PC.createAnswer 创建应答 Answer 方法,此方法会返回 SDP Answer 信息。* RTCIceCandidate WebRTC 网络信息(IP、端口等)* PC.addIceCandidate PC 连接添加对方的 IceCandidate 信息,即添加对方的网络信息。

WebRTC 建立连接步骤

  • 1.为连接的两端创建一个 RTCPeerConnection 对象,并且给 RTCPeerConnection 对象添加本地流。* 2.获取本地媒体描述信息(SDP),并与对端进行交换。* 3.获取网络信息(Candidate,IP 地址和端口),并与远端进行交换。

Demo 实战

首先,我们添加视频元素及控制按钮,引入 adpater.js 来适配各浏览器。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Demo</title>
    <style>
        video {
            width: 320px;
        }
    </style>
</head>
<body>
    <video id="localVideo" autoplay playsinline></video>
    <video id="remoteVideo" autoplay playsinline></video>

    <div>
        <button id="startBtn">打开本地视频</button>
        <button id="callBtn">建立连接</button>
        <button id="hangupBtn">断开连接</button>
    </div>
    <!-- 适配各浏览器 API 不统一的脚本 -->
    <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
    <script src="./webrtc.js"></script>
</body>
</html>

然后,定义我们将要使用到的对象。

// 本地流和远端流
let localStream;
let remoteStream;

// 本地和远端连接对象
let localPeerConnection;
let remotePeerConnection;

// 本地视频和远端视频
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');

// 设置约束
const mediaStreamConstraints = {
    video: true
}

// 设置仅交换视频
const offerOptions = {
    offerToReceiveVideo: 1
}

接下来,给按钮注册事件并实现相关业务逻辑。

function startHandle() {
    startBtn.disabled = true;
    // 1.获取本地音视频流
    // 调用 getUserMedia API 获取音视频流
    navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
        .then(gotLocalMediaStream)
        .catch((err) => {
            console.log('getUserMedia 错误', err);
        });
}

function callHandle() {
    callBtn.disabled = true;
    hangupBtn.disabled = false;

    // 视频轨道
    const videoTracks = localStream.getVideoTracks();
    // 音频轨道
    const audioTracks = localStream.getAudioTracks();
    // 判断视频轨道是否有值
    if (videoTracks.length > 0) {
        console.log(`使用的设备为: ${videoTracks[0].label}.`);
    }
    // 判断音频轨道是否有值
    if (audioTracks.length > 0) {
        console.log(`使用的设备为: ${audioTracks[0].label}.`);
    }
    const servers = null;

    // 创建 RTCPeerConnection 对象
    localPeerConnection = new RTCPeerConnection(servers);
    // 监听返回的 Candidate
    localPeerConnection.addEventListener('icecandidate', handleConnection);
    // 监听 ICE 状态变化
    localPeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange)

    remotePeerConnection = new RTCPeerConnection(servers);
    remotePeerConnection.addEventListener('icecandidate', handleConnection);
    remotePeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);
    remotePeerConnection.addEventListener('track', gotRemoteMediaStream);

    // 将音视频流添加到 RTCPeerConnection 对象中
    // 注意:新的协议中已经不再推荐使用 addStream 方法来添加媒体流,应使用 addTrack 方法
    // localPeerConnection.addStream(localStream);
    // 遍历本地流的所有轨道
    localStream.getTracks().forEach((track) => {
        localPeerConnection.addTrack(track, localStream)
    })

    // 2.交换媒体描述信息
    localPeerConnection.createOffer(offerOptions)
    .then(createdOffer).catch((err) => {
        console.log('createdOffer 错误', err);
    });
}

function hangupHandle() {
    // 关闭连接并设置为空
    localPeerConnection.close();
    remotePeerConnection.close();
    localPeerConnection = null;
    remotePeerConnection = null;
    hangupBtn.disabled = true;
    callBtn.disabled = false;
}

// getUserMedia 获得流后,将音视频流展示并保存到 localStream
function gotLocalMediaStream(mediaStream) {
    localVideo.srcObject = mediaStream; 
    localStream = mediaStream; 
    callBtn.disabled = false;
}

function createdOffer(description) {
    console.log(`本地创建offer返回的sdp:\n${description.sdp}`)
    // 本地设置描述并将它发送给远端
    // 将 offer 保存到本地
    localPeerConnection.setLocalDescription(description) 
        .then(() => {
            console.log('local 设置本地描述信息成功');
        }).catch((err) => {
            console.log('local 设置本地描述信息错误', err)
        });
    // 远端将本地给它的描述设置为远端描述
    // 远端将 offer 保存
    remotePeerConnection.setRemoteDescription(description) 
        .then(() => { 
            console.log('remote 设置远端描述信息成功');
        }).catch((err) => {
            console.log('remote 设置远端描述信息错误', err);
        });
    // 远端创建应答 answer
    remotePeerConnection.createAnswer() 
        .then(createdAnswer)
        .catch((err) => {
            console.log('远端创建应答 answer 错误', err);
        });
}

function createdAnswer(description) {
    console.log(`远端应答Answer的sdp:\n${description.sdp}`)
    // 远端设置本地描述并将它发给本地
    // 远端保存 answer
    remotePeerConnection.setLocalDescription(description)
        .then(() => { 
            console.log('remote 设置本地描述信息成功');
        }).catch((err) => {
            console.log('remote 设置本地描述信息错误', err);
        });
    // 本地将远端的应答描述设置为远端描述
    // 本地保存 answer
    localPeerConnection.setRemoteDescription(description) 
        .then(() => { 
            console.log('local 设置远端描述信息成功');
        }).catch((err) => {
            console.log('local 设置远端描述信息错误', err);
        });
}

// 3.端与端建立连接
function handleConnection(event) {
    // 获取到触发 icecandidate 事件的 RTCPeerConnection 对象 
    // 获取到具体的Candidate
    const peerConnection = event.target;
    const iceCandidate = event.candidate;

    if (iceCandidate) {
        // 创建 RTCIceCandidate 对象
        const newIceCandidate = new RTCIceCandidate(iceCandidate);
        // 得到对端的 RTCPeerConnection
        const otherPeer = getOtherPeer(peerConnection);

        // 将本地获得的 Candidate 添加到远端的 RTCPeerConnection 对象中
        // 为了简单,这里并没有通过信令服务器来发送 Candidate,直接通过 addIceCandidate 来达到互换 Candidate 信息的目的
        otherPeer.addIceCandidate(newIceCandidate)
            .then(() => {
                handleConnectionSuccess(peerConnection);
            }).catch((error) => {
                handleConnectionFailure(peerConnection, error);
            });
    }
}

// 4.显示远端媒体流
function gotRemoteMediaStream(event) {
    if (remoteVideo.srcObject !== event.streams[0]) {
        remoteVideo.srcObject = event.streams[0];
        remoteStream = event.streams[0];
        console.log('remote 开始接受远端流')
    }
}

最后,还需要注册一些 Log 函数及工具函数。

function handleConnectionChange(event) {
    const peerConnection = event.target;
    console.log('ICE state change event: ', event);
    console.log(`${getPeerName(peerConnection)} ICE state: ` + `${peerConnection.iceConnectionState}.`);
}

function handleConnectionSuccess(peerConnection) {
    console.log(`${getPeerName(peerConnection)} addIceCandidate 成功`);
}

function handleConnectionFailure(peerConnection, error) {
    console.log(`${getPeerName(peerConnection)} addIceCandidate 错误:\n`+ `${error.toString()}.`);
}

function getPeerName(peerConnection) {
    return (peerConnection === localPeerConnection) ? 'localPeerConnection' : 'remotePeerConnection';
}

function getOtherPeer(peerConnection) {
    return (peerConnection === localPeerConnection) ? remotePeerConnection : localPeerConnection;
}

其实当你熟悉整个流程后可以将所有的 Log 函数统一抽取并封装起来,上文为了便于你在读代码的过程中更容易的理解整个 WebRTC 建立连接的过程,并没有进行抽取。

好了,到这里一切顺利的话,你就成功的建立了 WebRTC 连接,效果如下:

前端音视频WebRTC实时通讯

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,884评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,212评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,351评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,412评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,438评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,127评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,714评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,636评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,173评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,264评论 3 339
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,402评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,073评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,763评论 3 332
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,253评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,382评论 1 271
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,749评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,403评论 2 358

推荐阅读更多精彩内容