筆者以前寫過一篇 【從頭到腳】擼一個多人視頻聊天 — 前端 WebRTC 實戰(一),主要講 WebRTC 的一些基礎知識以及單人通話的簡單實現。原計劃這篇寫多人通話的,鑑於有同窗留言說想看畫板,因此把這篇文章提早了,但願能夠給你們提供一些思路。javascript
本期的主要內容,即是實現一個共享畫板,還有上期沒講的一個知識點:RTCDataChannel 。前端
特別注意:介於本次的實現多基於上期的知識點以及相關示例,因此強烈建議不太瞭解 WebRTC 基礎的同窗,配合上篇一塊兒看 傳送門。最近文章的相關示例都集中在一個項目裏,截至本期目錄以下:vue
照例先看下本期的實戰目標(靈魂畫手上線):實現一個能夠兩人(基於上期文章的 1 對 1 對等鏈接)協做做畫的畫板。是什麼概念呢?簡單來講就是兩我的能夠共享一個畫板,均可以在上面做畫。java
先來感覺一下恐懼!顫抖吧!人類!(圖爲白板演示,共享在下面) node
咱們先把上期留下的知識點補上,由於今天的栗子也會用到它。git
簡單來講,RTCDataChannel 就是在點對點鏈接中創建一個雙向的數據通道,從而得到文本、文件等數據的點對點傳輸能力。它依賴於流控制傳輸協議(SCTP),SCTP 是一種傳輸協議,相似於 TCP 和 UDP,能夠直接在 IP 協議之上運行。可是,在 WebRTC 的狀況下,SCTP 經過安全的 DTLS 隧道進行隧道傳輸,該隧道自己在 UDP 之上運行
。 嗯,我是個學渣,對於這段話我也只能說是,看過!你們能夠直接 查看原文。github
另外總的來講 RTCDataChannel 和 WebSocket 很像,只不過 WebSocket 不是 P2P 鏈接,須要服務器作中轉。web
RTCDataChannel 經過上一期講過的 RTCPeerConnection 來建立。mongodb
// 建立
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
建立通道。canvas
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
。
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.onmousemove.bind(this); // 解決 eventlistener 不能用 bind
this.bindMousedown = this.onmousedown.bind(this);
this.bindMouseup = this.onmouseup.bind(this);
複製代碼
在 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 還支持文件傳輸,這個咱們之後有機會再講。另外筆者以前還寫過 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,歡迎各類技術交流,期待你的加入
若是你看到了這裏,且本文對你有一點幫助的話,但願你能夠動動小手支持一下做者,感謝🍻。文中若有不對之處,也歡迎你們指出,共勉。
更多文章: