【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

江三瘋發表於2019-03-18

前言

從頭到腳 】會作為一個系列文章來發布,它包括但不限於 WebRTC 多人視訊,預計會有:

  • WebRTC 實戰(一):也就是本期,主要是基礎講解以及一對一的本地對等連線,網路對等連線。
  • WebRTC 實戰(二):主要講解資料傳輸以及多端本地對等連線、網路對等連線。
  • WebRTC 實戰(三):實現一個一對一的視訊聊天專案,包括但不限於截圖、錄製等。
  • WebRTC + Canvas 實現一個共享畫板專案。
  • 作者開源作品 ???Vchat — 一個社交聊天系統(vue + node + mongodb) 的系列文章

因為文章輸出確實要耗費很大的精力,所以上面計劃不一定是這個釋出順序,中間也會穿插釋出其它方向的文章,如 Vue、JavaScript 或者其他學習的主題。在文章裡,會把我自己遇到過的一些坑點重點提示大家注意,儘量讓大家在學習過程中少走彎路。當然,我的也並不是標準答案,只是我個人的思路。如果大家有更好的方法,可以互相交流,也希望大家都能有所收穫。

在這裡也希望大家可以 關注 一波,你們的關注支援,也能激勵我輸出更好的文章。

先放個效果圖,這期的目標是實現一個 1 V 1 的視訊通話(筆記本攝像頭不能用了,用的虛擬攝像頭)。文章比較長,可以 mark 以後慢慢看。

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

文章末尾有 交流群公眾號,希望大家支援,感謝?。

什麼是 WebRTC ?

WebRTC 是由一家名為 Gobal IP Solutions,簡稱 GIPS 的瑞典公司開發的。Google 在 2011 年收購了 GIPS,並將其原始碼開源。然後又與 IETF 和 W3C 的相關標準機構合作,以確保行業達成共識。其中:

  • Web Real-Time Communications (WEBRTC) W3C 組織:定義瀏覽器 API。
  • Real-Time Communication in Web-browsers (RTCWEB) IETF 標準組織:定義其所需的協議,資料,安全性等手段。

簡單來說,WebRTC 是一個可以在 Web 應用程式中實現音訊,視訊和資料的實時通訊的開源專案。在實時通訊中,音視訊的採集和處理是一個很複雜的過程。比如音視訊流的編解碼、降噪和回聲消除等,但是在 WebRTC 中,這一切都交由瀏覽器的底層封裝來完成。我們可以直接拿到優化後的媒體流,然後將其輸出到本地螢幕和揚聲器,或者轉發給其對等端。

WebRTC 的音視訊處理引擎:

WebRTC 的音視訊處理引擎

所以,我們可以在不需要任何第三方外掛的情況下,實現一個瀏覽器到瀏覽器的點對點(P2P)連線,從而進行音視訊實時通訊。當然,WebRTC 提供了一些 API 供我們使用,在實時音視訊通訊的過程中,我們主要用到以下三個:

  • getUserMedia:獲取音訊和視訊流(MediaStream)
  • RTCPeerConnection:點對點通訊
  • RTCDataChannel:資料通訊

不過,雖然瀏覽器給我們解決了大部分音視訊處理問題,但是從瀏覽器請求音訊和視訊時,我們還是需要特別注意流的大小和質量。因為即便硬體能夠捕獲高清質量流,CPU 和頻寬也不一定可以跟上,這也是我們在建立多個對等連線時,不得不考慮的問題。

實現

接下來,我們通過分析上文提到的 API,來逐步弄懂 WebRTC 實時通訊實現的流程。

getUserMedia

MediaStream

getUserMedia 這個 API 大家可能並不陌生,因為常見的 H5 錄音等功能就需要用到它,主要就是用來獲取裝置的媒體流(即 MediaStream)。它可以接受一個約束物件 constraints 作為引數,用來指定需要獲取到什麼樣的媒體流。

    navigator.mediaDevices.getUserMedia({ audio: true, video: true }) 
    // 參數列示需要同時獲取到音訊和視訊
        .then(stream => {
          // 獲取到優化後的媒體流
          let video = document.querySelector('#rtc');
          video.srcObject = stream;
        })
        .catch(err => {
          // 捕獲錯誤
        });
複製程式碼

