webrtc+canvas+socket.io從零實現一個你畫我猜 | 掘金技術徵文

開場白

最近鍵盤壞了,恰好看到掘金有聲網的技術徵文,想整個鍵盤。因而就開始從零開始學習webrtc, 一開始看文檔就是個素質三連。這麼難啊,這咋整啊,這誰頂的住啊。因而就開始全網找資料,很幸運的在掘金上找到了江三瘋大佬的webrtc系列,以及WebRTC實時通訊系列教程,或者英文原版的Real time communication with WebRTC,有興趣的同窗也能夠去看下,很是棒。既然有這麼棒的文章爲啥還要再寫篇文章呢,那固然是分(zheng)享(ge)經(jian)驗(pan)啦。鑑於本身耗時將近三週的學習加項目,項目寫着寫着就破千行了(枯惹),雖然中途有事情耽誤了一段時間,可是也是花費了我極其大的精力,踩了無數的坑,這裏我會盡量從最基礎開始用簡答易懂的方式,帶領你們完成一個較完整你畫我猜。文章可能會很長,能夠慢慢看。有些知識點不須要那麼詳細,爲了讓你思路更清晰會省略介紹,有興趣的能夠本身去看。javascript

項目演示

演示

Github地址:你畫我猜css

歡迎Star!html

webrtc

WebRTC (Web Real-Time Communication)是一個能夠用在視頻聊天,音頻聊天或P2P文件分享等Web App中的 API。html5

全名叫web的實時通訊,從官方文檔能夠看出來他能夠用來視頻聊天,音頻聊天,端對端(p2p),數據傳輸,文件分享的一個api。如今的直播用的就是這個技術java

webrtc下有三個重要的api,正好對應三個功能。git

  • getUserMedia 請求獲取用戶的媒體信息包括視頻流(video)和音頻流(audio)
  • RTCPeerConnection 表明一個由本地計算機到遠端的WebRTC鏈接,用於實現端對端的鏈接。該接口提供了建立,保持,監控,關閉鏈接的方法的實現。
  • RTCDataChannel 表明在二者之間創建了一個雙向數據通道的鏈接,是一個數據通道,傳輸數據

getUserMedia

首先咱們先實現一個簡單的獲取視頻和音頻而且顯示在網頁上es6

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>
複製代碼

由於咱們這裏只須要得到數據流,這裏就不具體的解釋api,咱們能夠去看官方文檔MDN。 從這裏能夠看咱們只須要一個簡單的api就能得到到本地的視頻和音頻流,咱們最後確定是須要將這個流發送到其餘的客戶端的,如何發送流呢,咱們經過RTCPeerConnection來進行鏈接以及流的傳輸。github

navigator.getUserMedia 目前是仍是支持的。可是在官方文檔中已經不推薦使用,應該使用navigator.MediaDevices上的getUserMedia(),可是該api目前不是全部瀏覽器都支持,有兼容性問題 web

爲了不兼容性問題,咱們能夠用如下代碼來進行兼容性適配canvas

//瀏覽器不支持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

這是實現端對端(既不經過服務器進行數據交換)鏈接的最重要的api,這也是最難理解的一部分。

端對端的鏈接第一次是須要藉助服務器來鏈接的,須要服務器來進行中轉,當第一次鏈接上後就不須要再經過服務器了。這裏咱們使用socket.io,以及一點點koa,這個咱們後面再講。也有其餘方式咱們這裏不講有興趣的能夠看江三瘋大佬的文章。總之第一次是須要服務器來實現兩端的鏈接。

接下來是具體的交換過程

  • 建立RTCPeerConnection的實例
  • 交換本地和遠程的sdp數據描述,使用offer和answer來進行nat穿透,創建p2p
  • 交換ice網絡信息,用於聯網的時候的網絡信息交換

建立RTCPeerConnection的實例

let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
let peer = new PeerConnection(iceServers)
複製代碼

這裏有個參數iceServers,參數中存在兩個屬性,分別是stun和turn。是用於NAT穿透的,具體能夠看WebRTC in the real world: STUN TURN and signaling

{
  iceServers: [
    { url: "stun:stun.l.google.com:19302"}, // 谷歌的公共服務
    {
      url: "turn:***",
      username: ***, // 用戶名
      credential: *** // 密碼
    }
  ]
}
複製代碼

