【從頭到腳】WebRTC + Canvas 實現一個雙人協做的共享畫板 | 掘金技術徵文

前言

筆者以前寫過一篇 【從頭到腳】擼一個多人視頻聊天 — 前端 WebRTC 實戰(一),主要講 WebRTC 的一些基礎知識以及單人通話的簡單實現。原計劃這篇寫多人通話的,鑑於有同窗留言說想看畫板,因此把這篇文章提早了,但願能夠給你們提供一些思路。javascript

本期的主要內容,即是實現一個共享畫板,還有上期沒講的一個知識點:RTCDataChannel 。前端

特別注意:介於本次的實現多基於上期的知識點以及相關示例,因此強烈建議不太瞭解 WebRTC 基礎的同窗,配合上篇一塊兒看 傳送門。最近文章的相關示例都集中在一個項目裏,截至本期目錄以下:vue

照例先看下本期的實戰目標(靈魂畫手上線):實現一個能夠兩人(基於上期文章的 1 對 1 對等鏈接)協做做畫的畫板。是什麼概念呢?簡單來講就是兩我的能夠共享一個畫板,均可以在上面做畫。java

先來感覺一下恐懼!顫抖吧!人類!(圖爲白板演示,共享在下面) node

RTCDataChannel

咱們先把上期留下的知識點補上,由於今天的栗子也會用到它。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

  • label:建立時提到的別名。
  • ordered:指發送的消息是否須要按照它們的發送順序到達目的地(true),或者容許它們無序到達(false)。默認值:true。
  • binaryType:是一個 DOMString 類型,表示發送的二進制數據的類型。值爲 blob 或 arraybuffer,默認值爲 "blob"。
  • readyState:表示數據鏈接的狀態:
    • connecting 等待鏈接,也是建立初始狀態。
    • open 鏈接成功而且運行。
    • closing 鏈接關閉中,不會接受新的發送任務,可是緩衝隊列中的消息仍是會被繼續發送或者接收。也就是沒發送完的會繼續發送。
    • closed 鏈接徹底被關閉。

前面說 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 了。說時遲那時快,看,Canvas 來了。源碼地址

  • 功能點

從圖上咱們能夠看見這個畫板類須要哪些功能:繪製圓形、繪製線條、繪製矩形、繪製多邊形、橡皮擦、撤回、前進、清屏、線寬、顏色,這些是功能可選項。

再往細分析:

  1. 繪製各類形狀,確定要用到鼠標事件,來記錄鼠標移動的位置從而進行繪圖;
  2. 繪製多邊形,須要用戶選擇究竟是幾邊形,最少固然是 3 邊,也就是三角形;
  3. 線寬和顏色也是用戶能夠改變的東西,因此咱們須要提供一個接口,用來修改這些屬性;
  4. 撤回和前進,意味着咱們須要保存每次繪製的圖像,保存時機在鼠標擡起的時候;並且撤回和前進不是無限制的,有邊界點;
  5. 試想一下:當你繪製了 5 步,如今撤回到了第 3 步,想在第 3 步的基礎上再次進行繪製,這時候是否是應該把第 4 步和第 5 步清除掉?若是不清除,新繪製的算第幾步?

綜上,咱們能夠先列出大致的框架。

// 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,歡迎各類技術交流,期待你的加入

後記

若是你看到了這裏,且本文對你有一點幫助的話,但願你能夠動動小手支持一下做者,感謝🍻。文中若有不對之處,也歡迎你們指出,共勉。

更多文章:

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

相關文章
相關標籤/搜索