我們簡單看一下獲取到的 MediaStream。

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

可以看到它有很多屬性,我們只需要瞭解一下就好,更多資訊可以檢視 MDN

* id [String]: 對當前的 MS 進行唯一標識。所以每次重新整理瀏覽器或是重新獲取 MS,id 都會變動。
* active [boolean]: 表示當前 MS 是否是活躍狀態(就是是否可以播放)。
* onactive: 當 active 為 true 時,觸發該事件。
複製程式碼

結合上圖,我們順便複習一下上期講的原型和原型鏈。MediaStream 的 __proto__ 指向它的建構函式所對應的原型物件,在原型物件中又有一個 constructor 屬性指向了它所對應的建構函式。也就是說 MediaStream 的建構函式是一個名為 MediaStream 的函式。可能說得有一點繞,對原型還不熟悉的同學,可以去看一下上期文章 JavaScript 原型和原型鏈及 canvas 驗證碼實踐

這裡也可以通過 getAudioTracks()、getVideoTracks() 來檢視獲取到的流的某些資訊,更多資訊檢視 MDN

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

* kind: 是當前獲取的媒體流型別(Audio/Video)。
* label: 是媒體裝置,我這裡用的是虛擬攝像頭。
* muted: 表示媒體軌道是否靜音。
複製程式碼

相容性

繼續來看 getUserMedia,navigator.mediaDevices.getUserMedia 是新版的 API,舊版的是 navigator.getUserMedia。為了避免相容性問題,我們可以稍微處理一下(其實說到底,現在 WebRTC 的支援率還不算高,有需要的可以選擇一些介面卡,如 adapter.js)。

    // 判斷是否有 navigator.mediaDevices,沒有賦成空物件
    if (navigator.mediaDevices === undefined) {
        navigator.mediaDevices = {};
    }
    
    // 繼續判斷是否有 navigator.mediaDevices.getUserMedia,沒有就採用 navigator.getUserMedia
    if (navigator.mediaDevices.getUserMedia) {
        navigator.mediaDevices.getUserMedia = function(prams) {
            let getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
            // 相容獲取
            if (!getUserMedia) {
                return Promise.reject(new Error('getUserMedia is not implemented in this browser'));
            }
            return new Promise(function(resolve, reject) {
                getUserMedia.call(navigator, prams, resolve, reject);
            });
        };
    }
    navigator.mediaDevices.getUserMedia(constraints)
        .then(stream => {
            let video = document.querySelector('#Rtc');
            if ('srcObject' in video) { // 判斷是否支援 srcObject 屬性
                video.srcObject = stream;
            } else {
                video.src = window.URL.createObjectURL(stream);
            }
            video.onloadedmetadata = function(e) {
                video.play();
            };
        })
        .catch((err) => { // 捕獲錯誤
            console.error(err.name + ': ' + err.message);
        });
複製程式碼

constraints

對於 constraints 約束物件,我們可以用來指定一些和媒體流有關的屬性。比如指定是否獲取某種流:

    navigator.mediaDevices.getUserMedia({ audio: false, video: true });
    // 只需要視訊流,不要音訊
複製程式碼

指定視訊流的寬高、幀率以及理想值:

    // 獲取指定寬高,這裡需要注意:在改變視訊流的寬高時,
    // 如果寬高比和採集到的不一樣,會直接截掉某部分
    { audio: false, 
      video: { width: 1280, height: 720 } 
    }
    // 設定理想值、最大值、最小值
    {
      audio: true,
      video: {
        width: { min: 1024, ideal: 1280, max: 1920 },
        height: { min: 776, ideal: 720, max: 1080 }
      }
    }
複製程式碼

對於移動裝置來說,還可以指定獲取前攝像頭,或者後置攝像頭:

    { audio: true, video: { facingMode: "user" } } // 前置
    { audio: true, video: { facingMode: { exact: "environment" } } } // 後置
    // 也可以指定裝置 id,
    // 通過 navigator.mediaDevices.enumerateDevices() 可以獲取到支援的裝置
    { video: { deviceId: myCameraDeviceId } }
複製程式碼

還有一個比較有意思的就是設定視訊源為螢幕,但是目前只有火狐支援了這個屬性。

    { audio: true, video: {mediaSource: 'screen'} } 