NAT

先說下咱們爲何要用NAT穿透技術才能實現p2p的鏈接。

NAT全稱(Network Address Translation,網絡地址轉換),是用於網絡的地址交換,這會致使咱們得不到設備真實的ip地址

因爲外網用的是IPV4的地址碼,致使地址碼的數量不夠,因而就將會使用路由之類的NAT設備將外網的ip地址以及端口號都修改並使用IPV6的地址,使得多個內網能夠該外網。這樣增長了網絡鏈接數量,可是卻使得咱們沒法從內網直接找到對方的內網,因此咱們須要進行NAT穿透,來實現端對端的鏈接。

NAT穿透的大體步驟是如A,B兩端,A段向B端發送一條信息,這條信息是會被NAT設備給丟棄,可是會在NAT上留下一個洞,下次信息就能夠經過這個洞來傳輸,同理B也這一發送一條信息,來打通本身的NAT設備。具體實現使用STUN和TURN來進行NAT穿透,該過程是經過STUN Server來進行NAT穿透,若是沒法穿透則須要使用TURN Server來進行中轉,具體是如何穿透的能夠看ICE協議下NAT穿越的實現(STUN&TURN),另外咱們能夠搭建本身的STUN 和 TURN,本身動手搭建 WebRTC TURN&STUN 服務器

  • STUN(Simple Traversal of User Datagram Protocol through Network Address Translators (NATs),NAT的UDP簡單穿越)是一種網絡協議
  • TURN的全稱爲Traversal Using Relay NAT,TURN協議容許NAT或者防火牆後面的對象能夠經過TCP或者UDP接收到數據

P2P

如今咱們已經瞭解了NAT穿透,如今讓咱們用PeerConnection來實現p2p鏈接。上文中咱們已經建立了PeerConnection的實例,咱們稱他爲localPeer,remotePeer。如今咱們來交換本地和遠程的sdp數據描述,先上代碼。

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))
複製代碼

實現交換本地和遠程的sdp數據描述和咱們以前的NAT穿透的步驟很像。

  • localPeer調用createOffer()api來建立一個offer類型的sdp,並使用setLocalDescription()將其添加到localDescription,這裏咱們只是在本地創建p2p,不須要服務器,來第一次鏈接
  • remotePeer接受到localPeer的localDescription,並使用setRemoteDescription將其添加到本身的RemoteDescription
  • remotePeer經過createAnswer()建立一個answer類型的sdp,並將其添加到本身的LocalDescription
  • localPeer將remotePeer的localDescription添加爲本身的remoteDescription

到這裏兩端的sdp數據交換就已經完成,也就表明了本地的p2p已經鏈接好了,可是咱們這裏是在同一個界面建立了兩個端,是沒法真正的p2p,若是要使用網絡的p2p咱們就須要使用ice實現網絡的對等鏈接,而且還須要socket.io來創建第一次數據傳輸

SDP

SDP(Session Description Protocol,會話描述協議) 它不屬於傳輸協議, 可是可使用多種的傳輸協議,包括會話通知協議(SAP)、會話初始協議(SIP)、實時流協議(RTSP)、MIME 擴展協議的電子郵件以及超文本傳輸協議(HTTP)。

這是一個具體的sdp,是本地媒體元數據,詳情能夠去看P2P通訊標準協議(三)之ICE

v=0
o=- 1877521640243013583 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是一個offer類型的sdp,answer也是同理

ICE

ICE的全稱爲Interactive Connectivity Establishment,即交互式鏈接創建。ICE是一個用於在offer/answer模式下的NAT傳輸協議,主要用於UDP下多媒體會話的創建,使用了STUN協議以及TURN 協議

若是咱們須要實現網絡的p2p就須要進行兩端的ice協議鏈接。這裏咱們須要用到

  • RTCPeerConnection.onicecandidate()api用於監視本地ice網絡的變化,若是有了就將其使用socket.io發送出去,
  • RTCPeerConnection.addIceCandidate()用於將收到的ice添加到本地的RTCPeerConnection實例中。

傳輸stream流 當創建好了p2p後咱們可使用RTCPeerConnection實例中的

  • addstream() 添加本地的媒體流,
  • onaddstream() 檢測本地的媒體流,

