webRtc实践总结

2022-03-09 14:56:51 浏览数 (1)

场景

  • 业务上有这样的一个场景,这是一个游戏直播会场,需要把手机上面的游戏画面,投屏到大屏幕上面,不仅如此可能还需要加一些其他信息例如比赛信息或者logo赞助等等,只使用设备本身投屏就不能满足现在的述求,说白了在大屏之上我们需要一个自定义的游戏视频画面。

技术抽象

  • 业务是这样的类似场景,具体实践是使用electron的客户端实现:主窗口采集的视频,投放大屏窗口中。

核心代码功能解析

  • 需要实现两个窗口实例
  • 需要实现视频传输

解决方案

  • electron是支持获取屏幕实例的api的,并且在不同屏幕中渲染自定义内容。
代码语言:javascript复制
import { BrowserWindow, screen } from 'electron'
/**
 * @name: createMore
 * @msg: 获取多个窗口实例创建渲染窗口
 */
async function createMore() {
  const displays = screen.getAllDisplays()
  for (let i = 0; i < displays.length; i  ) {
    const display = displays[i]
    createWindow(display)
  }
}
/**
 * @name: createWindow
 * @msg: 根据不同的显示屏创建窗口
 * @param {any} display
 */
async function createWindow(display) {
  const win = new BrowserWindow({
    frame: platform === 'darwin',
    fullscreen: true,
    x: display.bounds.x,
    y: display.bounds.y,
    webPreferences: {
      nodeIntegration: (process.env
        .ELECTRON_NODE_INTEGRATION as unknown) as boolean,
      enableRemoteModule: true,
      webSecurity: false,
    },
  })
}
  • 视频传输方案比较多也是需要选型的,但是由于博客题目显得有些不言而喻了。

视频传输方案

截图方案

  1. 使用截图的方式,使用定时器定时截取视频容器里面的画面转成图片,通过ipc传输到另一个窗口渲染(icp的封装不在这次分享之内)技术是可行的。
  2. 问题是:传输的次数太过频繁,会造成客户端卡死的情况。传输数据很大,图片转成base64之后数据量很大,这样数据进行不适合进行本地通信。扩展性差,当前是一个视频是可以完成传输的,如果是多个视频的场景,无疑性能是有极大的问题。

远程流媒体服务

  1. 不管是自建流媒体服务或者使用第三方的流媒体服务,都可以实现本地视频流或者多个视频流上传,然后在另个窗口订阅这些视频流,性能上面试绝对没有问题的,实现也很简单。
  2. 问题是:体验不好,会有延迟性,明明是本地的视频,非要上传,再下载,受带宽影响就会出现延迟性。经济费用:自建服务器和第三方服务都会带来经济成本。

webrtc视频传输

  1. 高性能,低延迟,无费用,这其实才是我们真正想要的
  2. WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能,这是定义也是符合我的诉求。
  3. 设备都是在同一台机器上同一个网络,更不需要穿透服务
  4. 所以我选择了WebRTC

核心概念(不在赘述)

  • webrtc基础概念
  • RTCPeerConnection相关与建立连接有关
  • MediaStream 媒体对象
  • MediaStreamEvent监听媒体加入事件可以实现在另一端获取媒体对象进行播放
  • RTCIceCandidate端和端连接
  • 腾讯团队博客
  • 微医前端博客
  • 其他博客

核心代码

  • 没有代码的技术分享都是耍流氓,这是当前技术分享一种不好的氛围
  • 各个大神的技术分享并没有什么大的问题,就是实践的场景不够抽象,其实我们是要模拟一个端对端的视频传输,有的大哥直接写在一个页面里面,js对象都是在一个内存空间里面的,这样的demo下载下来能跑可是场景不对啊,这不是端对端!有些大哥就把问题搞得有些复杂,加了一个node和sockit,有毛病吗?没毛病,太复杂了,确实生产场景可以用这样的方式去封装优化等等,像我这样的场景怎么集成呢?本地建一个sockit服务吗?
  • 最好的抽象就是:第一模拟端对端场景,第二有消息通信,难道我们没有消息通信简单的途径吗?有啊postmessage不就可以了吗?