複製程式碼

這裡就不接著做搬運工了,更多精彩盡在 MDN,^_^!

RTCPeerConnection

RTCPeerConnection 介面代表一個由本地計算機到遠端的 WebRTC 連線。該介面提供了建立,保持,監控,關閉連線的方法的實現。—— MDN

概述

RTCPeerConnection 作為建立點對點連線的 API,是我們實現音視訊實時通訊的關鍵。在點對點通訊的過程中,需要交換一系列資訊,通常這一過程叫做 — 信令(signaling)。在信令階段需要完成的任務:

 * 為每個連線端建立一個 RTCPeerConnection,並新增本地媒體流。
 * 獲取並交換本地和遠端描述:SDP 格式的本地媒體後設資料。
 * 獲取並交換網路資訊:潛在的連線端點稱為 ICE 候選者。
複製程式碼

我們雖然把 WebRTC 稱之為點對點的連線,但並不意味著,實現過程中不需要伺服器的參與。因為在點對點的通道建立起來之前,二者之間是沒有辦法通訊的。這也就意味著,在信令階段,我們需要一個通訊服務來幫助我們建立起這個連線。WebRTC 本身沒有指定信令服務,所以,我們可以但不限於使用 XMPP、XHR、Socket 等來做信令交換所需的服務。我在工作中採用的方案是基於 XMPP 協議的Strophe.js來做雙向通訊,但是在本例中則會使用Socket.io以及 Koa 來做專案演示。

NAT 穿越技術

我們先看連線任務的第一條:為每個連線端建立一個 RTCPeerConnection,並新增本地媒體流。事實上,如果是一般直播模式,則只需要播放端新增本地流進行輸出,其他參與者只需要接受流進行觀看即可。