onaddstream()在接送端answer的setRemoteDescription執行完成後會當即執行,也就是說咱們不能在p2p建立完成後在使用addstream來添加流。

addstream()和onaddstream()已經在官方文檔中不推薦使用,咱們最好使用更新的addTrack()和onaddTrack(),有興趣能夠看MDN

RTCDataChannel

RTCDataChannel用於p2p中的數據通道,咱們使用的是RTCPeerConnection中的createDataChannel()來建立一個TCDataChannel實例。這裏咱們假設建立了一個實例叫channel,這裏咱們須要的api有

  • channel.send() channel主動向已鏈接的通道發送數據
  • ondatachannel() 監視是channel是否發生改變,好比打開(onopen),關閉(onclose),得到send過來的數據(onmessage)
//發送數據hello
 channel.send(JSON.stringify('hello'))
 
 // 監聽channel的狀態
 peer.ondatachannel = (event) => {
    var channel = event.channel
    channel.binaryType = 'arraybuffer'
    channel.onopen = (event) => { // 鏈接成功
      console.log('channel onopen')
    }
    channel.onclose = function(event) { // 鏈接關閉
      console.log('channel onclose')
    }
    channel.onmessage = (event) => { // 收到消息
      let data = JSON.parse(event.data)
      console.log('channel onmessage', data)
    }
 }  
複製代碼

到這裏咱們的webrtc基礎已經寫完了,咱們雖然webrtc是一個不須要服務器的p2p,可是咱們第一次鏈接是須要服務器來幫咱們找到響應的端的,從而將offer,answer,ice等信息進行交互,創建p2p鏈接。接下來咱們就使用koa和socket.io做爲服務器來進行首次的鏈接,以及一些業務邏輯交互。

koa&socket.io

koa

koa是一個爲一個HTTP服務的中間件框架,極其的輕量級,幾乎沒有集成,不少功能須要咱們安裝插件才能使用。而且使用的是es6的語法,使用的是async來實現異步。

咱們須要建立一個server.js來部署服務器。

import Koa from 'koa'
import { join } from 'path'
import Static from 'koa-static'
import Socket from 'socket.io'
// 建立一個socket.io
const io = new Socket({
  options : {
    pingTimeout: 10000,
    pingInterval: 5000
  }
})
// 建立koa
const app = new Koa()
// socket注入app
io.attach(app)

// 添加指定靜態web文件的Static路徑
// Static(root, opts) 這裏將public做爲根路徑
app.use(Static(
  // join 拼接路徑 
  // __dirname返回被執行文件夾的絕對路徑
  join( __dirname, './public')
))
// 服務器端口號,這裏兩個listen外面的是socket.io的,後面一個是koa的listen,須要將socket監聽koa的端口,否則會報錯
io.listen(app.listen(3000, () => {
  console.log('server start at port: ' + 3000)
}))
複製代碼

socket.io

咱們先來介紹下WebSocket網絡協議,他是不一樣於http協議的一種,具體能夠看websocket

socket.io是服務器使用的是WebSocket網絡協議,是HTML5新增的一種通訊協議,其特色是服務端能夠主動向客戶端推送信息,客戶端也能夠主動向服務端發送信息,是真正的雙向平等對話,屬於服務器推送技術的一種。

這樣咱們就能夠經過兩端的主動發送打服務器,以及服務器主動發送到雙端,來實現交互。 咱們須要使用socket.io的api

  • socket.on('event', () => {}) 監聽socket觸發的事件
  • socket.emit('event', () => {}) 主動發送
  • socket.join('room', () => {}) 加入房間
  • socket.leave('room', () => {}) 離開房間
  • socket.to(room | socket.id) | socket.in(room | socket.id) 指定房間,或者服務器

首先客戶端和服務器端相互鏈接。因爲服務器端設置了端口號爲3000,咱們的html頁端的socket服務器

// 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
// 監聽鏈接
// io是服務器端的, socket是客戶端的
io.on('connection', socket => {
    ...
})
// 監聽關閉
io.on('disconnect', socket => {})
複製代碼

咱們經過socket的來實現webrtc的第一次鏈接

// A 向 B 的p2p
// html 

