笔者以前写过一篇 【从头到脚】撸一个多人视频聊天 — 前端 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,欢迎各类技术交流,期待你的加入
若是你看到了这里,且本文对你有一点帮助的话,但愿你能够动动小手支持一下做者,感谢🍻。文中若有不对之处,也欢迎你们指出,共勉。
更多文章: