前端音視訊WebRTC實時通訊的核心

童歐巴發表於2020-10-19

image

觀感度:?????

口味:新疆炒米粉

烹飪時間:10min

本文已收錄在前端食堂同名倉庫Github github.com/Geekhyt,歡迎光臨食堂,如果覺得酒菜還算可口,賞個 Star 對食堂老闆來說是莫大的鼓勵。

通過上兩個系列專欄的學習,我們對前端音視訊及 WebRTC 有了初步的瞭解,是時候敲程式碼實現一個 Demo 來真實感受下 WebRTC 實時通訊的魅力了。還沒有看過的同學請移步:

RTCPeerConnection

RTCPeerConnection 類是在瀏覽器下使用 WebRTC 實現實時互動音視訊系統中最核心的類,它代表一個由本地計算機到遠端的 WebRTC 連線。該介面提供了建立、保持、監控及關閉連線的方法的實現。

想要對這個類瞭解更多可以移步這個連結, https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection

其實,如果你有做過 socket 開發的話,你會更容易理解 RTCPeerConnection,它其實就是一個加強版本的 socket。

在上個系列專欄 前端音視訊之WebRTC初探 中,我們瞭解了 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 = mediaStream;
        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 連線,效果如下:

(隨手抓起桌邊的鼠年企鵝公仔)

參考

❤️愛心三連擊

1.如果你覺得食堂酒菜還合胃口,就點個贊支援下吧,你的是我最大的動力。

2.關注公眾號前端食堂,吃好每一頓飯!

3.點贊、評論、轉發 === 催更!

image

相關文章