// A 
// user 是全局變量,存在sessionStorage中, 建立時候獲取
var user = window.sessionStorage.user || ''
// 發給服務器改socket的名稱
socket.emit('createUser', 'A')
// 兼容性
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
var peer = new PeerConnection()
// 建立A端的offer
peer.createOffer()
    .then(offer => {
        // 設置A端的本地描述
        peer.setLocalDescription(offer, () => {
            // socket發送offer和房間
            socket.emit('offer', {offer: offer, user: 'B'})
        })
    })
// 監聽本地的ice變化,有則發送個B
peer.onicecandidate = (event) => {
    if (event.candidate) {
![](https://user-gold-cdn.xitu.io/2019/6/3/16b1b606f637e98e?w=1829&h=1005&f=gif&s=4145004)

// B  
// user 是全局變量,存在sessionStorage中, 建立時候獲取
var user = window.sessionStorage.user || ''
// 發給服務器改socket的名稱
socket.emit('createUser', 'A')
let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection
var peer = new PeerConnection()
// 接受服務器端發過來的offer辨識的數據
socket.on('offer', date => {
    // 設置B端的遠程offer 描述
    peer.setRemoteDescription(data.offer, () => {
        // 建立B的Answer
        peer.createAnswer()
            .then(answer => {
                // 設置B端的本地描述
                peer.setLocalDescription(answer, () => {
                    socket.emit('answer', {answer: answer, user: 'A'})
                })
            })
        })
    })
socket.on('ice', data => {
    // 設置B ICE
    peer.addIceCandidate(data.candidate);
})
socket.emit('createUser', 'B')

// server.js
// 用於接受客戶端的用戶名對應的服務器
const sockets = {} 
// 保存user
const users = {}
io.on('connection', data => {
    // 建立帳戶
    socket.on('createUser', data => {
        let user = new User(data)
        users[data] = user
        sockets[data] = socket
    })
    socket.on('offer', data => {
        // 經過B的socket的id只發送給B
        socket.to(sockets[data.user].id).emit('offer', data)
    })
    socket.on('answer', data => {
        // 經過B的socket的id只發送給A
        socket.to(sockets[data.user].id).emit('answer', data)
    })
    socket.on('ice', data => {
        // ice發送給B
        socket.to(sockets[data.user].id).emit('ice', data)
    })
})
複製代碼

以上就是經過socket.io來實現p2p的第一次鏈接。和咱們在webrtc基礎的過程是同樣的,只是經過了server.js來進行中轉。在以後的業務邏輯中咱們須要對多種不一樣的服務器羣進行廣播,這裏咱們來擴展下socket的廣播的種類。

  • io.emit() 對鏈接了服務器的全部客戶端進行廣播,好比顯示房間信息
  • io.to(room).emit() 對一個房間中的全部客戶端進行廣播,用於房間內的通知
  • socket.to(room).emit() 發送個房間中除了本身覺得的服務器
  • socket.emit() 發送給服務器本身
  • socket.to(socket.id).emit() 發送給指定的服務器

到這裏關於socket.io的咱們一些api的使用和使用socket.io來實現p2p咱們已經瞭解了,接下來咱們將下關於canvas實現一個畫板

canvas

cnavas是html5中的畫板,咱們能夠用它來實如今html上的繪畫功能,這裏咱們的畫板也是用這個作的。 實現畫板咱們用一個類來進行封裝,須要實現如下的功能

  • 畫筆,用來繪製圖案
  • 橡皮,清除圖案
  • 回退,回退到上一次繪畫
  • 前進,前進到下一次繪畫
  • 清除,清除全部的繪畫概率
  • 設置線條,用於設置畫筆和橡皮的寬度
  • 設置顏色,用於設置畫筆顏色
  • 操做函數,用於根據不一樣的操做調用不一樣的函數
  • 回調函數,用於將事件進行回調,用於數據的傳輸,同步畫板

因此咱們能夠寫出咱們的canvas的繪製類

// 建立繪圖類
    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
        // 如今的座標
        this.now = [0, 0]
        // 移動前的座標
        this.last = [0, 0]
        this.bindMousemove = this.onmousemove.bind(this)
        this.callBack = callBack || function() {}
      }
      // 初始化
      init() { }
      // 監聽鼠標按下
      onmousedown(event) { }
      // 監聽鼠標移動
      onmousemove(event) { }
      // 監聽鼠標擡起
      onmouseup() { }
      //繪製線條
      line(last, now, weight, color) { }
      // 橡皮
      eraser(last, now, weight) { }
      // 回退
      back() { }
      // 前進
      go() { }
      // 清除
      clear() { }
      // 收集每一幀的圖片
      getImage() { }
      // 繪製當前幀的圖片
      putImage() { }
      // 設置尺寸   
      setWeight(weight) { }
      // 設置顏色
      setColor(color) { }
      // 全部的操做的合集
      options(option, data) { }
    }
複製代碼

咱們來具體實現下這些方法

操做合集

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
          }
        }
      }
