【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

江三瘋發表於2019-04-25

前言

這是 WebRTC 系列的第三篇文章,主要講多人點對點連線。如果你對 WebRTC 還不太瞭解,推薦閱讀我之前的文章。

文章倉庫在 ?? fe-code,歡迎 star

原始碼地址 webrtc-stream

線上預覽 webrtc-stream-depaadjmes.now.sh

三種模式

簡單介紹一下基於 WebRTC 的多人通訊的幾種架構模式。

  • Mesh 架構

我們之前寫過幾個 1 v 1 的栗子,它們的連線模式如下:

【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

這是典型的端到端對等連線,所以當我們要實現多人視訊(實際上也就是多端通訊)的時候,我們會很自然的想到在 1 v 1 的基礎上擴充,給每個客戶端建立多個 1 v 1 的對等連線:

【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

這就是所謂的 Mesh 模式,不需要額外的伺服器處理媒體資料(當然,信令伺服器是不可少的),僅僅是基於 WebRTC 自身的點對點連線進行通訊,本期的例項也是採用這種模式。

但是這種架構的缺點也是十分明顯的,如果連線的客戶端過多,上行頻寬面臨的壓力將會非常大,相應的視訊通話 。

  • Mixer 架構

傳統的視訊會議,一般都是採用 Mixer 架構。以錄播攝像為例,會利用 MCU (多點控制單元) 接收並混合每個客戶端傳入的媒體流。也就是將多個客戶端的音視訊畫面合成單個流,再傳輸給每個參與的客戶端。這樣也可以保證客戶端始終是 1 對 1 的連線,有效緩解了 Mesh 架構的問題。缺點則是依賴服務端,成本比較大,而且服務端處理過多也更容易導致視訊流的延遲。

【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

  • Router 架構

Router 模式和 Mixer 很類似,比較來說,它只是單純的進行資料流的轉發,而不用合成、轉碼等操作。

【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

因此,在實際運用中,使用哪種方式來處理,需要結合專案需求、成本等因素綜合考量。

多人視訊

1 v 1

我們基於 Mesh 模式來做多人視訊的演示,所以需要給每個客戶端建立多個 1 v 1 的對等連線。除了 WebRTC 的基礎知識,還需要用到 Socket.io 和 Koa 來做信令服務。

【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

先複習一下 1 v 1 的連線過程:

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

當然,NAT 穿越和候選資訊交換也是必不可少的。

本地 ICE 候選資訊採集完成後,通過信令服務進行交換。
這一步也是在建立 Peer 之後,但與 offer 的傳送沒有先後關係。
複製程式碼

1 v 多

我們平時觀看直播實際上就是 1 v 多,也就是隻有一端輸出視訊流,其他觀看端只需要接收就好了。但是這種形式,一般不會採用點對點連線,而是用傳統的直播方式,服務端進行媒體流的轉發。有些直播可以和主播進行互動,這裡的原理大致和上篇文章中的共享畫板類似。

【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

這裡只是給大家介紹一下這種直播模式,所以具體的就不細說了。

多路通話

其實這種情況,主要用於視訊會議或者多人視訊通話,類似於微信的視訊通話一樣。

注意事項

我們剛剛回憶過 1 v 1 的連線流程,也知道要基於 Mesh 架構來做,那麼到底該如何去做呢?這裡先提煉兩個要點:

  • 如何給每個客戶端建立多個點對點連線?
  • 如何確認連線的順序?

【從頭到腳】前端實現多人視訊聊天— WebRTC 實戰(多人篇)| 掘金技術徵文

我們以 3 個客戶端 A、B、C 為例。A 最先開啟瀏覽器或者說 A 是第一個加入房間的,那麼 A 進入的時候房間內沒有其他人,這個時候要做什麼?只需要初始化一下自己的視訊畫面就好,並不需要進行任何連線操作,因為這個時候沒有第二個人,也就沒有連線的物件。

什麼時候需要進行連線?等 B 加入房間的時候。這裡又一個問題,B 加入房間時,誰傳送 Offer ? 因為都參與通話,B 加入的時候首先也會初始化自己的視訊流,那麼此時 A 和 B 都可以 createOffer 。這也是和之前 1 v 1 的區別所在,因為 1 v 1 我們有明確的 呼叫端接收端,不需要考慮這個問題。所以,為了避免連線混亂,我們只用後加入的成員,向房間內所有已加入成員分別傳送 Offer,也就是說 B 加入時,給 A 發;C 加入時,再給 A 和 B 分別發。 以此來保證連線的有序性,這是第二個問題。