主页面采集视频

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>parent</title>
  </head>
  <body>
    <video id="video" style="width: 400px; height: 400px"></video>
    <button id="start" onclick="start()">请先开启摄像头</button>
    <button id="start" onclick="call()">新增远程页面</button>
    <h5>下面是iframe区域可以播放父容器传过来的视频</h5>
    <div id="content"></div>
    <script>
      /**
       * @name:
       * @msg: 监听子窗口的消息
       */
      var pcMap = new Map(); // 本地链接对象集合
      window.addEventListener(
        "message",
        async (message) => {
          const mes = JSON.parse(message.data);
          const content = mes.content;
          switch (mes.type) {
            // 监听到远程加入
            case "addRemote":
              var pc = await newPC(mes.content.displayId);
              pcMap.set(mes.content.displayId, pc);
              break;
            case "RTCOnicecandidate":
              const rtcIceCandidate = new RTCIceCandidate({
                candidate: content.sdp,
                sdpMid: content.sdpMid,
                sdpMLineIndex: content.sdpMLineIndex,
              });
              //添加对端Candidate
              var pc = pcMap.get(content.displayId);
              if (pc) {
                pc.addIceCandidate(rtcIceCandidate)
                  .then(() => {
                    console.log("连上了");
                  })
                  .catch((e) => {
                    console.log("Error: Failure during addIceCandidate()", e);
                  });
              }
              break;
            //接收远端Answer
            case "RTCAnswer":
              const rtcDescription = { type: "answer", sdp: content.sdp };
              //设置远端setRemoteDescription
              var pc = pcMap.get(content.displayId);
              pc.setRemoteDescription(
                new RTCSessionDescription(rtcDescription)
              );
              break;
            default:
              break;
          }
        },
        false
      );
      /**
       * @name: start
       * @msg: 开始摄像头
       */
      function start() {
        // var constraints = { audio: true, video: { width: 1280, height: 720 } };
        var constraints = { audio: true, video: { width: 1280, height: 720 } };
        navigator.mediaDevices
          .getUserMedia(constraints)
          .then(function (mediaStream) {
            var video = document.querySelector("video");
            video.srcObject = mediaStream;
            video.onloadedmetadata = function (e) {
              video.play();
            };
          })
          .catch(function (err) {
            console.log(err.name   ": "   err.message);
          });
      }
      /**
       * @name:call
       * @msg: call呼叫子页面,简历rtc链接
       */
      var id = 0;
      function call() {
        let iframe = document.createElement("iframe");
        // 这边是模拟场景所以使用url传参的方式 真实场景id是每个设备id
        iframe.src = `2.html?id=${id}`;
        iframe.style = "width:400px;height:400px";
        iframe.id = id;
        document.getElementById("content").appendChild(iframe);
        id  ;
      }
      /**
       * @name:newPC
       * @msg:新建rtc链接
       * @return {*}
       */
      async function newPC(displayId) {
        const config = {
          // iceServers: [{ url: 'stun:stun.xten.com' }],
          configuration: {
            offerToReceiveAudio: true,
            offerToReceiveVideo: true,
          },
        };
        const pc = new RTCPeerConnection(config);
        pc.onicecandidate = (event) => {
          // 点对点链接
          if (event.candidate) {
            const mes = JSON.stringify({
              type: "RTCOnicecandidate",
              content: {
                displayId: displayId,
                sdpMid: event.candidate.sdpMid,
                sdpMLineIndex: event.candidate.sdpMLineIndex,
                sdp: event.candidate.candidate,
              },
            });
            document
              .getElementById(displayId)
              .contentWindow.postMessage(mes, "*");
          }
        };
        pc.onnegotiationneeded = (e) => {
          console.log("onnegotiationneeded", e);
        };
        pc.onicegatheringstatechange = (e) => {
          console.log("onicegatheringstatechange", e);
        };
        pc.oniceconnectionstatechange = (e) => {
          console.log("oniceconnectionstatechange", e);
        };
        pc.onsignalingstatechange = (e) => {
          console.log("onsignalingstatechange", e);
        };
        pc.ontrack = (e) => {
          console.log(e);
        };
        const offerOptions = {
          offerToReceiveAudio: 1,
          offerToReceiveVideo: 1,
        };
        // 先添加流 其他远程页面才能收到
        const stream = document.querySelector("video").captureStream();
        pc.addStream(stream);
        // 创建offer
        const offer = await pc.createOffer(offerOptions);
        // 设置本地
        await pc.setLocalDescription(offer);
        // 发送offer
        const mes = JSON.stringify({
          type: "RTCOffer",
          content: {
            displayId: displayId,
            sdp: offer.sdp,
          },
        });
        //向指定窗口发送消息
        document.getElementById(displayId).contentWindow.postMessage(mes, "*");
        return pc;
      }
    </script>
  </body>
</html>

假装是远程的iframe页面播放视频

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>child</title>
  </head>
  <body>
    <video
      id="video"
      style="width: 100%; height: 100%; object-fit: cover"
    ></video>
    <script>
      // 真实场景每个id独立唯一,子业务加载完成后告诉父页面子页面的id
      function getQueryVariable(variable) {
        var query = window.location.search.substring(1);
        var vars = query.split("&");
        for (var i = 0; i < vars.length; i  ) {
          var pair = vars[i].split("=");
          if (pair[0] == variable) {
            return pair[1];
          }
        }
        return false;
      }
      var displayId = getQueryVariable("id");
      window.pc = newPC();
      window.onload = function () {
        const message = JSON.stringify({
          type: "addRemote",
          content: {
            displayId: displayId,
          },
        });
        window.parent.postMessage(message, "*");
      };
      window.addEventListener(
        "message",
        async (message) => {
          const mes = JSON.parse(message.data);
          const content = mes.content;
          switch (mes.type) {
            //监听到远程的offer//只处理自己设备的offer
            case "RTCOffer":
              if (content.displayId == displayId) {
                // 创建的时间可以自定义不一定在offer中创建
                window.pc = await newPC();
                const rtcDescription = { type: "offer", sdp: content.sdp };
                //设置远端setRemoteDescription
                this.pc.setRemoteDescription(
                  new RTCSessionDescription(rtcDescription)
                );
                //createAnswer
                const offerOptions = {
                  offerToReceiveAudio: 1,
                  offerToReceiveVideo: 1,
                };
                pc.createAnswer(offerOptions).then(
                  (offer) => {
                    pc.setLocalDescription(offer);
                    //发送answer消息
                    const mes = JSON.stringify({
                      type: "RTCAnswer",
                      content: {
                        displayId: displayId,
                        sdp: offer.sdp,
                      },
                    });
                    window.parent.postMessage(mes, "*");
                  },
                  (error) => {
                    console.log(error);
                  }
                );
              }
              break;
            case "RTCOnicecandidate":
              if (content.displayId == displayId) {
                const rtcIceCandidate = new RTCIceCandidate({
                  candidate: content.sdp,
                  sdpMid: content.sdpMid,
                  sdpMLineIndex: content.sdpMLineIndex,
                });
                //添加对端Candidate
                if (window.pc) {
                  pc.addIceCandidate(rtcIceCandidate)
                    .then(() => {
                      console.log("连上了");
                    })
                    .catch((e) => {
                      console.log("Error: Failure during addIceCandidate()", e);
                    });
                }
              }
              break;
            default:
              break;
          }
        },
        false
      );
      /**
       * @name:newPC
       * @msg:新建rtc链接
       * @return {*}
       */
      async function newPC() {
        const config = {
          configuration: {
            offerToReceiveAudio: true,
            offerToReceiveVideo: true,
          },
        };
        const pc = new RTCPeerConnection(config);
        pc.onicecandidate = (event) => {
          // 点对点链接
          if (event.candidate) {
            const mes = JSON.stringify({
              type: "RTCOnicecandidate",
              content: {
                displayId: displayId,
                sdpMid: event.candidate.sdpMid,
                sdpMLineIndex: event.candidate.sdpMLineIndex,
                sdp: event.candidate.candidate,
              },
            });
            window.parent.postMessage(message, "*");
          }
        };
        pc.onnegotiationneeded = (e) => {
          console.log("onnegotiationneeded", e);
        };
        pc.onicegatheringstatechange = (e) => {
          console.log("onicegatheringstatechange", e);
        };
        pc.oniceconnectionstatechange = (e) => {
          console.log("oniceconnectionstatechange", e);
        };
        pc.onsignalingstatechange = (e) => {
          console.log("onsignalingstatechange", e);
        };
        pc.ontrack = (e) => {
          console.log(e);
        };
        // 监听到流了播放
        pc.onaddstream = (e) => {
          var video = document.querySelector("video");
          video.srcObject = e.stream;
          video.onloadedmetadata = function (e) {
            video.play();
          };
        };
        return pc;
      }
    </script>
  </body>
</html>

注意事项

  • 有创建就要有销毁electorn在业务场景变化时记得销毁
  • 进程之间通信业务需要封装,不要使用原始的ipc通信的方式,在项目发展后期变得难以维护

总结

  • 基于公司的业务场景,去做技术选型,去做技术预演,去封装基础的通讯库,享受代码开发带了的乐趣正如我现在写博客一样。
  • 作为一个技术人如果通宵实现一推重复任务是没有成长性的,在业务和技术成长中一个要选中到自己的平衡点。
  • 现在技术大会越来越浮夸,一个技术分享大会啥没有没讲,就讲公司内部的实践,大哥们这写都是基于场景的代码实践,个人觉得都没有一个人去把一个js好玩的api讲清楚来的有意义。
  • 应该多花一点时间在基础技术的库上面,而不是虚无缥缈的应用。应用见效快,但没有什么意义,每个公司都做低代码有什么可炫耀的!用go,或者c 去写node的扩展,完善前端基础工具链才是挺有意思的事情。谁能把electron的性能提升,包体积变小,这是我真正佩服的,再往底层写我更佩服。

0 人点赞