一起來學習 WebRTC (篇一)| 掘金技術徵文

展豪發表於2019-04-22

前言

作為一個認為啥都想懂一點的小開發,一直都對WebRTC很感興趣,這個興趣來源於幾年前公司希望做一個即時通訊的小功能在APP上,不過最終由於專案最終需求更改而擱置。雖然如此,但是我還是瞭解了一些關於該技術的技術背景,例如P2P通訊、內網打洞等等。通過幾個晚上的學習和實驗,大體上了解WebRTC的原理和使用方法,現在分享一下我的學習過程吧。

準備工作

作為一個文件黨,從來都要先看官方文件和文章,這樣才能保證自己拿到最新,最好的一手資訊。WebRTC官網文件也還算是比較全面,不過貌似都好久沒更新了。推測是,大概很久沒有做功能升級了吧。我這次學習,參考了一些官方例子,加上了自己的理解。有錯誤的地方大家可以指出來呀,一起學習。參考的文章會在文章結尾加上。廢話不多說了,開始吧。

開啟我們的攝像頭

WebRTC是谷歌開發的,目標是創造一個高質量的、可靠的通訊框架,從字面的意我們可以拆分為了WebRTC兩部分,Web很好理解啊,就是基於網路,而RTC全稱為Real Time Communications(實時通訊),因此它的作用就是讓我們可以利用瀏覽器(也能用於APP),進行實時的通訊的一個框架。既然是通訊媒介當然是多種的,包括視訊,語音,文字等多種多媒體資訊,甚至你還能利用它來傳輸各種檔案。下面,我們用最直觀的,視訊通訊來開始我們的學習吧。

用瀏覽器開啟攝像頭很簡單,我們可以直接呼叫JS API 實現。

  • HTML
<!DOCTYPE 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步

  1. navigator.mediaDevices.getUserMedia 中獲得視訊裝置。
  2. then 的回撥中把視訊流傳到 video 標籤。

非常簡單吧

獲得攝像頭

值得注意的是,我用的是Chrome 瀏覽器,新版本的Chrome加強了獲取裝置的安全策略。如果你想要開啟攝像頭等裝置,你的域名如果不是本地檔案或者 localhost 那必須通過https 訪問。

使用 RTC 進行 P2P 傳輸

既然視訊流我們得到了,第二步,我們來使用WebRTCRTCPeerConnection 來進行本地傳輸吧。這個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()`方法存取為遠端會話描述
複製程式碼

過程看上去很麻煩,不過其實他們就做了個事情

  1. 建立會話描述(SDP
  2. 交換會話描述(SDP
  3. 儲存自己跟對方的會話描述

有關 SDP的格式,可以參看文章後面的連結

下面讓我們看程式碼,走起

  • HTML
<!DOCTYPE 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>

    <!-- 墊片,用於統一瀏覽器 API -->
    <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('本地媒體流開啟中...');
}
複製程式碼

這是響應開始按鈕的函式。跟第一個例子一樣,主要是用來開啟攝像頭,並且把視訊流傳到idlocalVideo的視訊標籤。

// 撥打按鈕, 建立 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);
}
複製程式碼

這部份是撥打按鈕的響應函式。在這個方法中,我們做了個事情。

  1. 建立了用於通訊的一對RTCPeerConnection物件,localPeerConnectionremotePeerConnection

  2. 分別給兩個RTCPeerConnection物件註冊了icecandidate(重要)iceconnectionstatechange 事件的響應函式

  3. remotePeerConnection註冊了addstream事件的響應。

  4. 把本地視訊流新增到localPeerConnection

  5. localPeerConnection建立offer

這裡有一個上面沒有提及的東西ICE CandidateICE是啥呢?哈哈,他的全稱是 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來說是本地,所以就是呼叫 setLocalDescriptionoffer資訊儲存。而對於對方就是遠端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儲存到雙方對應的描述中。

到這裡為止雙方的連線建好,offeranswer也儲存妥當。由於remotePeerConnection在之前已經已經註冊好addStream的響應方法了gotRemoteMediaStream,而正如前文說的,因為建立offer的時候已經把視訊流帶上了,所以gotRemoteMediaStream此刻會回撥,通過這個方法,把視訊流顯示在remoteVideo標籤中。

// 回撥儲存遠端媒體流物件並把流傳到 video 標籤
function gotRemoteMediaStream(event) {
    const mediaStream = event.stream;
    remoteVideo.srcObject = mediaStream;
    remoteStream = mediaStream;
    trace("遠端節點連結成功,接收遠端媒體流中...");
}
複製程式碼

現在,我們應該可以看到兩個一模一樣的畫面了。注意哦,右邊那個是通過RTC 傳輸過來的。撒花~

RTC transport

這一篇先到這裡吧,我們下一篇繼續。下一篇會繼續繼續深入WebRTC架構和ICEsignling之類的內容。謝謝大家的閱讀,畢竟我也是個初學者,如果文中有不對的地方,大家可以評論一下,然後一起探討。再次謝過。

程式碼和參考文件

Agora SDK 使用體驗徵文大賽 | 掘金技術徵文,徵文活動正在進行中

相關文章