WebRTC (Web Real-Time Communication)是一個可以用在視頻聊天,音頻聊天或P2P文件分享等Web App中的 API。
全名叫web的實時通信,從官方文檔可以看出來他可以用來視頻聊天,音頻聊天,端對端(p2p),數(shù)據(jù)傳輸,文件分享的一個api?,F(xiàn)在的直播用的就是這個技術(shù)
webrtc下有三個重要的api,正好對應(yīng)三個功能。
getUserMedia 請求獲取用戶的媒體信息包括視頻流(video)和音頻流(audio)
RTCPeerConnection 代表一個由本地計算機到遠端的WebRTC連接,用于實現(xiàn)端對端的連接。該接口提供了創(chuàng)建,保持,監(jiān)控,關(guān)閉連接的方法的實現(xiàn)。
RTCDataChannel 代表在兩者之間建立了一個雙向數(shù)據(jù)通道的連接,是一個數(shù)據(jù)通道,傳輸數(shù)據(jù)
getUserMedia
首先我們先實現(xiàn)一個簡單的獲取視頻和音頻并且顯示在網(wǎng)頁上
javasrcipt
// 獲取本地的視頻和音頻流,{ audio: true, video: true }都是true這兩個都獲取
let localStream
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then((stream) => {localStream = stream})
//找到video標簽,用一個video來接受流,并且顯示
let video = document.querySelector("#video")
// 使用srcObject給video添加流
video.srcObject = localStream
html
<video id="video" autoplay style="width:600; height:400;"></video>
因為我們這里只需要獲得數(shù)據(jù)流,這里就不具體的解釋api,我們可以去看官方文檔MDN。 從這里可以看我們只需要一個簡單的api就能獲得到本地的視頻和音頻流,我們最后肯定是需要將這個流發(fā)送到其他的客戶端的,如何發(fā)送流呢,我們通過RTCPeerConnection來進行連接以及流的傳輸。
navigator.getUserMedia 目前是還是支持的。但是在官方文檔中已經(jīng)不推薦使用,應(yīng)該使用navigator.MediaDevices上的getUserMedia(),但是該api目前不是所有瀏覽器都支持,有兼容性問題
為了避免兼容性問題,我們可以用以下代碼來進行兼容性適配
//瀏覽器不支持navigator.mediaDevices
if (navigator.mediaDevices == undefined) {
navigator.mediaDevices = {}
navigator.mediaDevices.getUserMedia = function (constraints) {
//獲得舊版的getUserMedia
let getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia
//瀏覽器就不支持getUserMedia這個api,則返回個錯誤
if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia is can not use in the browser'))
}
// getUserMedia是異步的,所以用Promise,將返回一個綁定在navigator上的getUserMedia
return new Promise((resolve, reject) => {
getUserMedia.call(navigator, constraints, resolve, reject)
})
}
}
RTCPeerConnection
這是實現(xiàn)端對端(既不通過服務(wù)器進行數(shù)據(jù)交換)連接的最重要的api,這也是最難理解的一部分。
端對端的連接第一次是需要借助服務(wù)器來連接的,需要服務(wù)器來進行中轉(zhuǎn),當?shù)谝淮芜B接上后就不需要再通過服務(wù)器了。這里我們使用socket.io,以及一點點koa,這個我們后面再講。也有其他方式我們這里不講有興趣的可以看江三瘋大佬的文章??傊谝淮问切枰?wù)器來實現(xiàn)兩端的連接。
接下來是具體的交換過程
創(chuàng)建RTCPeerConnection的實例
交換本地和遠程的sdp數(shù)據(jù)描述,使用offer和answer來進行nat穿透,建立p2p
交換ice網(wǎng)絡(luò)信息,用于聯(lián)網(wǎng)的時候的網(wǎng)絡(luò)信息交換
創(chuàng)建RTCPeerConnection的實例
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
let peer = new PeerConnection(iceServers)
這里有個參數(shù)iceServers,參數(shù)中存在兩個屬性,分別是stun和turn。是用于NAT穿透的,具體可以看WebRTC in the real world: STUN TURN and signaling
{
iceServers: [
{ url: "stun:stun.l.google.com:19302"}, // 谷歌的公共服務(wù)
{
url: "turn:***",
username: ***, // 用戶名
credential: *** // 密碼
}
]
}
NAT
先說下我們?yōu)槭裁匆肗AT穿透技術(shù)才能實現(xiàn)p2p的連接。
NAT全稱(Network Address Translation,網(wǎng)絡(luò)地址轉(zhuǎn)換),是用于網(wǎng)絡(luò)的地址交換,這會導(dǎo)致我們得不到設(shè)備真實的ip地址
由于外網(wǎng)用的是IPV4的地址碼,導(dǎo)致地址碼的數(shù)量不夠,于是就將會使用路由之類的NAT設(shè)備將外網(wǎng)的ip地址以及端口號都修改并使用IPV6的地址,使得多個內(nèi)網(wǎng)可以該外網(wǎng)。這樣增加了網(wǎng)絡(luò)連接數(shù)量,但是卻使得我們無法從內(nèi)網(wǎng)直接找到對方的內(nèi)網(wǎng),所以我們需要進行NAT穿透,來實現(xiàn)端對端的連接。
NAT穿透的大致步驟是如A,B兩端,A段向B端發(fā)送一條信息,這條信息是會被NAT設(shè)備給丟棄,但是會在NAT上留下一個洞,下次信息就可以通過這個洞來傳輸,同理B也這一發(fā)送一條信息,來打通自己的NAT設(shè)備。具體實現(xiàn)使用STUN和TURN來進行NAT穿透,該過程是通過STUN Server來進行NAT穿透,如果無法穿透則需要使用TURN Server來進行中轉(zhuǎn),具體是如何穿透的可以看ICE協(xié)議下NAT穿越的實現(xiàn)(STUN&TURN),另外我們可以搭建自己的STUN 和 TURN,自己動手搭建 WebRTC TURN&STUN 服務(wù)器
STUN(Simple Traversal of User Datagram Protocol through Network Address Translators (NATs),NAT的UDP簡單穿越)是一種網(wǎng)絡(luò)協(xié)議
TURN的全稱為Traversal Using Relay NAT,TURN協(xié)議允許NAT或者防火墻后面的對象可以通過TCP或者UDP接收到數(shù)據(jù)
P2P
現(xiàn)在我們已經(jīng)了解了NAT穿透,現(xiàn)在讓我們用PeerConnection來實現(xiàn)p2p連接。上文中我們已經(jīng)創(chuàng)建了PeerConnection的實例,我們稱他為localPeer,remotePeer?,F(xiàn)在我們來交換本地和遠程的sdp數(shù)據(jù)描述,先上代碼。
localPeer.createOffer()
.then(offer => localPeer.setLocalDescription(offer))
.then(() => remotePeer.setRemoteDescription(localPeer.localDescription))
.then(() => remotePeer.createAnswer())
.then(answer => remotePeer.setLocalDescription(answer))
.then(() => localPeer.setRemoteDescription(remotePeer.localDescription))
實現(xiàn)交換本地和遠程的sdp數(shù)據(jù)描述和我們之前的NAT穿透的步驟很像。
localPeer調(diào)用createOffer()api來創(chuàng)建一個offer類型的sdp,并使用setLocalDescription()將其添加到localDescription,這里我們只是在本地建立p2p,不需要服務(wù)器,來第一次連接
remotePeer接受到localPeer的localDescription,并使用setRemoteDescription將其添加到自己的RemoteDescription
remotePeer通過createAnswer()創(chuàng)建一個answer類型的sdp,并將其添加到自己的LocalDescription
localPeer將remotePeer的localDescription添加為自己的remoteDescription
到這里兩端的sdp數(shù)據(jù)交換就已經(jīng)完成,也就代表了本地的p2p已經(jīng)連接好了,但是我們這里是在同一個界面創(chuàng)建了兩個端,是無法真正的p2p,如果要使用網(wǎng)絡(luò)的p2p我們就需要使用ice實現(xiàn)網(wǎng)絡(luò)的對等連接,并且還需要socket.io來建立第一次數(shù)據(jù)傳輸
SDP
SDP(Session Description Protocol,會話描述協(xié)議) 它不屬于傳輸協(xié)議, 但是可以使用多種的傳輸協(xié)議,包括會話通知協(xié)議(SAP)、會話初始協(xié)議(SIP)、實時流協(xié)議(RTSP)、MIME 擴展協(xié)議的電子郵件以及超文本傳輸協(xié)議(HTTP)。
這是一個具體的sdp,是本地媒體元數(shù)據(jù),詳情可以去看P2P通信標準協(xié)議(三)之ICE
v=0
o=- 43013583 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
a=msid-semantic: WMS
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
讓我們再看下offer
可以看到offer是一個offer類型的sdp,answer也是同理
ICE
ICE的全稱為Interactive Connectivity Establishment,即交互式連接建立。ICE是一個用于在offer/answer模式下的NAT傳輸協(xié)議,主要用于UDP下多媒體會話的建立,使用了STUN協(xié)議以及TURN 協(xié)議
如果我們需要實現(xiàn)網(wǎng)絡(luò)的p2p就需要進行兩端的ice協(xié)議連接。這里我們需要用到
RTCPeerConnection.onicecandidate()api用于監(jiān)視本地ice網(wǎng)絡(luò)的變化,如果有了就將其使用socket.io發(fā)送出去,
RTCPeerConnection.addIceCandidate()用于將收到的ice添加到本地的RTCPeerConnection實例中。
傳輸stream流 當建立好了p2p后我們可以使用RTCPeerConnection實例中的
addstream() 添加本地的媒體流,
onaddstream() 檢測本地的媒體流,
onaddstream()在接送端answer的setRemoteDescription執(zhí)行完成后會立即執(zhí)行,也就是說我們不能在p2p創(chuàng)建完成后在使用addstream來添加流。
addstream()和onaddstream()已經(jīng)在官方文檔中不推薦使用,我們最好使用更新的addTrack()和onaddTrack(),有興趣可以看MDN
RTCDataChannel
RTCDataChannel用于p2p中的數(shù)據(jù)通道,我們使用的是RTCPeerConnection中的createDataChannel()來創(chuàng)建一個TCDataChannel實例。這里我們假設(shè)創(chuàng)建了一個實例叫channel,這里我們需要的api有
channel.send() channel主動向已連接的通道發(fā)送數(shù)據(jù)
ondatachannel() 監(jiān)視是channel是否發(fā)生改變,比如打開(onopen),關(guān)閉(onclose),獲得send過來的數(shù)據(jù)(onmessage)
//發(fā)送數(shù)據(jù)hello
channel.send(JSON.stringify('hello'))
// 監(jiān)聽channel的狀態(tài)
peer.ondatachannel = (event) => {
var channel = event.channel
channel.binaryType = 'arraybuffer'
channel.onopen = (event) => { // 連接成功
console.log('channel onopen')
}
channel.onclose = function(event) { // 連接關(guān)閉
console.log('channel onclose')
}
channel.onmessage = (event) => { // 收到消息
let data = JSON.parse(event.data)
console.log('channel onmessage', data)
}
}
到這里我們的webrtc基礎(chǔ)已經(jīng)寫完了,我們雖然webrtc是一個不需要服務(wù)器的p2p,但是我們第一次連接是需要服務(wù)器來幫我們找到響應(yīng)的端的,從而將offer,answer,ice等信息進行交互,建立p2p連接。接下來我們就使用koa和socket.io作為服務(wù)器來進行首次的連接,以及一些業(yè)務(wù)邏輯交互。
koa&socket.io
koa
koa是一個為一個HTTP服務(wù)的中間件框架,極其的輕量級,幾乎沒有集成,很多功能需要我們安裝插件才能使用。并且使用的是es6的語法,使用的是async來實現(xiàn)異步。
我們需要創(chuàng)建一個server.js來部署服務(wù)器。
import Koa from 'koa'
import { join } from 'path'
import Static from 'koa-static'
import Socket from 'socket.io'
// 創(chuàng)建一個socket.io
const io = new Socket({
options : {
pingTimeout: 10000,
pingInterval: 5000
}
})
// 創(chuàng)建koa
const app = new Koa()
// socket注入app
io.attach(app)
// 添加指定靜態(tài)web文件的Static路徑
// Static(root, opts) 這里將public作為根路徑
app.use(Static(
// join 拼接路徑
// __dirname返回被執(zhí)行文件夾的絕對路徑
join( __dirname, './public')
))
// 服務(wù)器端口號,這里兩個listen外面的是socket.io的,后面一個是koa的listen,需要將socket監(jiān)聽koa的端口,不然會報錯
io.listen(app.listen(3000, () => {
console.log('server start at port: ' + 3000)
}))
socket.io
我們先來介紹下WebSocket網(wǎng)絡(luò)協(xié)議,他是不同于http協(xié)議的一種,具體可以看websocket
socket.io是服務(wù)器使用的是WebSocket網(wǎng)絡(luò)協(xié)議,是HTML5新增的一種通信協(xié)議,其特點是服務(wù)端可以主動向客戶端推送信息,客戶端也可以主動向服務(wù)端發(fā)送信息,是真正的雙向平等對話,屬于服務(wù)器推送技術(shù)的一種。
這樣我們就可以通過兩端的主動發(fā)送打服務(wù)器,以及服務(wù)器主動發(fā)送到雙端,來實現(xiàn)交互。 我們需要使用socket.io的api
socket.on('event', () => {}) 監(jiān)聽socket觸發(fā)的事件
socket.emit('event', () => {}) 主動發(fā)送
socket.join('room', () => {}) 加入房間
socket.leave('room', () => {}) 離開房間
socket.to(room | socket.id) | socket.in(room | socket.id) 指定房間,或者服務(wù)器
首先客戶端和服務(wù)器端相互連接。由于服務(wù)器端設(shè)置了端口號為3000,我們的html頁端的socket服務(wù)器
// html
// 引入
<script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
// 連接3000端口
var socket = io('ws://localhost:3000/')
// server.js
// 監(jiān)聽連接
// io是服務(wù)器端的, socket是客戶端的
io.on('connection', socket => {
...
})
// 監(jiān)聽關(guān)閉
io.on('disconnect', socket => {})
我們通過socket的來實現(xiàn)webrtc的第一次連接
// A 向 B 的p2p
// html
// A
// user 是全局變量,存在sessionStorage中, 創(chuàng)建時候獲取
var user = window.sessionStorage.user || ''
// 發(fā)給服務(wù)器改socket的名稱
socket.emit('createUser', 'A')
// 兼容性
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
var peer = new PeerConnection()
// 創(chuàng)建A端的offer
peer.createOffer()
.then(offer => {
// 設(shè)置A端的本地描述
peer.setLocalDescription(offer, () => {
// socket發(fā)送offer和房間
socket.emit('offer', {offer: offer, user: 'B'})
})
})
// 監(jiān)聽本地的ice變化,有則發(fā)送個B
peer.onicecandidate = (event) => {
if (event.candidate) {

// B
// user 是全局變量,存在sessionStorage中, 創(chuàng)建時候獲取
var user = window.sessionStorage.user || ''
// 發(fā)給服務(wù)器改socket的名稱
socket.emit('createUser', 'A')
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
var peer = new PeerConnection()
// 接受服務(wù)器端發(fā)過來的offer辨識的數(shù)據(jù)
socket.on('offer', date => {
// 設(shè)置B端的遠程offer 描述
peer.setRemoteDescription(data.offer, () => {
// 創(chuàng)建B的Answer
peer.createAnswer()
.then(answer => {
// 設(shè)置B端的本地描述
peer.setLocalDescription(answer, () => {
socket.emit('answer', {answer: answer, user: 'A'})
})
})
})
})
socket.on('ice', data => {
// 設(shè)置B ICE
peer.addIceCandidate(data.candidate);
})
socket.emit('createUser', 'B')
// server.js
// 用于接受客戶端的用戶名對應(yīng)的服務(wù)器
const sockets = {}
// 保存user
const users = {}
io.on('connection', data => {
// 創(chuàng)建賬戶
socket.on('createUser', data => {
let user = new User(data)
users[data] = user
sockets[data] = socket
})
socket.on('offer', data => {
// 通過B的socket的id只發(fā)送給B
socket.to(sockets[data.user].id).emit('offer', data)
})
socket.on('answer', data => {
// 通過B的socket的id只發(fā)送給A
socket.to(sockets[data.user].id).emit('answer', data)
})
socket.on('ice', data => {
// ice發(fā)送給B
socket.to(sockets[data.user].id).emit('ice', data)
})
})
以上就是通過socket.io來實現(xiàn)p2p的第一次連接。和我們在webrtc基礎(chǔ)的過程是一樣的,只是通過了server.js來進行中轉(zhuǎn)。在之后的業(yè)務(wù)邏輯中我們需要對多種不同的服務(wù)器群進行廣播,這里我們來擴展下socket的廣播的種類。
io.emit() 對連接了服務(wù)器的所有客戶端進行廣播,比如顯示房間信息
io.to(room).emit() 對一個房間中的所有客戶端進行廣播,用于房間內(nèi)的通知
socket.to(room).emit() 發(fā)送個房間中除了自己以為的服務(wù)器
socket.emit() 發(fā)送給服務(wù)器自己
socket.to(socket.id).emit() 發(fā)送給指定的服務(wù)器
到這里關(guān)于socket.io的我們一些api的使用和使用socket.io來實現(xiàn)p2p我們已經(jīng)了解了,接下來我們將下關(guān)于canvas實現(xiàn)一個畫板
canvas
cnavas是html5中的畫板,我們可以用它來實現(xiàn)在html上的繪畫功能,這里我們的畫板也是用這個做的。 實現(xiàn)畫板我們用一個類來進行封裝,需要實現(xiàn)以下的功能
畫筆,用來繪制圖案
橡皮,清除圖案
回退,回退到上一次繪畫
前進,前進到下一次繪畫
清除,清除所有的繪畫幾率
設(shè)置線條,用于設(shè)置畫筆和橡皮的寬度
設(shè)置顏色,用于設(shè)置畫筆顏色
操作函數(shù),用于根據(jù)不同的操作調(diào)用不同的函數(shù)
回調(diào)函數(shù),用于將事件進行回調(diào),用于數(shù)據(jù)的傳輸,同步畫板
所以我們可以寫出我們的canvas的繪制類
// 創(chuàng)建繪圖類
class Draw {
constructor(canvas, callBack) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.width = this.canvas.width
this.height = this.canvas.height
this.color = color
this.weight = weight
this.isMove = false
this.option = ''
// 保存每次鼠標按下并抬起的所繪制的圖片,用于撤回,前進
this.imgData = []
// 記錄當前幀
this.index = 0
// 現(xiàn)在的坐標
this.now = [0, 0]
// 移動前的坐標
this.last = [0, 0]
this.bindMousemove = this.onmousemove.bind(this)
this.callBack = callBack || function() {}
}
// 初始化
init() { }
// 監(jiān)聽鼠標按下
onmousedown(event) { }
// 監(jiān)聽鼠標移動
onmousemove(event) { }
// 監(jiān)聽鼠標抬起
onmouseup() { }
//繪制線條
line(last, now, weight, color) { }
// 橡皮
eraser(last, now, weight) { }
// 回退
back() { }
// 前進
go() { }
// 清除
clear() { }
// 收集每一幀的圖片
getImage() { }
// 繪制當前幀的圖片
putImage() { }
// 設(shè)置尺寸
setWeight(weight) { }
// 設(shè)置顏色
setColor(color) { }
// 所有的操作的合集
options(option, data) { }
}
復(fù)制代碼
我們來具體實現(xiàn)下這些方法
操作合集
options(option, data) {
switch (option) {
case 'pen': {
this.line(...data)
this.callBack('pen', data)
break
}
case 'eraser': {
this.eraser(...data)
this.callBack('eraser', data)
break
}
case 'getImage': {
this.callBack('getImage')
this.getImage()
break
}
case 'go': {
this.callBack('go')
this.go()
break
}
case 'back': {
this.callBack('back')
this.back()
break
}
case 'clear': {
this.callBack('clear')
this.clear()
break
}
case 'setWeight': {
this.callBack('setWeight', data)
this.setWeight(data)
break
}
case 'setColor': {
this.callBack('setColor', data)
this.setColor(data)
break
}
}
}
這里我們將所有操作的調(diào)用都放在一個方法中,這樣有利于代碼的重構(gòu),但是這樣做最主要的目的是為了,當我們將每個操作的回調(diào)函數(shù)寫在option方法中而不寫在具體操作的方法中,這樣可以避免當我們使用回調(diào)函數(shù)把參數(shù)傳遞出去的后,接收端使用該方法更新了自己的canvas后又會調(diào)用回調(diào)導(dǎo)致兩端的無限回調(diào)。
畫筆和橡皮
我們實現(xiàn)畫筆的思路是當鼠標按下時,我們監(jiān)聽鼠標的移動,鼠標以移動就將鼠標的位置參數(shù)傳遞給options函數(shù),options函數(shù)通過this.option來識別是畫筆還是橡皮,調(diào)用響應(yīng)的函數(shù)。當鼠標抬起時,結(jié)束移動事件的監(jiān)聽,并將當前幀進行保存,并且調(diào)用callback函數(shù)將保存針的信息傳遞出去。
onmousedown(event) {
this.last = [event.offsetX, event.offsetY]
this.canvas.addEventListener('mousemove', this.bindMousemove)
}
onmousemove(event) {
this.isMove = true
this.now = [event.offsetX, event.offsetY]
let data = [
this.last,
this.now,
this.weight,
this.color
]
this.options(this.option, data)
}
onmouseup() {
this.canvas.removeEventListener('mousemove', this.bindMousemove)
if (this.isMove) {
this.isMove = false
this.options('getImage')
}
}
line(last, now, weight, color) {
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
this.ctx.lineWidth = weight
this.ctx.strokeStyle = color
this.ctx.moveTo(last[0], last[1])
this.ctx.lineTo(now[0], now[1])
this.ctx.closePath()
this.ctx.stroke()
this.last = now
}
eraser(last, now, weight) {
this.ctx.save()
this.ctx.beginPath()
// console.log(now[0] , now[1])
this.ctx.arc(now[0], now[1], weight, 0, 2 * Math.PI)
this.ctx.closePath()
this.ctx.clip()
this.ctx.clearRect(0, 0, this.width, this.height)
this.ctx.fillStyle = '#fff'
this.ctx.fillRect(0, 0, this.width, this.height)
this.ctx.restore()
}
畫筆的具體實現(xiàn)
ctx.beginPath()表示開始繪制路徑,并且設(shè)置下線條的特點,顏色等。
ctx.moveTo(last[0], last[1])表示將筆的位置移動到一開始的位置,表示畫筆的其實位置。
ctx.lineTo(now[0], now[1])表示畫一條從(last[0], last[1])到(now[0], now[1])一條線。
this.ctx.closePath()關(guān)閉路徑繪制
ctx.stroke()使用線條來繪制,而不是填充
last = now 更新坐標點
橡皮的具體實現(xiàn)
ctx.save() 保存當前狀態(tài)
ctx.beginPath() 開始繪制路徑
ctx.arc(now[0], now[1], weight, 0, 2 * Math.PI) 繪制一個圓形,參數(shù)為圓心x,y,半徑r,以及開始的角度,結(jié)束的角度。這里開始角度為0是從x軸的正軸開始,一圈。就相當于我們以鼠標位移結(jié)束位置繪制了一個圓。
ctx.closePath() 關(guān)閉路徑繪制
ctx.clip() 是我們路徑繪制的另外一種方法,他將我們繪制的路徑進行剪切,使得我們之后的所有操作都會在這個路徑繪制區(qū)域,使用clip來進行路徑繪制,必須是封閉的路徑
ctx.clearRect(0, 0, this.width, this.height) 雖然這里清除整個屏幕,但是由于我們使用了clip來繪制路徑,所以我們的所有只會在clip區(qū)域內(nèi)生效,所以我們清除的只是我們繪制的區(qū)域,也就是橡皮檫掉的區(qū)域
ctx.fillStyle = '#fff' ctx.fillRect(0, 0, this.width, this.height)將清除的區(qū)域填充為白色
ctx.restore() 將之前的保存的畫板重繪,其他地方就不會改變,只有橡皮檫過的地方改變。
更多細節(jié)可以看canvas繪制形狀
前進和回退
前進和回退的是每當鼠標抬起時我們算一針,通過canvas的
this.ctx.getImageData(0, 0, this.width, this.height) 參數(shù)(x, y, width, height) 這里我們把整個canvas畫布進行截圖得到圖片,并且保存在this.imgData = [] 數(shù)組中
通過this.index來指定當前幀,前進就index++, 后退相反
通過this.ctx.putImageData(this.imgData[this.index], 0, 0)將當前幀的圖片放出,使用之前需要清屏
清除,設(shè)置參數(shù)
this.imgData = [] 清空圖片數(shù)組
this.ctx.clearRect(0, 0, this.width, this.height) 清屏
this.index = 0 清除指針
this.getImage() 保存第一針
this.weight = weight 設(shè)置字體寬度
this.color = color 上傳顏色
到這里我們的canvas用到的技術(shù)已經(jīng)介紹完畢
一對多,多對多
視頻模式有好幾種,具體可以去在視頻模式,不同的模式處理不同的情況,不過我們這里使用的是p2p多對多的連接。因為是p2p,所以要實現(xiàn)多對多,那就可以變成每個的一對一。就是通過每個端都進行p2p連接。這里我們需要注意添加的順序問題。這里我們是當有人進入房間時,進入的人和房間每一個進行p2p,已經(jīng)進入的就只和進入的進行p2p。這樣就可以全部都是p2p
// nat連接方法
function createPeers(data) {
if (user !== data.joinUser) {
let conn = [data.joinUser, user].join('-')
if (!peers[conn]) {
initPeer(conn)
}
} else if (data.joinUser === user) {
if (data.roomusers.length > 1) {
data.roomusers.forEach(roomuser => {
if (roomuser.name !== user) {
let conn = [data.joinUser, roomuser.name].join('-')
if (!peers[conn]) {
// initPeer和之前差不多,就多了將新建的Peer和channel加入數(shù)組
initPeer(conn)
}
}
})
}
}
}
我們在每個客戶端都使用了一個數(shù)組來進行存儲。通過加入的和現(xiàn)有的user進行標示,來標示不同的p2p。
每個p2p的具體實現(xiàn)
和之前單個的相同,只是我們會通過for循環(huán)來遍歷數(shù)組,將每個房間內(nèi)的人都會去發(fā)送offer
// 新建對每個已經(jīng)在房間的offer
if (data.joinUser === user) {
for (let conn in peers) {
// conn標示
createoffer(conn, peers[conn])
}
}
function createoffer(conn, peer) {
peer.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
})
.then(offer => {
peer.setLocalDescription(offer, () => {
console.log('setLocalDescription-offer', peer.localDescription)
socket.emit('offer', {room: room, conn: conn, user: conn.split('-')[0], toUser: conn.split('-')[1], sdp: offer})
})
})
}
而在使用socket.io進行第一個連接的時候,需要通過conn標示來進行對應(yīng)的傳輸,我們將conn進行拆分,user是發(fā)送者,touser是接受者。
// 轉(zhuǎn)發(fā)offer
socket.on('offer', data => {
// 通過toUser發(fā)送個其對應(yīng)的socket
socket.to(sockets[data.toUser].id).emit('offer', data)
})
// 接收端收到offer
socket.on('offer', (data) => {
console.log('setRemoteDescription-offer-sdp', data.conn, data.sdp)
var peer = peers[data.conn]
peer.setRemoteDescription(data.sdp, () => {
peer.createAnswer()
.then(answer => {
peer.setLocalDescription(answer, () => {
console.log('setLocalDescription-answer', data.conn, answer)
// 此時將發(fā)送者和接受者互換,發(fā)送answer
socket.emit('answer', {room: room, user: data.toUser, toUser: data.user, conn: data.conn, sdp: answer})
})
})
})
})
// 轉(zhuǎn)發(fā)answer
socket.on('answer', data => {
socket.to(sockets[data.toUser].id).emit('answer', data)
})
// 請求端收到answer
socket.on('answer', (data) => {
// 呼叫端設(shè)置遠程 answer 描述
var peer = peers[data.conn]
peer.setRemoteDescription(data.sdp, () => {
console.log('setRemoteDescription-answer-sdp', data.conn, data.sdp)
})
})
加上ice
// 監(jiān)聽ICE候選信息 如果收集到,就發(fā)送給對方
peer.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice', {room: room, conn: conn, user: conn.split('-')[0], toUser: conn.split('-')[1], candidate: event.candidate})
}
}
// 轉(zhuǎn)發(fā)iceCandidate
socket.on('ice', data => {
socket.to(sockets[data.toUser].id).emit('ice', data)
})
// 收到Ice
socket.on('ice', (data) => {
console.log('onice', data.conn, data.candidate)
var peer = peers[data.conn]
console.log('------------------------peer',peer)
peer.addIceCandidate(data.candidate); // 設(shè)置遠程 ICE
})
到這里我們的p2p就結(jié)束了
動態(tài)畫板效果
這里我們有三種方法:
通過socket.io來進行主動的數(shù)據(jù)傳輸,不過我們這也是一對多正常的方法, 但是既然我們這次用的是webrtc那我們就不使用這種方法了。
通過將canvas變成數(shù)據(jù)流,并且通過addStream和onAddStream來進行,將流傳輸并且用video進行接受流,但是這里有個坑,由于這個坑我卡了一星期,由于我們的需求是會更改添加的流對象,但是我們之前說過onaddstream()在接送端answer的setRemoteDescription執(zhí)行完成后會立即執(zhí)行,所以我們不能在完成連接后在切換流對象,所以這個方法在我這個需求中是不行的
通過RTCDataChannel來實現(xiàn),這個方法和第一個方法很像,原理就是通過主動發(fā)送數(shù)據(jù)到其他的端,其他端來在自己的canvas上進行繪畫,既然我們使用的是這種方法,現(xiàn)在我們介紹下具體的實現(xiàn)流程
前面說過canva類中有個回調(diào)函數(shù),當我們進行操作的時候,就會調(diào)用回調(diào)函數(shù),將參數(shù)傳遞到類外面的sendOther()方法
sendOther(option, data) 傳遞兩個參數(shù)一個是option操作對應(yīng)不同的方法,data數(shù)據(jù)對應(yīng)方法的數(shù)據(jù)
channels[conn].send(JSON.stringify(data)) channels[conn] 數(shù)組中對應(yīng)的標示的channel,我們使用for循環(huán)就能將已經(jīng)連接的所有p2p主動發(fā)送數(shù)據(jù)
而接收端ondatachannel會去接受發(fā)送過來的數(shù)據(jù),根據(jù)不同option來進行操作
peer.ondatachannel = (event) => {
var channel = event.channel
channel.binaryType = 'arraybuffer'
channel.onopen = (event) => { // 連接成功
console.log('channel onopen')
}
channel.onclose = function(event) { // 連接關(guān)閉
console.log('channel onclose')
}
channel.onmessage = (event) => { // 收到消息
let obj = JSON.parse(event.data)
let option = obj.option
let data = obj.data
// console.log('onmessage----------', data, option, event)
if (option === 'text') {
msgList.push(data)
updateMsgList(data)
} else {
switch (option) {
case 'pen': {
draw.line(...data)
break
}
case 'eraser': {
draw.eraser(...data)
break
}
case 'getImage': {
draw.getImage()
break
}
case 'back': {
draw.back()
break
}
case 'go': {
draw.go()
break
}
case 'clear': {
draw.clear()
break
}
case 'setWeight': {
draw.setWeight(...data)
break
}
case 'setColor': {
draw.setColor(...data)
break
}
}
}
// console.log('channel onmessage', e.data);
}
}
總結(jié)
通過這次的項目還是有很多收獲的,首先是webrtc領(lǐng)域,如果不是這次項目可能我都不會接觸這個領(lǐng)域,也加強了我的canvas和業(yè)務(wù)邏輯的能力。用原生js寫業(yè)務(wù)是真滴麻煩。 由于這段時間在寫小程序,這個項目有些地方還是沒有完善的,有些業(yè)務(wù)邏輯還沒寫完,不過核心功能已經(jīng)寫完了,沒有太大影響。
1.這畫功沒誰了!
2.我看到了一把斷了的弓箭,這是什么成語呢?
3.還有個繁體字!
4.我在遙望,月亮之上!
5.不要怪我大意,我會倚天屠龍!
6.我的心里只有你沒有她!
7.這幅有點抽象?。?/p>
8.大力出奇跡!
9.睡得真香??!
10.這是道數(shù)學(xué)題?
11.一個人,一顆心,一把鎖,一個寵物!
12.這個簡單!
13.某動物被拴在了柱子上!
14.抬頭望望天!
15.猜這個也就用4、5秒鐘吧!
16.這大票子!
17.這是書架嗎?
18.美女與雞的故事。
19.這個就難了,這是誰畫的?。。?/p>