那麼如何在一個端建立多個點對點連線呢?我採用的策略是,兩兩之間的連線,都是單獨建立的 Peer 例項。也就是說,A ——> B 、A ——> C 的連線中,A 會建立兩個 Peer 例項,用來分別與 B、C 做連線,同樣的 B、C 也會建立多個 Peer 例項。但是我們需要確保每個端之間的 Peer 是一一對應的,簡單來說,就是 A 的 PeerA-B 必須和 B 的 peerA-B 連線。很明顯,這裡需要一個唯一性標識。

// loginname 唯一
// 假設 A 的 loginname 是 A;B 的 loginname 是 B;
// 在客戶端 A 中
let arr = ['A', 'B'];
let id = arr.sort().join('-'); // 排序後再連線 A-B
this.PeerList[id] = Peer; // 將建立的 peer 以鍵值對形式都存放到 PeerList 中
// PS: 在客戶端 B 中,操作一樣
複製程式碼

程式碼寫起來

其實實現多人通訊的主要思路剛剛已經講完了,我習慣於先將思路理清楚,再講程式碼實現。個人覺得這樣比大家直接看程式碼註釋效果要好,大家有什麼好的意見也可以在評論區提出,我們一起討論。

我們先做一個加入房間的過渡頁,簡單的 Vue 寫法,沒啥好說的。

<div class="center">
    登入名:<input type="text" v-model="account"> <br>
    房間號:<input type="text" v-model="roomid"> <br>
    <button @click="join">加入房間</button>
</div>

// ···
methods: {
    join() {
        if (this.account && this.roomid) {
            this.$router.push({name: 'room',
            params: {roomid: this.roomid, account: this.account}})
        }
        // 引數是路由形式的,如 room/id/account
    }
}

複製程式碼

初始化步驟和前兩期 1 v 1 的栗子沒有區別,視訊通話首先當然是獲取視訊流。

getUserMedia() { // 獲取媒體流
    let myVideo = this.$refs['video-mine']; // 預設播放自己視訊流的 video
    let getUserMedia = (navigator.getUserMedia ||
        navigator.webkitGetUserMedia ||
        navigator.mozGetUserMedia ||
        navigator.msGetUserMedia);
    //獲取本地的媒體流,並繫結到一個video標籤上輸出
    return new Promise((resolve, reject) => {
        getUserMedia.call(navigator, {
            "audio": true,
            "video": true
        }, (stream) => {
            //繫結本地媒體流到video標籤用於輸出
            myVideo.srcObject = stream;
            this.localStream = stream;
            resolve();
        }, function(error){
            reject(error);
            // console.log(error);
            //處理媒體流建立失敗錯誤
        });
    })
}
複製程式碼

大家還記不記得,在 1 v 1 中,我們建立 Peer 例項的時機是: 接收端 點選同意通話後,初始化自己的 Peer 例項;呼叫端 收到對方同意申請的通知後,初始化 Peer 例項,並向其傳送 Offer。剛剛分析過,多人通訊思路有些不一樣,但是 初始化方法是差不多的,我們先寫個初始化方法。

getPeerConnection(v) {
    let videoBox = this.$refs['video-box']; // 用於向 box 中新增新加入的成員視訊
    let iceServer = { // stun 服務,如果要做到 NAT 穿透,還需要 turn 服務
        "iceServers": [
            {
                "url": "stun:stun.l.google.com:19302"
            }
        ]
    };
    let PeerConnection = (window.RTCPeerConnection ||
        window.webkitRTCPeerConnection ||
        window.mozRTCPeerConnection);
    // 建立 peer 例項
    let peer = new PeerConnection(iceServer);
    //向PeerConnection中加入需要傳送的流
    peer.addStream(this.localStream);

    // 如果檢測到媒體流連線到本地,將其繫結到一個video標籤上輸出
    // v.account 就是上面提到的 A-B
    peer.onaddstream = function(event){
        let videos = document.querySelector('#' + v.account);
        if (videos) { // 如果頁面上有這個標識的播放器,就直接賦值 src
            videos.srcObject = event.stream;
        } else {
            let video = document.createElement('video');
            video.controls = true;
            video.autoplay = 'autoplay';
            video.srcObject = event.stream;
            video.id = v.account; 
            // video加上對應標識,這樣在對應客戶端斷開連線後,可以移除相應的video
            videoBox.append(video);
        }
    };
    // 傳送ICE候選到其他客戶端
    peer.onicecandidate = (event) => {
        if (event.candidate) {
            // ··· 傳送 ICE
        }
    };
    this.peerList[v.account] = peer; // 儲存 Peer
}
複製程式碼

