筆者之前寫過一篇 【從頭到腳】擼一個多人視頻聊天 — 前端 WebRTC 實戰(一),主要講 WebRTC 的一些基礎知識以及單人通話的簡單實現。原計劃這篇寫多人通話的,鑒於有同學留言說想看畫板,所以把這篇文章提前了,希望可以給大家提供一些思路。
本期的主要內容,便是實現一個共享畫板,還有上期沒講的一個知識點:RTCDataChannel 。
特別注意:介於本次的實現多基於上期的知識點以及相關示例,所以強烈建議不太了解 WebRTC 基礎的同學,配合上篇一起看 傳送門。最近文章的相關示例都集中在一個項目里,截至本期目錄如下:
照例先看下本期的實戰目標(靈魂畫手上線):實現一個可以兩人(基於上期文章的 1 對 1 對等連接)協作作畫的畫板。是什麼概念呢?簡單來說就是兩個人可以共享一個畫板,都可以在上面作畫。
先來感受一下恐懼!顫抖吧!人類!
我們先把上期留下的知識點補上,因為今天的栗子也會用到它。
簡單來說,RTCDataChannel 就是在點對點連接中建立一個雙向的數據通道,從而獲得文本、文件等數據的點對點傳輸能力。它依賴於流控制傳輸協議(SCTP),SCTP 是一種傳輸協議,類似於 TCP 和 UDP,可以直接在 IP 協議之上運行。但是,在 WebRTC 的情況下,SCTP 通過安全的 DTLS 隧道進行隧道傳輸,該隧道本身在 UDP 之上運行。 嗯,我是個學渣,對於這段話我也只能說是,看過!大家可以直接 查看原文。
SCTP 是一種傳輸協議,類似於 TCP 和 UDP,可以直接在 IP 協議之上運行。但是,在 WebRTC 的情況下,SCTP 通過安全的 DTLS 隧道進行隧道傳輸,該隧道本身在 UDP 之上運行
另外總的來說 RTCDataChannel 和 WebSocket 很像,只不過 WebSocket 不是 P2P 連接,需要伺服器做中轉。
RTCDataChannel 通過上一期講過的 RTCPeerConnection 來創建。
// 創建 let Channel = RTCPeerConnection.createDataChannel(messagechannel, options); // messagechannel 可以看成是給 DataChannel 取的別名,限制是不得超過65,535 位元組。 // options 可以設置一些屬性,一般默認就好。 // 接收 RTCPeerConnection.ondatachannel = function(event) { let channel = event.channel; }
RTCDataChannel 只需要在一端使用 createDataChannel 來創建實例,在接收端只需要給 RTCPeerConnection 加上 ondatachannel 監聽即可。但是有一點需要注意的是,一定要是 呼叫端 也就是創建 createOffer 的那端來 createDataChannel 創建通道。
createDataChannel
ondatachannel
RTCDataChannel 的一些屬性,更多可以查看 MDN
前面說 RTCDataChannel 和 WebSocket 很像是真的很像,我們基於上期的本地 1 對 1 連接,簡單看一下用法。
這裡還是說一下,系列文章就是這點比較麻煩,後面的很多內容都是基於前面的基礎的,但是有很多同學又沒看過之前的文章。但是我也不能每次都把之前的內容再重複一遍,所以還是強烈建議有需求的同學,結合之前的文章一起看 傳送門,希望大家理解。
一個簡單的收發消息的功能,我們已經知道了在 呼叫端 和 接收端 分別拿到 RTCDataChannel 實例,但是還不知道怎麼接收和發送消息,現在就來看一下。
// this.peerB 呼叫端 RTCPeerConnection 實例 this.channelB = this.peerB.createDataChannel(messagechannel); // 創建 Channel this.channelB.onopen = (event) => { // 監聽連接成功 console.log(channelB onopen, event); this.messageOpen = true; // 連接成功後顯示消息框 }; this.channelB.onclose = function(event) { // 監聽連接關閉 console.log(channelB onclose, event); };
// 發送消息 send() { this.channelB.send(this.sendText); this.sendText = ; } // this.peerA 接收端 RTCPeerConnection 實例 this.peerA.ondatachannel = (event) => { this.channelA = event.channel; // 獲取接收端 channel 實例 this.channelA.onopen = (e) => { // 監聽連接成功 console.log(channelA onopen, e); }; this.channelA.onclose = (e) => { // 監聽連接關閉 console.log(channelA onclose, e); }; this.channelA.onmessage = (e) => { // 監聽消息接收 this.receiveText = e.data; // 接收框顯示消息 console.log(channelA onmessage, e.data); }; };
建立對等連接的過程這裡就省略了,通過這兩段代碼就可以實現簡單的文本傳輸了。
ok,WebRTC 的三大 API 到這裡就講完了,接下來開始我們今天的第一個實戰慄子 — 白板演示。可能有的同學不太了解白板演示,通俗點講,就是你在白板上寫寫畫畫的東西,可以實時的讓對方看到。先來看一眼我的大作:
嗯,如上,白板操作會實時展示在演示畫面中。其實基於 WebRTC 做白板演示非常簡單,因為我們不需要視頻通話,所以不需要獲取本地媒體流。那我們可以直接把 Canvas 畫板作為一路媒體流來建立連接,這樣對方就能看到你的畫作了。怎麼把 Canvas 變成媒體流呢,這裡用到了一個神奇的 API:captureStream。
captureStream
this.localstream = this.$refs[canvas].captureStream();
一句話就可以把 Canvas 變成媒體流了,所以演示畫面仍然是 video 標籤在播放媒體流,只是這次不是從攝像頭獲取的流,而是 Canvas 轉換的。
現在點對點連接我們有了,白板流我們也有了,好像就缺一個能畫畫的 Canvas 了。說時遲那時快,看,Canvas 來了。源碼地址
從圖上我們可以看見這個畫板類需要哪些功能:繪製圓形、繪製線條、繪製矩形、繪製多邊形、橡皮擦、撤回、前進、清屏、線寬、顏色,這些是功能可選項。
再往細分析:
綜上,我們可以先列出大體的框架。
// Palette.js class Palette { constructor() { } gatherImage() { // 採集圖像 } reSetImage() { // 重置為上一幀 } onmousedown(e) { // 滑鼠按下 } onmousemove(e) { // 滑鼠移動 } onmouseup() { // 滑鼠抬起 } line() { // 繪製線性 } rect() { // 繪製矩形 } polygon() { // 繪製多邊形 } arc() { // 繪製圓形 } eraser() { // 橡皮擦 } cancel() { // 撤回 } go () { // 前進 } clear() { // 清屏 } changeWay() { // 改變繪製條件 } destroy() { // 銷毀 } }
任何繪製,都需要經過滑鼠按下,滑鼠移動,滑鼠抬起這幾步;
onmousedown(e) { // 滑鼠按下 this.isClickCanvas = true; // 滑鼠按下標識 this.x = e.offsetX; // 獲取滑鼠按下的坐標 this.y = e.offsetY; this.last = [this.x, this.y]; // 保存每次的坐標 this.canvas.addEventListener(mousemove, this.bindMousemove); // 監聽 滑鼠移動事件 } onmousemove(e) { // 滑鼠移動 this.isMoveCanvas = true; // 滑鼠移動標識 let endx = e.offsetX; let endy = e.offsetY; let width = endx - this.x; let height = endy - this.y; let now = [endx, endy]; // 當前移動到的坐標 switch (this.drawType) { case line : this.line(this.last, now, this.lineWidth, this.drawColor); // 繪製線條的方法 break; } } onmouseup() { // 滑鼠抬起 if (this.isClickCanvas) { this.isClickCanvas = false; this.canvas.removeEventListener(mousemove, this.bindMousemove); // 移除滑鼠移動事件 if (this.isMoveCanvas) { // 滑鼠沒有移動不保存 this.isMoveCanvas = false; this.gatherImage(); // 保存每次的圖像 } } }
代碼中滑鼠移動事件用的是 this.bindMousemove,這是因為我們需要綁定 this,但是 bind 後每次返回的並不是同一個函數,而移除事件和綁定的不是同一個的話,無法移除。所以需要用變數保存一下 bind 後的函數。
this.bindMousemove
this.bindMousemove = this.onmousemove.bind(this); // 解決 eventlistener 不能用 bind this.bindMousedown = this.onmousedown.bind(this); this.bindMouseup = this.onmouseup.bind(this);
在 this.line 方法中,我們將所有的參數採用函數參數的形式傳入,是為了共享畫板時需要同步繪製對方繪圖的每一步。在繪製線條的時候,採取將每次移動的坐標點連接成線的方式,這樣畫出來比較連續。如果直接繪製點,速度過快會出現較大的斷層。
this.line
line(last, now, lineWidth, drawColor) { // 繪製線性 this.paint.beginPath(); this.paint.lineCap = "round"; // 設定線條與線條間接合處的樣式 this.paint.lineJoin = "round"; this.paint.lineWidth = lineWidth; this.paint.strokeStyle = drawColor; this.paint.moveTo(last[0], last[1]); this.paint.lineTo(now[0], now[1]); this.paint.closePath(); this.paint.stroke(); // 進行繪製 this.last = now; // 更新上次的坐標 }
在滑鼠抬起的時候,用到了一個 gatherImage 方法,用來採集圖像,這也是撤回和前進的關鍵。
gatherImage() { // 採集圖像 this.imgData = this.imgData.slice(0, this.index + 1); // 每次滑鼠抬起時,將儲存的imgdata截取至index處 let imgData = this.paint.getImageData(0, 0, this.width, this.height); this.imgData.push(imgData); this.index = this.imgData.length - 1; // 儲存完後將 index 重置為 imgData 最後一位 }
回想一下之前提到的一個問題,在撤退到某一步且從這一步開始作畫的話,我們需要把這一步後續的圖像都刪除,以免造成混亂。所以我們用一個全局的 index 作為當前繪製的是第幾幀圖像的標識,在每次保存的圖像的時候,都截取一次圖像緩存數組 imgData,用以跟 index 保持一致,儲存完後將 index 重置到最後一位。
cancel() { // 撤回 if (--this.index <0) { // 最多重置到 0 位 this.index = 0; return; } this.paint.putImageData(this.imgData[this.index], 0, 0); // 繪製 } go () { // 前進 if (++this.index > this.imgData.length -1) { // 最多前進到 length -1 this.index = this.imgData.length -1; return; } this.paint.putImageData(this.imgData[this.index], 0, 0); }
橡皮擦我們用到了 Canvas 的一個屬性,clip 裁切。簡單來說,就是將圖像繪製一個裁剪區域,後續的操作便都只會作用域該區域。所以當我們把裁剪區域設置成一個小圓點的時候,後面就算清除整個畫板,實際也只清除了這個圓點的範圍。清除完以後,再將其還原。
eraser(endx, endy, width, height, lineWidth) { // 橡皮擦 this.paint.save(); // 緩存裁切前的 this.paint.beginPath(); this.paint.arc(endx, endy, lineWidth / 2, 0, 2 * Math.PI); this.paint.closePath(); this.paint.clip(); // 裁切 this.paint.clearRect(0, 0, width, height); this.paint.fillStyle = #fff; this.paint.fillRect(0, 0, width, height); this.paint.restore(); // 還原 }
在繪製矩形等這種形狀是,因為其並不是一個連續的動作,所以應該以滑鼠最後的位置為坐標進行繪製。那麼這個時候應該不斷清除畫板並重置為上一幀的圖像(這裡的上一幀是指,滑鼠按下前的,因為滑鼠抬起才會保存一幀圖像,顯然,移動的時候沒有保存)。
看一下不做重置的現象,應該更容易理解。下面,就是見證奇蹟的時刻:
rect(x, y, width, height, lineWidth, drawColor) { // 繪製矩形 this.reSetImage(); this.paint.lineWidth = lineWidth; this.paint.strokeStyle = drawColor; this.paint.strokeRect(x, y, width, height); } reSetImage() { // 重置為上一幀 this.paint.clearRect(0, 0, this.width, this.height); if(this.imgData.length >= 1){ this.paint.putImageData(this.imgData[this.index], 0, 0); } }
Canvas 封裝就講到這裡,因為剩下的基礎功能都類似,做共享畫板的時候還有一點小改動,我們後續會提到。源碼在這裡
這下準備工作都做好了,對等連接該上了。我們不需要獲取媒體流,而是用 Canvas 流代替。
async createMedia() { // 保存canvas流到全局 this.localstream = this.$refs[canvas].captureStream(); this.initPeer(); // 獲取到媒體流後,調用函數初始化 RTCPeerConnection }
剩下的工作就和我們上期的 1 v 1 本地連接一模一樣了,這裡不再粘貼,需要得同學可以查看上期文章或者直接查看源碼。
做了這麼多鋪墊,一切都是為了今天的終極目標,完成一個多人協作的共享畫板。實際上,在共享畫板中要用到的知識點,我們都已經講完了。我們基於上期的 1 v 1 網路連接做一些改造,先重溫一下前言中的那張圖。
仔細看一下我圈住的地方,從登錄人可以看出,這是我在兩個瀏覽器打開的頁面截圖。當然你們也可以直接去線上地址實際操作一下。兩個頁面,兩個畫板,兩個人都可以操作,各自的操作也會分別同步到對方的畫板上。右邊是一個簡單的聊天室,所有的數據同步以及聊天消息都是基於今天講的 RTCDataChannel 來做的。
這次不需要視頻流,也不需要 Canvas 流,所以我們在點對點連接時直接建立數據通道。
createDataChannel() { // 創建 DataChannel try{ this.channel = this.peer.createDataChannel(messagechannel); this.handleChannel(this.channel); } catch (e) { console.log(createDataChannel:, e); } }, onDataChannel() { // 接收 DataChannel this.peer.ondatachannel = (event) => { // console.log(ondatachannel, event); this.channel = event.channel; this.handleChannel(this.channel); }; }, handleChannel(channel) { // 處理 channel channel.binaryType = arraybuffer; channel.onopen = (event) => { // 連接成功 console.log(channel onopen, event); this.isToPeer = true; // 連接成功 this.loading = false; // 解除 loading this.initPalette(); }; channel.onclose = function(event) { // 連接關閉 console.log(channel onclose, event) }; channel.onmessage = (e) => { // 收到消息 this.messageList.push(JSON.parse(e.data)); // console.log(channel onmessage, e.data); }; }
分別在 呼叫端 和 接收端 創建 channel。部分代碼省略。
// 呼叫端 socket.on(reply, async data =>{ // 收到回復 this.loading = false; switch (data.type) { case 1: // 同意 this.isCall = data.self; // 對方同意之後創建自己的 peer await this.createP2P(data); // 建立DataChannel await this.createDataChannel(); // 並給對方發送 offer this.createOffer(data); break; ··· } }); // 接收端 socket.on(apply, data => { // 收到請求 ··· this.$confirm(data.self + 向你請求視頻通話, 是否同意?, 提示, { confirmButtonText: 同意, cancelButtonText: 拒絕, type: warning }).then(async () => { await this.createP2P(data); // 同意之後創建自己的 peer 等待對方的 offer await this.onDataChannel(); // 接收 DataChannel ··· }).catch(() => { ··· }); });
連接成功後,就可以進行簡單的聊天了,和之前講 API 時的栗子基本一樣。本次只實現了簡單的文本聊天,DataChannel 還支持文件傳輸,這個我們以後有機會再講。另外筆者之前還寫過 http://Socket.io 實現的好友群聊等,感興趣的同學可以看看 ??????Vchat — 從頭到腳,擼一個社交聊天系統(vue + node + mongodb)。
send(arr) { // 發送消息 if (arr[0] === text) { let params = {account: this.account, time: this.formatTime(new Date()), mes: this.sendText, type: text}; this.channel.send(JSON.stringify(params)); this.messageList.push(params); this.sendText = ; } else { // 處理數據同步 this.channel.send(JSON.stringify(arr)); } }
一直說需要將各自的畫板操作同步給對方,那到底什麼時機來觸發同步操作呢?又需要同步哪些數據呢?在之前封裝畫板類的時候我們提到過,所有繪圖需要的數據都通過參數形式傳遞。
this.line(this.last, now, this.lineWidth, this.drawColor);
所以很容易想到,我們只需要在每次自己繪圖也就是滑鼠移動時,將繪圖所需的數據、操作的類型(也許是撤回、前進等操作)都發送給對方就可以了。在這裡我們利用一個回調函數去通知頁面什麼時候開始給對方發送數據。
// 有省略 constructor(canvas, {moveCallback}) { ··· this.moveCallback = moveCallback || function () {}; // 滑鼠移動的回調 } onmousemove(e) { // 滑鼠移動 this.isMoveCanvas = true; let endx = e.offsetX; let endy = e.offsetY; let width = endx - this.x; let height = endy - this.y; let now = [endx, endy]; // 當前移動到的位置 switch (this.drawType) { case line : { let params = [this.last, now, this.lineWidth, this.drawColor]; this.moveCallback(line, ...params); this.line(...params); } break; case rect : { let params = [this.x, this.y, width, height, this.lineWidth, this.drawColor]; this.moveCallback(rect, ...params); this.rect(...params); } break; case polygon : { let params = [this.x, this.y, this.sides, width, height, this.lineWidth, this.drawColor]; this.moveCallback(polygon, ...params); this.polygon(...params); } break; case arc : { let params = [this.x, this.y, width, height, this.lineWidth, this.drawColor]; this.moveCallback(arc, ...params); this.arc(...params); } break; case eraser : { let params = [endx, endy, this.width, this.height, this.lineWidth]; this.moveCallback(eraser, ...params); this.eraser(...params); } break; } }
看起來挺丑,但是這麼寫是有原因的。首先 moveCallback 不能放在相應操作函數的下面,因為都是同步操作,有些值在繪圖完成後會發生改變,比如 last 和 now ,繪圖完成後,二者相等。
其次,不能將 moveCallback 寫在相應操作函數內部,否則會無限循環。你想,你畫了一條線,Callback 通知對方也畫一條,對方也要調用 line 方法繪製相同的線。結果倒好,Callback 在 line 方法內部,它立馬又得反過來告訴你,這樣你來我往,一回生二回熟,來而不往非禮也,額,不好意思,說快了。反正會造成一些麻煩。
頁面收到 Callback 通知以後,直接調用 send 方法,將數據傳遞給對方。
moveCallback(...arr) { // 同步到對方 this.send(arr); }, send(arr) { // 發送消息 if (arr[0] === text) { ··· } else { // 處理數據同步 this.channel.send(JSON.stringify(arr)); } }
接收到數據後,調用封裝類相應方法進行繪製。
handleChannel(channel) { // 處理 channel ··· channel.onmessage = (e) => { // 收到消息 普通消息類型是 對象 if (Array.isArray(JSON.parse(e.data))) { // 如果收到的是數組,進行結構 let [type, ...arr] = JSON.parse(e.data); this.palette[type](...arr); // 調用相應方法 } else { this.messageList.push(JSON.parse(e.data)); // 接收普通消息 } // console.log(channel onmessage, e.data); }; }
至此,我們本期的主要內容就講完了,我們講了雙向數據通道 RTCDataChannel 的使用,簡單的白板演示以及雙人協作的共享畫板。因為很多內容是基於上一期的示例改造的,所以省略了一些基礎代碼,不好理解的同學建議兩期結合起來看(我是比較啰嗦了,來來回回說了好幾遍,主要還是希望大家看的時候能有所收穫)。
qq前端交流群:960807765,歡迎各種技術交流,期待你的加入
如果你看到了這裡,且本文對你有一點幫助的話,希望你可以動動小手支持一下作者,感謝??。文中如有不對之處,也歡迎大家指出,共勉。
更多文章:
歡迎關注公眾號 前端發動機,第一時間獲得作者文章推送,還有海量前端大佬優質文章,致力於成為推動前端成長的引擎。
推薦閱讀: