WebRTC:一個視頻聊天的簡單例子

  • 2019 年 10 月 3 日
  • 筆記

相關API簡介

在前面的章節中,已經對WebRTC相關的重要知識點進行了介紹,包括涉及的網絡協議、會話描述協議、如何進行網絡穿透等,剩下的就是WebRTC的API了。

WebRTC通信相關的API非常多,主要完成了如下功能:

  1. 信令交換
  2. 通信候選地址交換
  3. 音視頻採集
  4. 音視頻發送、接收

相關API太多,為避免篇幅過長,文中部分採用了偽代碼進行講解。詳細代碼參考文章末尾,也可以在筆者的Github上找到,有問題歡迎留言交流。

信令交換

信令交換是WebRTC通信中的關鍵環節,交換的信息包括編解碼器、網絡協議、候選地址等。對於如何進行信令交換,WebRTC並沒有明確說明,而是交給應用自己來決定,比如可以採用WebSocket。

發送方偽代碼如下:

const pc = new RTCPeerConnection(iceConfig);  const offer = await pc.createOffer();  await pc.setLocalDescription(offer);  sendToPeerViaSignalingServer(SIGNALING_OFFER, offer); // 發送方發送信令消息

接收方偽代碼如下:

const pc = new RTCPeerConnection(iceConfig);  await pc.setRemoteDescription(offer);  const answer = await pc.createAnswer();  await pc.setLocalDescription(answer);  sendToPeerViaSignalingServer(SIGNALING_ANSWER, answer); // 接收方發送信令消息

候選地址交換服務

當本地設置了會話描述信息,並添加了媒體流的情況下,ICE框架就會開始收集候選地址。兩邊收集到候選地址後,需要交換候選地址,並從中知道合適的候選地址對。

候選地址的交換,同樣採用前面提到的信令服務,偽代碼如下:

// 設置本地會話描述信息  const localPeer = new RTCPeerConnection(iceConfig);  const offer = await pc.createOffer();  await localPeer.setLocalDescription(offer);    // 本地採集音視頻  const localVideo = document.getElementById('local-video');  const mediaStream = await navigator.mediaDevices.getUserMedia({      video: true,      audio: true  });  localVideo.srcObject = mediaStream;    // 添加音視頻流  mediaStream.getTracks().forEach(track => {      localPeer.addTrack(track, mediaStream);  });    // 交換候選地址  localPeer.onicecandidate = function(evt) {      if (evt.candidate) {          sendToPeerViaSignalingServer(SIGNALING_CANDIDATE, evt.candidate);      }  }

音視頻採集

可以使用瀏覽器提供的getUserMedia接口,採集本地的音視頻。

const localVideo = document.getElementById('local-video');  const mediaStream = await navigator.mediaDevices.getUserMedia({      video: true,      audio: true  });  localVideo.srcObject = mediaStream;

音視頻發送、接收

將採集到的音視頻軌道,通過addTrack進行添加,發送給遠端。

mediaStream.getTracks().forEach(track => {      localPeer.addTrack(track, mediaStream);  });

遠端可以通過監聽ontrack來監聽音視頻的到達,並進行播放。

remotePeer.ontrack = function(evt) {      const remoteVideo = document.getElementById('remote-video');      remoteVideo.srcObject = evt.streams[0];  }

完整代碼

包含兩部分:客戶端代碼、服務端代碼。

1、客戶端代碼

const socket = io.connect('http://localhost:3000');    const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT';  const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT';    const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT';  const SERVER_USER_EVENT = 'SERVER_USER_EVENT';    const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; // 登錄    const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS';    const SIGNALING_OFFER = 'SIGNALING_OFFER';  const SIGNALING_ANSWER = 'SIGNALING_ANSWER';  const SIGNALING_CANDIDATE = 'SIGNALING_CANDIDATE';    let remoteUser = ''; // 遠端用戶  let localUser = ''; // 本地登錄用戶    function log(msg) {      console.log(`[client] ${msg}`);  }    socket.on('connect', function() {      log('ws connect.');  });    socket.on('connect_error', function() {      log('ws connect_error.');  });    socket.on('error', function(errorMessage) {      log('ws error, ' + errorMessage);  });    socket.on(SERVER_USER_EVENT, function(msg) {      const type = msg.type;      const payload = msg.payload;        switch(type) {          case SERVER_USER_EVENT_UPDATE_USERS:              updateUserList(payload);              break;      }      log(`[${SERVER_USER_EVENT}] [${type}], ${JSON.stringify(msg)}`);  });    socket.on(SERVER_RTC_EVENT, function(msg) {      const {type} = msg;        switch(type) {          case SIGNALING_OFFER:              handleReceiveOffer(msg);              break;          case SIGNALING_ANSWER:              handleReceiveAnswer(msg);              break;          case SIGNALING_CANDIDATE:              handleReceiveCandidate(msg);              break;      }  });    async function handleReceiveOffer(msg) {      log(`receive remote description from ${msg.payload.from}`);        // 設置遠端描述      const remoteDescription = new RTCSessionDescription(msg.payload.sdp);      remoteUser = msg.payload.from;      createPeerConnection();      await pc.setRemoteDescription(remoteDescription); // TODO 錯誤處理        // 本地音視頻採集      const localVideo = document.getElementById('local-video');      const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });      localVideo.srcObject = mediaStream;      mediaStream.getTracks().forEach(track => {          pc.addTrack(track, mediaStream);          // pc.addTransceiver(track, {streams: [mediaStream]}); // 這個也可以      });      // pc.addStream(mediaStream); // 目前這個也可以,不過接口後續會廢棄        const answer = await pc.createAnswer(); // TODO 錯誤處理      await pc.setLocalDescription(answer);      sendRTCEvent({          type: SIGNALING_ANSWER,          payload: {              sdp: answer,              from: localUser,              target: remoteUser          }      });  }    async function handleReceiveAnswer(msg) {      log(`receive remote answer from ${msg.payload.from}`);        const remoteDescription = new RTCSessionDescription(msg.payload.sdp);      remoteUser = msg.payload.from;        await pc.setRemoteDescription(remoteDescription); // TODO 錯誤處理  }    async function handleReceiveCandidate(msg){      log(`receive candidate from ${msg.payload.from}`);      await pc.addIceCandidate(msg.payload.candidate); // TODO 錯誤處理  }    /**   * 發送用戶相關消息給服務器   * @param {Object} msg 格式如 { type: 'xx', payload: {} }   */  function sendUserEvent(msg) {      socket.emit(CLIENT_USER_EVENT, JSON.stringify(msg));  }    /**   * 發送RTC相關消息給服務器   * @param {Object} msg 格式如{ type: 'xx', payload: {} }   */  function sendRTCEvent(msg) {      socket.emit(CLIENT_RTC_EVENT, JSON.stringify(msg));  }    let pc = null;    /**   * 邀請用戶加入視頻聊天   *  1、本地啟動視頻採集   *  2、交換信令   */  async function startVideoTalk() {      // 開啟本地視頻      const localVideo = document.getElementById('local-video');      const mediaStream = await navigator.mediaDevices.getUserMedia({          video: true,          audio: true      });      localVideo.srcObject = mediaStream;        // 創建 peerConnection      createPeerConnection();        // 將媒體流添加到webrtc的音視頻收發器      mediaStream.getTracks().forEach(track => {          pc.addTrack(track, mediaStream);          // pc.addTransceiver(track, {streams: [mediaStream]});      });      // pc.addStream(mediaStream); // 目前這個也可以,不過接口後續會廢棄  }    function createPeerConnection() {      const iceConfig = {"iceServers": [          {url: 'stun:stun.ekiga.net'},          {url: 'turn:turnserver.com', username: 'user', credential: 'pass'}      ]};        pc = new RTCPeerConnection(iceConfig);        pc.onnegotiationneeded = onnegotiationneeded;      pc.onicecandidate = onicecandidate;      pc.onicegatheringstatechange = onicegatheringstatechange;      pc.oniceconnectionstatechange = oniceconnectionstatechange;      pc.onsignalingstatechange = onsignalingstatechange;      pc.ontrack = ontrack;        return pc;  }    async function onnegotiationneeded() {      log(`onnegotiationneeded.`);        const offer = await pc.createOffer();      await pc.setLocalDescription(offer); // TODO 錯誤處理        sendRTCEvent({          type: SIGNALING_OFFER,          payload: {              from: localUser,              target: remoteUser,              sdp: pc.localDescription // TODO 直接用offer?          }      });  }    function onicecandidate(evt) {      if (evt.candidate) {          log(`onicecandidate.`);            sendRTCEvent({              type: SIGNALING_CANDIDATE,              payload: {                  from: localUser,                  target: remoteUser,                  candidate: evt.candidate              }          });      }  }    function onicegatheringstatechange(evt) {      log(`onicegatheringstatechange, pc.iceGatheringState is ${pc.iceGatheringState}.`);  }    function oniceconnectionstatechange(evt) {      log(`oniceconnectionstatechange, pc.iceConnectionState is ${pc.iceConnectionState}.`);  }    function onsignalingstatechange(evt) {      log(`onsignalingstatechange, pc.signalingstate is ${pc.signalingstate}.`);  }    // 調用 pc.addTrack(track, mediaStream),remote peer的 onTrack 會觸發兩次  // 實際上兩次觸發時,evt.streams[0] 指向同一個mediaStream引用  // 這個行為有點奇怪,github issue 也有提到 https://github.com/meetecho/janus-gateway/issues/1313  let stream;  function ontrack(evt) {      // if (!stream) {      //     stream = evt.streams[0];      // } else {      //     console.log(`${stream === evt.streams[0]}`); // 這裡為true      // }      log(`ontrack.`);      const remoteVideo = document.getElementById('remote-video');      remoteVideo.srcObject = evt.streams[0];  }    // 點擊用戶列表  async function handleUserClick(evt) {      const target = evt.target;      const userName = target.getAttribute('data-name').trim();        if (userName === localUser) {          alert('不能跟自己進行視頻會話');          return;      }        log(`online user selected: ${userName}`);        remoteUser = userName;      await startVideoTalk(remoteUser);  }    /**   * 更新用戶列表   * @param {Array} users 用戶列表,比如 [{name: '小明', name: '小強'}]   */  function updateUserList(users) {      const fragment = document.createDocumentFragment();      const userList = document.getElementById('login-users');      userList.innerHTML = '';        users.forEach(user => {          const li = document.createElement('li');          li.innerHTML = user.userName;          li.setAttribute('data-name', user.userName);          li.addEventListener('click', handleUserClick);          fragment.appendChild(li);      });        userList.appendChild(fragment);  }    /**   * 用戶登錄   * @param {String} loginName 用戶名   */  function login(loginName) {      localUser = loginName;      sendUserEvent({          type: CLIENT_USER_EVENT_LOGIN,          payload: {              loginName: loginName          }      });  }    // 處理登錄  function handleLogin(evt) {      let loginName = document.getElementById('login-name').value.trim();      if (loginName === '') {          alert('用戶名為空!');          return;      }      login(loginName);  }    function init() {      document.getElementById('login-btn').addEventListener('click', handleLogin);  }    init();

2、服務端代碼

// 添加ws服務  const io = require('socket.io')(server);  let connectionList = [];    const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT';  const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT';    const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT';  const SERVER_USER_EVENT = 'SERVER_USER_EVENT';    const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN';  const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS';    function getOnlineUser() {    return connectionList    .filter(item => {      return item.userName !== '';    })    .map(item => {      return {        userName: item.userName      };    });  }    function setUserName(connection, userName) {    connectionList.forEach(item => {      if (item.connection.id === connection.id) {        item.userName = userName;      }    });  }    function updateUsers(connection) {    connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});  }    io.on('connection', function (connection) {      connectionList.push({      connection: connection,      userName: ''    });      // 連接上的用戶,推送在線用戶列表    // connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});    updateUsers(connection);      connection.on(CLIENT_USER_EVENT, function(jsonString) {      const msg = JSON.parse(jsonString);      const {type, payload} = msg;        if (type === CLIENT_USER_EVENT_LOGIN) {        setUserName(connection, payload.loginName);        connectionList.forEach(item => {          // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});          updateUsers(item.connection);        });      }    });      connection.on(CLIENT_RTC_EVENT, function(jsonString) {      const msg = JSON.parse(jsonString);      const {payload} = msg;      const target = payload.target;        const targetConn = connectionList.find(item => {        return item.userName === target;      });      if (targetConn) {        targetConn.connection.emit(SERVER_RTC_EVENT, msg);      }    });      connection.on('disconnect', function () {      connectionList = connectionList.filter(item => {        return item.connection.id !== connection.id;      });      connectionList.forEach(item => {        // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});        updateUsers(item.connection);      });    });  });

寫在後面

WebRTC的API非常多,因為WebRTC本身就比較複雜,隨着時間的推移,WebRTC的某些API(包括某些協議細節)也在改動或被廢棄,這其中也有向後兼容帶來的複雜性,比如本地視頻採集後加入傳輸流,可以採用 addStream 或 addTrack 或 addTransceiver,再比如會話描述版本從plan-b遷移到unified-plan。

建議親自動手擼一遍代碼,加深了解。

相關鏈接

2019.08.02-video-talk-using-webrtc

https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection

onremotestream called twice for each remote stream