建立 Peer 的時候用到了 account 標識來做儲存,這裡也涉及到我們建立點對點連線的時機問題。現在我們來看看,之前分析的第二個問題如何體現在程式碼上呢?

// data 是後端返回的房間內所有成員列表
// account 是本次新加入成員 loginname
socket.on('joined', (data, account) => {
// joined 在每次有人加入房間時觸發,自己加入時,自己也會收到
    if (data.length> 1) { // 成員數大於1,也就是前面提到的從第二個開始,每個新加入成員傳送 Offer
        data.forEach(v => {
            let obj = {};
            let arr = [v.account, this.$route.params.account];
            obj.account = arr.sort().join('-'); // 組合 Peer 的標識
            if (!this.peerList[obj.account] && v.account !== this.$route.params.account) {
                // 如果列表中沒有這個標識的 Peer ,則建立 Peer例項
                // 如果是自己,就不建立,否則就重複了
                // 比如所有成員列表中,有 A 和 B,我自己就是 A,如果不排除,就會建立兩個 A-B
                this.getPeerConnection(obj);
            }
        });
        if (account === this.$route.params.account) { 
        // 如果新加入成員是自己,則給所有已加入成員傳送 Offer
            for (let k in this.peerList) {
                this.createOffer(k, this.peerList[k]);
            }
        }
    }
});
複製程式碼

我們在初始化 Peer 例項的時候,還做了一個傳送 ICE 的操作。那我們就以 ICE 接收為例,看一下這種加了唯一標識的處理和之前有什麼區別。

getPeerConnection(v) {
    // ··· 部分程式碼省略
    // 傳送ICE候選到其他客戶端
    peer.onicecandidate = (event) => {
        if (event.candidate) {
            socket.emit('__ice_candidate',
            {candidate: event.candidate,
            roomid: this.$route.params.roomid,
            account: v.account});
            // 將標識 v.account 也放進資料中轉發給對方,用於匹配對應的 Peer
        }
    };
}

// 在mounted 方法中接收
socket.on('__ice_candidate', v => {
    //如果是一個ICE的候選,則將其加入到PeerConnection中
    if (v.candidate) {
        // 利用傳過來的唯一標識匹配對應的 Peer,並新增 Ice
        this.peerList[v.account] && this.peerList[v.account].addIceCandidate(v.candidate).catch((e) => {                    console.log('err', e)
        });
    }
});
複製程式碼

其實區別就是,我們把標識(A-B)也放進了信令互動的資料中,這樣才能在兩端之前匹配到對應的 Peer 例項,而不至於混亂。

最後,後端程式碼比較簡單,看一下需要注意的點就好。

const users = {};
app._io.on( 'connection', sock => {
    sock.on('join', data=>{
        sock.join(data.roomid, () => {
            if (!users[data.roomid]) {
                users[data.roomid] = [];
            }
            // 因為多房間,採用了這種格式儲存房間成員
            // {'room1': [userA, userB, userC]}   userA 包含loginname 和 sock.id
            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);
            }
            app._io.in(data.roomid).emit('joined', users[data.roomid], data.account, sock.id); 
            // 新成員加入時,把房間內成員列表發給房間內所有人
        });
    });
    sock.on('offer', data=>{ // 轉發 Offer
        sock.to(data.roomid).emit('offer',data);
    });
    // 這裡轉發是直接轉發到房間了,也可以轉發到指定的客戶端
    // 看過上一篇共享畫板的同學應該有印象,沒看過的可以去看看,這裡就不再多說
    sock.on('answer', data=>{ // 轉發 Answer
        sock.to(data.roomid).emit('answer',data);
    });
    sock.on('__ice_candidate', data=>{ // 轉發ICE
        sock.to(data.roomid).emit('__ice_candidate',data);
    });
})

app._io.on('disconnect', (sock) => { // 斷開連線時,刪除對應的客戶端資料
    for (let k in users) {
        users[k] = users[k].filter(v => v.id !== sock.id);
    }
    console.log(`disconnect id => ${users}`);
});
複製程式碼

到這裡,主要流程就講完了。另外關於 Offer、Answer 的建立和交換和 1 v 1 的區別也只在於多加了一個標識,跟上面講的 ICE 傳輸一樣。所以,就不貼程式碼了,有需要的同學可以去程式碼倉庫看 完整程式碼

交流群

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

後記

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

更多文章:

前端進階之路系列

從頭到腳實戰系列

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

相關文章