複製代碼

這裏咱們將全部操做的調用都放在一個方法中,這樣有利於代碼的重構,可是這樣作最主要的目的是爲了,當咱們將每一個操做的回調函數寫在option方法中而不寫在具體操做的方法中,這樣能夠避免當咱們使用回調函數把參數傳遞出去的後,接收端使用該方法更新了本身的canvas後又會調用回調致使兩端的無限回調。

畫筆和橡皮

咱們實現畫筆的思路是當鼠標按下時,咱們監聽鼠標的移動,鼠標以移動就將鼠標的位置參數傳遞給options函數,options函數經過this.option來識別是畫筆仍是橡皮,調用響應的函數。當鼠標擡起時,結束移動事件的監聽,並將當前幀進行保存,而且調用callback函數將保存針的信息傳遞出去。

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()
      }
複製代碼

畫筆的具體實現

  • ctx.beginPath()表示開始繪製路徑,而且設置下線條的特色,顏色等。
  • ctx.moveTo(last[0], last[1])表示將筆的位置移動到一開始的位置,表示畫筆的其實位置。
  • ctx.lineTo(now[0], now[1])表示畫一條從(last[0], last[1])到(now[0], now[1])一條線。
  • this.ctx.closePath()關閉路徑繪製
  • ctx.stroke()使用線條來繪製,而不是填充
  • last = now 更新座標點

橡皮的具體實現

  • ctx.save() 保存當前狀態
  • ctx.beginPath() 開始繪製路徑
  • ctx.arc(now[0], now[1], weight, 0, 2 * Math.PI) 繪製一個圓形,參數爲圓心x,y,半徑r,以及開始的角度,結束的角度。這裏開始角度爲0是從x軸的正軸開始,一圈。就至關於咱們以鼠標位移結束位置繪製了一個圓。
  • ctx.closePath() 關閉路徑繪製
  • ctx.clip() 是咱們路徑繪製的另一種方法,他將咱們繪製的路徑進行剪切,使得咱們以後的全部操做都會在這個路徑繪製區域,使用clip來進行路徑繪製,必須是封閉的路徑
  • ctx.clearRect(0, 0, this.width, this.height) 雖然這裏清除整個屏幕,可是因爲咱們使用了clip來繪製路徑,因此咱們的全部只會在clip區域內生效,因此咱們清除的只是咱們繪製的區域,也就是橡皮檫掉的區域
  • ctx.fillStyle = '#fff' ctx.fillRect(0, 0, this.width, this.height)將清除的區域填充爲白色
  • ctx.restore() 將以前的保存的畫板重繪,其餘地方就不會改變,只有橡皮檫過的地方改變。

更多細節能夠看canvas繪製形狀

前進和回退

前進和回退的是每當鼠標擡起時咱們算一針,經過canvas的

  • this.ctx.getImageData(0, 0, this.width, this.height) 參數(x, y, width, height) 這裏咱們把整個canvas畫布進行截圖獲得圖片,而且保存在this.imgData = [] 數組中
  • 經過this.index來指定當前幀,前進就index++, 後退相反
  • 經過this.ctx.putImageData(this.imgData[this.index], 0, 0)將當前幀的圖片放出,使用以前須要清屏

清除,設置參數

  • this.imgData = [] 清空圖片數組
  • this.ctx.clearRect(0, 0, this.width, this.height) 清屏
  • this.index = 0 清除指針
  • this.getImage() 保存第一針
  • this.weight = weight 設置字體寬度
  • this.color = color 上傳顏色

到這裏咱們的canvas用到的技術已經介紹完畢

一對多,多對多

