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

江三瘋發表於2019-04-02

前言

筆者之前寫過一篇 【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一),主要講 WebRTC 的一些基礎知識以及單人通話的簡單實現。原計劃這篇寫多人通話的,鑑於有同學留言說想看畫板,所以把這篇文章提前了,希望可以給大家提供一些思路。

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

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

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

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

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

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

RTCDataChannel

我們先把上期留下的知識點補上,因為今天的栗子也會用到它。

介紹

簡單來說,RTCDataChannel 就是在點對點連線中建立一個雙向的資料通道,從而獲得文字、檔案等資料的點對點傳輸能力。它依賴於流控制傳輸協議(SCTP),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 建立通道。

RTCDataChannel 的一些屬性,更多可以檢視 MDN

  • label:建立時提到的別名。
  • ordered:指傳送的訊息是否需要按照它們的傳送順序到達目的地(true),或者允許它們無序到達(false)。預設值:true。
  • binaryType:是一個 DOMString 型別,表示傳送的二進位制資料的型別。值為 blob 或 arraybuffer,預設值為 "blob"。
  • readyState:表示資料連線的狀態:
    • connecting 等待連線,也是建立初始狀態。
    • open 連線成功並且執行。
    • closing 連線關閉中,不會接受新的傳送任務,但是緩衝佇列中的訊息還是會被繼續傳送或者接收。也就是沒傳送完的會繼續傳送。
    • closed 連線完全被關閉。

前面說 RTCDataChannel 和 WebSocket 很像是真的很像,我們基於上期的本地 1 對 1 連線,簡單看一下用法。

這裡還是說一下,系列文章就是這點比較麻煩,後面的很多內容都是基於前面的基礎的,但是有很多同學又沒看過之前的文章。但是我也不能每次都把之前的內容再重複一遍,所以還是強烈建議有需求的同學,結合之前的文章一起看 傳送門,希望大家理解。

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

一個簡單的收發訊息的功能,我們已經知道了在 呼叫端接收端 分別拿到 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 實現一個雙人協作的共享畫板 | 掘金技術徵文

嗯,如上,白板操作會實時展示在演示畫面中。其實基於 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(); // 還原
}
複製程式碼
  • 矩形

在繪製矩形等這種形狀是,因為其並不是一個連續的動作,所以應該以滑鼠最後的位置為座標進行繪製。那麼這個時候應該不斷清除畫板並重置為上一幀的影象(這裡的上一幀是指,滑鼠按下前的,因為滑鼠抬起才會儲存一幀影象,顯然,移動的時候沒有儲存)。

看一下不做重置的現象,應該更容易理解。下面,就是見證奇蹟的時刻:

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

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 網路連線做一些改造,先重溫一下前言中的那張圖。

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

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

仔細看一下我圈住的地方,從登入人可以看出,這是我在兩個瀏覽器開啟的頁面截圖。當然你們也可以直接去線上地址實際操作一下。兩個頁面,兩個畫板,兩個人都可以操作,各自的操作也會分別同步到對方的畫板上。右邊是一個簡單的聊天室,所有的資料同步以及聊天訊息都是基於今天講的 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 使用體驗徵文大賽 | 掘金技術徵文,徵文活動正在進行中

相關文章