因為各瀏覽器差異,RTCPeerConnection 一樣需要加上字首。 javascript let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection; let peer = new PeerConnection(iceServers); 我們看見 RTCPeerConnection 也同樣接收一個引數 — iceServers,先來看看它長什麼樣: javascript { iceServers: [ { url: "stun:stun.l.google.com:19302"}, // 谷歌的公共服務 { url: "turn:***", username: ***, // 使用者名稱 credential: *** // 密碼 } ] } 引數配置了兩個 url,分別是 STUN 和 TURN,這便是 WebRTC 實現點對點通訊的關鍵,也是一般 P2P 連線都需要解決的問題:NAT穿越。

NAT(Network Address Translation,網路地址轉換)簡單來說就是為了解決 IPV4 下的 IP 地址匱乏而出現的一種技術,也就是一個 公網 IP 地址一般都對應 n 個內網 IP。這樣也就會導致不是同一區域網下的瀏覽器在嘗試 WebRTC 連線時,無法直接拿到對方的公網 IP 也就不能進行通訊,所以就需要用到 NAT 穿越(也叫打洞)。以下為 NAT 穿越基本流程:

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

一般情況下會採用 ICE 協議框架進行 NAT 穿越,ICE 的全稱為 Interactive Connectivity Establishment,即互動式連線建立。它使用 STUN 協議以及 TURN 協議來進行穿越。關於 NAT 穿越的更多資訊可以參考 ICE協議下NAT穿越的實現(STUN&TURN)P2P通訊標準協議(三)之ICE

到這裡,我們可以發現,WebRTC 的通訊至少需要兩種服務配合:

  • 信令階段需要雙向通訊服務輔助資訊交換。
  • STUN、TURN輔助實現 NAT 穿越。

建立點對點連線

WebRTC 的點對點連線到底是什麼樣的過程呢,我們通過結合圖例來分析連線。

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

顯而易見,在上述連線的過程中:

呼叫端(在這裡都是指代瀏覽器)需要給 接收端 傳送一條名為 offer 的資訊。

接收端 在接收到請求後,則返回一條 answer 資訊給 呼叫端

這便是上述任務之一 ,SDP 格式的本地媒體後設資料的交換。sdp 資訊一般長這樣:

    v=0
    o=- 1837933589686018726 2 IN IP4 127.0.0.1
    s=-
    t=0 0
    a=group:BUNDLE audio video
    a=msid-semantic: WMS yvKeJMUSZzvJlAJHn4unfj6q9DMqmb6CrCOT
    m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
    ...
    ...
複製程式碼

但是任務不僅僅是交換,還需要分別儲存自己和對方的資訊,所以我們再加點料:

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

 * **呼叫端** 建立 offer 資訊後,先呼叫 setLocalDescription 儲存本地 offer 描述,再將其傳送給 **接收端**。
 * **接收端** 收到 offer 後,先呼叫 setRemoteDescription 儲存遠端 offer 描述;然後又建立 answer 資訊,同樣需要呼叫 setLocalDescription 儲存本地 answer 描述,再返回給 **接收端**
 * **呼叫端** 拿到 answer 後,再次呼叫 setRemoteDescription 設定遠端 answer 描述。
複製程式碼

到這裡點對點連線還缺一步,也就是網路資訊 ICE 候選交換。不過這一步和 offer、answer 資訊的交換並沒有先後順序,流程也是一樣的。即:在呼叫端接收端的 ICE 候選資訊準備完成後,進行交換,並互相儲存對方的資訊,這樣就完成了一次連線。

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

這張圖是我認為比較完善的了,詳細的描述了整個連線的過程。正好我們再來小結一下: * 基礎設施:必要的信令服務和 NAT 穿越服務 * clientA 和 clientB 分別建立 RTCPeerConnection 併為輸出端新增本地媒體流。如果是視訊通話型別,則意味著,兩端都需要新增媒體流進行輸出。 * 本地 ICE 候選資訊採集完成後,通過信令服務進行交換。 * 呼叫端(好比 A 給 B 打視訊電話,A 為呼叫端)發起 offer 資訊,接收端接收並返回一個 answer 資訊,呼叫端儲存,完成連線。

本地 1 v 1 對等連線

基礎流程講完了,那麼是騾子是馬拉出來溜溜。我們先來實現一個本地的對等連線,藉此熟悉一下流程和部分 API。本地連線,意思就是不經過服務,在本地頁面的兩個 video 之間進行連線。算了,還是上圖吧,一看就懂。

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

明確一下目標,A 作為輸出端,需要獲取到本地流並新增到自己的 RTCPeerConnection;B 作為呼叫端,並沒有輸出的需求,因此只需要接收流。

建立媒體流

頁面佈局很簡單,就是兩個 video 標籤,分別代表 A 和 B。所以我們直接看程式碼,雖然原始碼是用 Vue 構建的,但是並沒有用到特別的 API,整體上和 es6 的 class 語法相差不大,而且都有詳細的註釋,所以建議沒有 Vue 基礎的同學可以直接當成 es6 來閱讀。示例 原始碼庫 webrtc-stream

  async createMedia() {
      // 儲存本地流到全域性
      this.localstream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
      let video = document.querySelector('#rtcA');
      video.srcObject = this.localstream;
      this.initPeer(); // 獲取到媒體流後,呼叫函式初始化 RTCPeerConnection
  }
複製程式碼

初始化 RTCPeerConnection

  initPeer() {
      ...
      this.peerA.addStream(this.localstream); // 新增本地流
      this.peerA.onicecandidate = (event) => {
      // 監聽 A 的ICE候選資訊 如果收集到,就新增給 B 連線狀態
          if (event.candidate) {
              this.peerB.addIceCandidate(event.candidate);
          }
      };
      ...
      // 監聽是否有媒體流接入,如果有就賦值給 rtcB 的 src
      this.peerB.onaddstream = (event) => {
          let video = document.querySelector('#rtcB');
          video.srcObject = event.stream;
      };
      this.peerB.onicecandidate = (event) => { 連線狀態
      // 監聽 B 的ICE候選資訊 如果收集到,就新增給 A
          if (event.candidate) {
              this.peerA.addIceCandidate(event.candidate);
          }
      };
  }
複製程式碼

這部分主要就是分別建立 peer 例項,並互相交換 ICE 資訊。不過有一個屬性需要在這裡提一下,就是 iceConnectionState。

  peer.oniceconnectionstatechange = (evt) => {
      console.log('ICE connection state change: ' + evt.target.iceConnectionState);
  };
複製程式碼

我們可以通過 oniceconnectionstatechange 方法來監測 ICE 連線的狀態,它一共有七種狀態:

new        ICE代理正在收集候選人或等待提供遠端候選人。
checking   ICE代理已經在至少一個元件上接收了遠端候選者,並且正在檢查候選但尚未找到連線。除了檢查,它可能還在收集。
connected  ICE代理已找到所有元件的可用連線,但仍在檢查其他候選對以檢視是否存在更好的連線。它可能還在收集。
completed  ICE代理已完成收集和檢查,並找到所有元件的連線。
failed     ICE代理已完成檢查所有候選對,但未能找到至少一個元件的連線。可能已找到某些元件的連線。
disconnected ICE 連線斷開
closed      ICE代理已關閉,不再響應STUN請求。
複製程式碼

我們需要注意的是 completed 和 disconnected,一個是完成連線時觸發,一個在斷開連線時觸發。

建立連線

  async call() {
      if (!this.peerA || !this.peerB) { // 判斷是否有對應例項,沒有就重新建立
          this.initPeer();
      }
      try {
          let offer = await this.peerA.createOffer(this.offerOption); // 建立 offer
          await this.onCreateOffer(offer);
      } catch (e) {
          console.log('createOffer: ', e);
      }
  }
複製程式碼

這裡需要判斷是否有對應例項,是為了結束通話之後又重新呼叫做的處理。

  async onCreateOffer(desc) {
      try {
          await this.peerB.setLocalDescription(desc); // 呼叫端設定本地 offer 描述
      } catch (e) {
          console.log('Offer-setLocalDescription: ', e);
      }
      try {
          await this.peerA.setRemoteDescription(desc); // 接收端設定遠端 offer 描述
      } catch (e) {
          console.log('Offer-setRemoteDescription: ', e);
      }
      try {
          let answer = await this.peerA.createAnswer(); // 接收端建立 answer
          await this.onCreateAnswer(answer);
      } catch (e) {
          console.log('createAnswer: ', e);
      }
  },
  async onCreateAnswer(desc) {
      try {
          await this.peerA.setLocalDescription(desc); // 接收端設定本地 answer 描述
      } catch (e) {
          console.log('answer-setLocalDescription: ', e);
      }
      try {
          await this.peerB.setRemoteDescription(desc); // 呼叫端端設定遠端 answer 描述
      } catch (e) {
          console.log('answer-setRemoteDescription: ', e);
      }
  }
複製程式碼

這基本就是之前重複過好幾次的流程用程式碼寫出來而已,看到這裡,思路應該比較清晰了。不過有一點需要說明一下,就是現在這種情況,A 作為呼叫端,B 一樣是可以拿到 A 的媒體流的。因為連線一旦建立了,就是雙向的,只不過 B 初始化 peer 的時候沒有新增本地流,所以 A 不會有 B 的媒體流。

網路 1 v 1 對等連線

想必基本流程大家都已經熟悉了,通過圖解、例項來來回回講了好幾遍。所以趁熱打鐵,我們這次把服務加上,做一個真正的點對點連線。在看下面的文章之前,我希望你有一點點 Koa 和 Scoket.io 的基礎,瞭解一些基本 API 即可。不熟悉的同學也不要緊,現在看也來得及,KoaSocke.io,或者可以參考我之前的文章 Vchat - 一個社交聊天系統(vue + node + mongodb)

需求

還是老規矩,先了解一下需求。圖片載入慢,可以直接看演示地址

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

連線過程涉及到多個環節,這裡就不一一截圖了,可以直接去演示地址檢視。簡單分析一下我們要做的事情: * 加入房間後,獲取到房間的所有線上成員。 * 選擇任一成員進行通話,也就是呼叫動作。這時候就有一些細節問題要處理:不能呼叫自己、同一時刻只允許呼叫一個人且需要判斷對方是否是通話中、呼叫後回覆需要有相應判斷(同意、拒絕以及通話中) * 拒絕或通話中,都沒有後續動作,可以換個人再呼叫。同意之後,就要開始建立點對點連線。

加入房間

簡單看一下加入房間的流程:

  // 前端
  join() {
      if (!this.account) return;
      this.isJoin = true; // 輸入框彈層邏輯
      window.sessionStorage.account = this.account; // 重新整理判斷是否登入過
      socket.emit('join', {roomid: this.roomid, account: this.account}); // 傳送加入房間請求
  }
  
  // 後端
  const sockS = {}; // 不同客戶端對應的 sock 例項
  const users = {}; // 成員列表
  sock.on('join', data=>{
      sock.join(data.roomid, () => {
          if (!users[data.roomid]) {
              users[data.roomid] = [];
          }
          let obj = {
              account: data.account,
              id: sock.id
          };
          let arr = users[data.roomid].filter(v => v.account === data.account);
          if (!arr.length) {
              users[data.roomid].push(obj);
          }
          sockS[data.account] = sock; // 儲存不同客戶端對應的 sock 例項
           // 將房間內成員列表發給房間內所有人
          app._io.in(data.roomid).emit('joined', users[data.roomid], data.account, sock.id);
      });
  });
複製程式碼

後端成員列表的處理,是因為做了多房間的邏輯,按每個房間的成員表返回的。你們如果做的時候沒有多房間,則不需要這麼考慮。sockS 的處理,是為了傳送私聊訊息。

呼叫

前面已經說了呼叫的注意事項,所以這裡就一起來講。需要注意的就是訊息中需要帶有自己和對方的 account,因為這是判斷成員 sock 的標識,也就是之前儲存在 socks 中的用來發私聊訊息的。然後是前面說的三種狀態,在這裡用 type 值 1, 2, 3 來區分,然後給出不同的回覆。

  // 前端
  apply(account) { // 傳送請求
      // account 對方account  self 是自己的account
      this.loading = true;
      this.loadingText = '呼叫中'; // 呼叫中 loading
      socket.emit('apply', {account: account, self: this.account});
  },
  reply(account, type) { // 處理回覆
      socket.emit('reply', {account: account, self: this.account, type: type});
  }
  // 收到請求
  socket.on('apply', data => {
      if (this.isCall) { // 判斷是否在通話中
          this.reply(data.self, '3');
          return;
      }
      this.$confirm(data.self + ' 向你請求視訊通話, 是否同意?', '提示', {
          confirmButtonText: '同意',
          cancelButtonText: '拒絕',
          type: 'warning'
      }).then(async () => {
          this.isCall = data.self;
          this.reply(data.self, '1');
      }).catch(() => {
          this.reply(data.self, '2');
      });
  });
  
  // 後端
  sock.on('apply', data=>{ // 轉發申請
      sockS[data.account].emit('apply', data);
  });
複製程式碼

後端比較簡單,僅僅是轉發一下請求,給對應的客戶端。其實我們這個例子的後端,基本都是這個操作,所以後面的後端程式碼就不貼了,可以去原始碼直接看。

回覆

回覆和和呼叫是一樣的邏輯,分別處理不同的回覆就好了。

  // 前端 
  socket.on('reply', async data =>{ // 收到回覆
      this.loading = false;
      switch (data.type) {
          case '1': // 同意
              this.isCall = data.self; // 儲存通話物件
              break;
          case '2': //拒絕
              this.$message({
                  message: '對方拒絕了你的請求!',
                  type: 'warning'
              });
              break;
          case '3': // 正在通話中
              this.$message({
                  message: '對方正在通話中!',
                  type: 'warning'
              });
              break;
      }
  });
複製程式碼

建立連線

呼叫和回覆的邏輯基本清楚了,那我們繼續思考,應該在什麼時機建立 P2P 連線呢?我們之前說的,拒絕和通話中都不需要處理,只有同意需要,那就應該在同意請求的位置開始建立。需要注意的是,同意請求有兩個地方:一個是你點了同意,另一個是對方知道你點了同意之後。

本例採取的是呼叫方傳送 Offer,這個地方一定得注意,只要有一方建立 Offer 就可以了,因為一旦連線就是雙向的。

  socket.on('apply', data => { // 你點同意的地方
      ...
      this.$confirm(data.self + ' 向你請求視訊通話, 是否同意?', '提示', {
          confirmButtonText: '同意',
          cancelButtonText: '拒絕',
          type: 'warning'
      }).then(async () => {
          await this.createP2P(data); // 同意之後建立自己的 peer 等待對方的 offer
          ... // 這裡不發 offer
      })
      ...
  });
  socket.on('reply', async data =>{ // 對方知道你點了同意的地方
      switch (data.type) {
          case '1': // 只有這裡發 offer
              await this.createP2P(data); // 對方同意之後建立自己的 peer
              this.createOffer(data); // 並給對方傳送 offer
              break;
          ...
      }
  });
複製程式碼

和微信等視訊通話一樣,雙方都需要進行媒體流輸出,因為你們都要看見對方。所以這裡和之前本地對等連線的區別就是都需要給自己的 RTCPeerConnection 例項新增媒體流,然後連線後各自都能拿到對方的視訊流。在 初始化 RTCPeerConnection 時,記得加上 onicecandidate 函式,用以給對方傳送 ICE 候選。

  async createP2P(data) {
      this.loading = true; // loading動畫
      this.loadingText = '正在建立通話連線';
      await this.createMedia(data);
  },
  async createMedia(data) {
      ... // 獲取並將本地流賦值給 video  同之前
      this.initPeer(data); // 獲取到媒體流後,呼叫函式初始化 RTCPeerConnection
  },
  initPeer(data) {
      // 建立輸出端 PeerConnection
      ...
      this.peer.addStream(this.localstream); // 都需要新增本地流
      this.peer.onicecandidate = (event) => {
      // 監聽ICE候選資訊 如果收集到,就傳送給對方
          if (event.candidate) { // 傳送 ICE 候選
              socket.emit('1v1ICE',
              {account: data.self, self: this.account, sdp: event.candidate});
          }
      };
      this.peer.onaddstream = (event) => {
      // 監聽是否有媒體流接入,如果有就賦值給 rtcB 的 src,改變相應loading狀態,賦值省略
          this.isToPeer = true;
          this.loading = false;
          ...
      };
  }
複製程式碼

createOffer 等資訊交換和之前一樣,只是需要通過 Socket 轉發給對應的客戶端。然後各自接收到訊息後分別採取對應的措施。

  socket.on('1v1answer', (data) =>{ // 接收到 answer
      this.onAnswer(data);
  });
  socket.on('1v1ICE', (data) =>{ // 接收到 ICE
      this.onIce(data);
  });
  socket.on('1v1offer', (data) =>{ // 接收到 offer
      this.onOffer(data);
  });
  
  // 這裡只貼一個 createOffer 的程式碼,因為和之前的思路都一樣,只是寫法有些區別
  // 建議大家都自己敲一遍,有問題可以交流,也可以去原始碼檢視。
  async createOffer(data) { // 建立併傳送 offer
      try {
          // 建立offer
          let offer = await this.peer.createOffer(this.offerOption);
          // 呼叫端設定本地 offer 描述
          await this.peer.setLocalDescription(offer);
          // 給對方傳送 offer
          socket.emit('1v1offer', {account: data.self, self: this.account, sdp: offer});
      } catch (e) {
          console.log('createOffer: ', e);
      }
  }
複製程式碼

結束通話

結束通話的思路依然是將各自的 peer 關閉,但是這裡結束通話方還需要藉助 Socket 告訴對方,你已經掛電話了,不然對方還在痴痴地等。

  hangup() { // 結束通話通話 並做相應處理 對方收到訊息後一樣需要關閉連線
      socket.emit('1v1hangup', {account: this.isCall, self: this.account});
      this.peer.close();
      this.peer = null;
      this.isToPeer = false;
      this.isCall = false;
  }
複製程式碼

下期預告

到這裡,這期的內容已經講完了。這篇文章花了不少時間,週末兩天都在寫這了,希望你們看完能有所收穫。

系列的下一期(ps:系列下一期不一定是文章下一期?)主要是講資料傳輸以及多端本地對等連線、網路對等連線,因為還有一個重要的 API 沒有提到,就是 RTCDataChannel - 資料傳輸,然後多人視訊需要注意的地方也不少。最後,希望大家可以動動小手關注一下,或者掃描下面的公眾號二維碼,方便第一時間獲得最新推送。

交流群

qq前端交流群:960807765,歡迎各種技術交流,期待你的加入

後記

如果你看到了這裡,且本文對你有一點幫助的話,希望你可以動動小手支援一下作者,感謝?。文中如有不對之處,也歡迎大家指出,共勉。

往期文章:

歡迎關注公眾號 前端發動機,第一時間獲得作者文章推送,還有各類前端優質文章,希望在未來的前端路上,與你一同成長。

【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)

相關文章