視頻模式有好幾種,具體能夠去在視頻模式,不一樣的模式處理不一樣的狀況,不過咱們這裏使用的是p2p多對多的鏈接。由於是p2p,因此要實現多對多,那就能夠變成每一個的一對一。就是經過每一個端都進行p2p鏈接。這裏咱們須要注意添加的順序問題。這裏咱們是當有人進入房間時,進入的人和房間每個進行p2p,已經進入的就只和進入的進行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加入數組
            initPeer(conn)
          }
        }
      })
    }
  }
  
}
複製代碼

咱們在每一個客戶端都使用了一個數組來進行存儲。經過加入的和現有的user進行標示,來標示不一樣的p2p。

每一個p2p的具體實現

和以前單個的相同,只是咱們會經過for循環來遍歷數組,將每一個房間內的人都會去發送offer

// 新建對每一個已經在房間的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標示來進行對應的傳輸,咱們將conn進行拆分,user是發送者,touser是接受者。

// 轉發offer
socket.on('offer', data => {
  // 經過toUser發送個其對應的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)
        // 此時將發送者和接受者互換,發送answer
        socket.emit('answer', {room: room, user: data.toUser, toUser: data.user, conn: data.conn, sdp: answer})
      })
    })
  })
})
複製代碼
// 轉發answer
socket.on('answer', data => {
  socket.to(sockets[data.toUser].id).emit('answer', data)
})
複製代碼
// 請求端收到answer
socket.on('answer', (data) => {
  // 呼叫端設置遠程 answer 描述
  var peer = peers[data.conn]
  peer.setRemoteDescription(data.sdp, () => {
    console.log('setRemoteDescription-answer-sdp', data.conn, data.sdp)
  }) 
})
複製代碼

加上ice

// 監聽ICE候選信息 若是收集到,就發送給對方
peer.onicecandidate = (event) => {
  if (event.candidate) {
    socket.emit('ice', {room: room, conn: conn, user: conn.split('-')[0], toUser: conn.split('-')[1], candidate: event.candidate})
  }
}
// 轉發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); // 設置遠程 ICE
})
複製代碼

到這裏咱們的p2p就結束了

動態畫板效果

這裏咱們有三種方法:

  • 經過socket.io來進行主動的數據傳輸,不過咱們這也是一對多正常的方法, 可是既然咱們此次用的是webrtc那咱們就不使用這種方法了。
  • 經過將canvas變成數據流,而且經過addStream和onAddStream來進行,將流傳輸而且用video進行接受流,可是這裏有個坑,因爲這個坑我卡了一星期,因爲咱們的需求是會更改添加的流對象,可是咱們以前說過onaddstream()在接送端answer的setRemoteDescription執行完成後會當即執行,因此咱們不能在完成鏈接後在切換流對象,因此這個方法在我這個需求中是不行的
  • 經過RTCDataChannel來實現,這個方法和第一個方法很像,原理就是經過主動發送數據到其餘的端,其餘端來在本身的canvas上進行繪畫,既然咱們使用的是這種方法,如今咱們介紹下具體的實現流程

前面說過canva類中有個回調函數,當咱們進行操做的時候,就會調用回調函數,將參數傳遞到類外面的sendOther()方法

  • sendOther(option, data) 傳遞兩個參數一個是option操做對應不一樣的方法,data數據對應方法的數據
  • channels[conn].send(JSON.stringify(data)) channels[conn] 數組中對應的標示的channel,咱們使用for循環就能將已經鏈接的全部p2p主動發送數據
  • 而接收端ondatachannel會去接受發送過來的數據,根據不一樣option來進行操做
peer.ondatachannel = (event) => {
    var channel = event.channel
    channel.binaryType = 'arraybuffer'
    channel.onopen = (event) => { // 鏈接成功
      console.log('channel onopen')
    }
    channel.onclose = function(event) { // 鏈接關閉
      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);
    }
  }  
複製代碼

總結

經過此次的項目仍是有不少收穫的,首先是webrtc領域,若是不是此次項目可能我都不會接觸這個領域,也增強了個人canvas和業務邏輯的能力。用原生js寫業務是真滴麻煩。 因爲這段時間在寫小程序,這個項目有些地方仍是沒有完善的,有些業務邏輯還沒寫完,不過核心功能已經寫完了,沒有太大影響。

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

相關文章
相關標籤/搜索