2.4、实战案例(一)
文章导读:本节开始咱们正式进入本章的项目实战,本节先来实现两个功能:摄像头的开启和关闭。推荐阅读方式:实操。
为了保证案例代码的足够简单,这里了不引入任何第三方css美化库,但为了使得案例界面显得规整,我简单的写了一些css样式,实现UI界面如下图2.4.1所示。
图 2.4.1 (项目软件界面)
本章的实战内容将完整实现上图所展示的所有功能, 在本章导读中我已经简单演示了该软件的相关操作, 这里再详细介绍下要实现的功能:①、软件加载完毕后,如上图2.4.1所示,默认情况下不会开启摄像头,所以所有的按钮除了“开启摄像头”之外均不可用。②、当点击左上角的“开启摄像头”按钮,则触发启动摄像头功能,如果启动摄像头成功,黑框部分将显示摄像头视频画面,此刻“更新配置、开始录制、更新滤镜”这三个按钮可用。③、点击“更新配置”按钮,软件将根据最新的采集配置,重新打开摄像头。④、点击“更新滤镜”按钮,可实时更新视频滤镜效果。⑤、点击“开始录制”按钮,即开始录制视频,此时“结束录制”按钮可用。⑥、点击“结束录制”按钮,则自动将录制视频保存到本地。通过实现这个案例,我们将对webrtc的设备管理以及音视频数据采集有更深的理解和实战经验。
由于篇幅原因,本案例中HTML、CSS代码将按需展示,非核心代码请读者从本书代码仓库中获取。接下来,我们根据软件的功能来设计程序逻辑:
第一、软件状态设计。之所以要考虑运行状态是因为不同的状态可操作的功能不一样,比如软件都加载完成之前(未就绪),所有功能将不可用;摄像头打开之前, 参数配置、录制功能都不可用 等。根据需求,软件的状态可分为:未就绪,已就绪、已开启摄像头、录制中 4种状态。四种状态的关系为:①、系统默认是未就绪状态,所有功能都不可用,即软件页面加载完之前的状态。②、软件页面加载完成后,系统状态为“已就绪”,此刻仅“打开摄像头”功能可用,其他功能不可用,因为摄像头没开,其他功能没法“玩”。③、已开启摄像头状态,这个状态是用户点击“打开摄像头”按钮并且成功打开摄像头后的状态,该状态下除了“打开摄像头”和“停止录制”两个功能外,其他功能均可用。④、录制中状态。该状态是用户点击“开始录制”按钮后,软件正在录制视频的状态,该状态下只有“结束录制”功能可用。这些就是本软件的状态关系。接下来,在分析每个功能逻辑。
第三、开启摄像头功能。这个功能将要读取到的默认
摄像头的视频画面并播放到黑色video标签中,如果成功,更新软件状态为“已开启摄像头”,此刻更新配置功能、更新滤镜功能、开始录制功能可用。同时加载本机可用的视频输入设备列表到“采集设备选择”下拉选框中。如果失败,则打印相关的异常信息。
第四、更新配置功能。这里更新的是视频采集参数配置,更新完毕之后实时作用到媒体流中。
第五、更新滤镜功能。与更新配置功能类似,通过调节滤镜效果参数来实现不同的滤镜叠加效果。
第六、开始录制功能。如果开始录视频,软件状态为“录制中”,为了保证逻辑的严谨,此刻只有“结束录制”功能可用。
第七、结束录制功能。结束录制之后,状态再次变成“开启摄像头”状态、并下载视频到本地。
至此,软件逻辑设计完毕,接下来编码实现。 本节要完成的内容:①、状态管理代码编写。②、打开/关闭摄像头代码编写。
为了尽可能提高代码的健壮性,符合工程需求,本次实战的的代码编写原则:行为/结构/样式相分离。即JS逻辑、HTML结构、CSS样式相互分离。 在JS逻辑中尽可能通过“对象”来管理逻辑。先来看第一个逻辑——状态管理,代码如下。
// 系统状态对象 const statusManager = { //未就绪(-1),已就绪(0)、已开启摄像头(1)、录制中(2)、 status: -1, // 设置系统状态 setStatus(statusCode) { if (isNaN(statusCode) || (statusCode > 2 || statusCode < -1)) { throw new Error("状态码不合法"); } if (statusCode != this.status) { // 触发状态变动回调 this.onStatusChanged(statusCode); } this.status = statusCode }, //读取系统状态 getStatus(statusCode) { return this.status = statusCode }, }
上述代码中,我们创建了一个对象——“statusManager”来管理状态相关的逻辑。status属性用来存储状态信息,四种值分别表示4中状态“ 未就绪(-1),已就绪(0)、已开启摄像头(1)、录制中(2)、”。为了做好逻辑的封装,读和写状态分别通过“setStatus”和“getStatus”两个方法来操作,另外,在setStatus方法中,当检测到状态发生变动时,系统的可用功能将会变化,所以我们要触发“状态变动”事件来通知对应的处理方法,于是我们要加入“onStatusChanged”回调方法来监听系统状态变动,onStatusChanged方法代码如下。
// 当系统状态发生变动的回调 onStatusChanged(statusCode) { switch (statusCode) { case 0: this.systemReady();// 就绪 break; case 1: this.openedCamera();// 摄像头打开成功 break; case 2: this.startedRecord();// 开始录制 break; default: console.log("onStatusChanged 未知状态", statusCode); break; } }
上述代码中,我一共处理了三种状态的变动:系统就绪、摄像头打开成功、开始录制。但是并没有加入“未就绪”状态的处理,原因是未就绪状态一般只存在于页面加载完成之前,当页面加载完后不可能再回到未加载状态。 如上的代码,当onStatusChanged被触发,根据不同系统状态码,我们做不同的处理,例如当状态码为0说明系统就绪了,此刻我们要决定接哪些功能可用,哪些按钮可点,这个逻辑我们放在“systemReady”方法中实现,同理其他方法也是如此。于是我们要完成“systemReady、openedCamera、startedRecord”这三个方法的实现。目前这三个方法主要通过改变按钮的状态来控制某些功能的可用与否,在当前软件页面中,目前有6个按钮,如上图2.4.1所示,考虑到目前界面的HTML代码量较大,下面只展示核心的HTML代码,6个按钮代码如下。
<button disabled id="openCamera">开启摄像头</button> <button disabled id="closeCamera" >关闭摄像头</button> <button disabled id="updateConstraints">更新配置</button> <button disabled id="startRecord">开始录制</button> <button disabled id="stopRecord">结束录制</button> <button disabled id="updateFilter">更新滤镜</button>
在JS中,我们通常通过document.getElementById来读取DOM元素,但我们都知道DOM读取操作是比较消耗性能的,于是我考虑通过一个对象来统一管理DOM元素,这里命名为domManager,即dom管理器。domManager的管理逻辑就是:统一提供一个对外读取dom元素的方法——getDom,为了读取dom元素的效率,这里规定使用ID来读取。getDom方法首次读取某个dom元素时用document.getElementById来读取并缓存,从第二次开始,往后读取dom元素可以优先从缓存中读取。domManager实现代码如下。
const domManager = { doms: new Map(),// 用一个Map来缓存dom对象 getDom(domId) { let dom = this.doms.get(domId); if (!dom) { dom = document.getElementById(domId); if (!dom) { throw new Error(`读取button ${domId}失败`); } this.doms.set(domId, dom); } return dom; }, }
有了统一的dom管理之后,往后操作dom就很方便了。接着上面的三个方法的编写——systemReady、openedCamera、startedRecord。代码如下。
// 系统就绪 systemReady() { domManager.getDom("openCamera").disabled = false; domManager.getDom("closeCamera").disabled = true; domManager.getDom("updateConstraints").disabled = true; domManager.getDom("startRecord").disabled = true; domManager.getDom("stopRecord").disabled = true; domManager.getDom("updateFilter").disabled = true; }, // 已开启摄像头 openedCamera() { domManager.getDom("openCamera").disabled = true; domManager.getDom("stopRecord").disabled = true; domManager.getDom("closeCamera").disabled = false; domManager.getDom("updateConstraints").disabled = false; domManager.getDom("startRecord").disabled = false; domManager.getDom("updateFilter").disabled = false; }, //已开始录制 startedRecord() { domManager.getDom("openCamera").disabled = true; domManager.getDom("closeCamera").disabled = true; domManager.getDom("updateConstraints").disabled = true; domManager.getDom("startRecord").disabled = true; domManager.getDom("stopRecord").disabled = false; domManager.getDom("updateFilter").disabled = true; },
上述的三个方法是状态管理器——statusManager 中方法,如果你理解整个状态的管理,上述的代码就非常简单,注意domManager.getDom("updateFilter").disabled = true表示的是按钮不可用,disabled = false是按钮可用。接下来看看statusManager、domManager这两个对象的的完整代码。
// DOM管理对象 const domManager = { doms: new Map(),// 用一个Map来缓存dom对象 getDom(domId) { let dom = this.doms.get(domId); if (!dom) { dom = document.getElementById(domId); if (!dom) { throw new Error(`读取button ${domId}失败`); } this.doms.set(domId, dom); } return dom; }, } // 系统状态对象 const statusManager = { //未就绪(-1),已就绪(0)、已开启摄像头(1)、录制中(2)、 status: -1, // 设置系统状态 setStatus(statusCode) { if (isNaN(statusCode) || (statusCode > 2 || statusCode < -1)) { throw new Error("状态码不合法"); } if (statusCode != this.status) { // 触发状态变动回调 this.onStatusChanged(statusCode); } this.status = statusCode }, //读取系统状态 getStatus(statusCode) { return this.status = statusCode }, // 当系统状态发生变动的回调 onStatusChanged(statusCode) { switch (statusCode) { case 0: this.systemReady(); break; case 1: this.openedCamera(); break; case 2: this.startedRecord(); break; default: console.log("onStatusChanged 未知状态", statusCode); break; } }, // 系统就绪 systemReady() { domManager.getDom("openCamera").disabled = false; domManager.getDom("closeCamera").disabled = true; domManager.getDom("updateConstraints").disabled = true; domManager.getDom("startRecord").disabled = true; domManager.getDom("stopRecord").disabled = true; domManager.getDom("updateFilter").disabled = true; }, // 已开启摄像头 openedCamera() { domManager.getDom("openCamera").disabled = true; domManager.getDom("stopRecord").disabled = true; domManager.getDom("closeCamera").disabled = false; domManager.getDom("updateConstraints").disabled = false; domManager.getDom("startRecord").disabled = false; domManager.getDom("updateFilter").disabled = false; }, //已开始录制 startedRecord() { domManager.getDom("openCamera").disabled = true; domManager.getDom("closeCamera").disabled = true; domManager.getDom("updateConstraints").disabled = true; domManager.getDom("startRecord").disabled = true; domManager.getDom("stopRecord").disabled = false; domManager.getDom("updateFilter").disabled = true; }, }
至此,我们完成了DOM和系统状态的管理,但这些JS逻辑和HTML页面还没有产生关联,比如当点击页面中的某个按钮时还不会有任何效果。为了让页面点击有效果,接下来我们来完成本节的最后一块内容——“摄像头的打开和关闭”。我们需要管理两部分的内容: 事件绑定的管理、 摄像头管理。事件绑定的管理就是管理HTML页面中哪些元素/按钮需要绑定哪些事件,比如“打开摄像头”按钮应该绑定“打开摄像头JS逻辑”。按照“行为结构相分离”的面向对象编程原则,dom元素的事件绑定不会如此写“<button onclick='xxx()'>按钮</button>”,取而代之我们创建单独的——eventManager,用于管理HTML的DOM元素的事件绑定。代码如下。
// 事件方法管理对象 const eventManager = { // 初始化按钮事件的监听 eventInit() { // 打开摄像头 domManager.getDom("openCamera").onclick = () => { cameraManager.openCamera({ video: true, audio: false, }).then(media => { domManager.getDom("myvideo").srcObject = media; statusManager.openedCamera(); }).catch(err => { console.log("读取媒体失败", error) }) } // 关闭摄像头 domManager.getDom("closeCamera").onclick = () => { cameraManager.closeCamera() statusManager.systemReady(); } }, }
eventManager对象目前暂时只有一个方法——eventInit,用于初始化所有按钮的事件绑定,上述代码我们绑定两个按钮:openCamera按钮、closeCamera按钮。openCamera按钮的逻辑是打开摄像头,并且把视频画面展示出来,同时还要更改软件的状态。closeCamera按钮的逻辑是关闭摄像头,也要更改软件状态。代码中提到一个新的对象:cameraManager,可以这么理解,该对象是用于管理摄像头相关操作的,如摄像头打开、关闭,下面会详细讲解cameraManager。eventInit什么时候被触发呢?该方法在页面加载完成后调用,代码如下。
// 页面加载完成 window.onload = function () { // 系统就绪 statusManager.setStatus(0); // 初始化按钮事件的监听 eventManager.eventInit(); }
摄像头管理可以看成是一个单独的业务,所以应设计一个对象来管理,代码如下。
// 摄像头管理对象 const cameraManager = { // 缓存流对象,方便管理,如关闭流、读流信息等 mediaStream: null, //开启摄像头 async openCamera(mediaStreamConstraints) { let media = await navigator.mediaDevices.getUserMedia(mediaStreamConstraints);this.mediaStream = media; return media; }, // 关闭摄像头 closeCamera() { if (this.mediaStream) { let trackes = this.mediaStream.getTracks(); if (trackes) { trackes.forEach(track => { if (track) { track.stop(); } }); } this.mediaStream = null; } }, }
如上述代码所示, openCamera为打开摄像头的逻辑,这里使用了“async/await”的写法,如果不熟练的读者,自行查询下资料。openCamera方法只关心一件事情,根据传进来的采集音视频配置参数mediaStreamConstraints来读取设备流数据,并且把读到的流对象返回,同时为了方便管理流对象,顺便把流对象赋值给自身的mediaStream属性,逻辑结束。openCamera为关闭摄像头的逻辑,此处就体现出把流对象赋值给自身属性mediaStream的作用——方便他时之用,在webrtc中关闭摄像头就是把通过的摄像头读取的流关闭掉即可,按照正常逻辑,应该这么关闭:mediaStream.stop()。但是却没有这么简单粗暴方法,取而代之却是得从流对象中读取所有的轨,并且把所有的轨一个个的关闭来达到关闭流的效果,所以上述的代码中才会通过“this.mediaStream.getTracks()”读取到本流中的所有轨,并且一条条的关闭掉。
这里提到了一个新概念——轨,所谓的轨从直观上感受就是一条水平的线,在webrtc中,一个流中包含了多个轨,如视频轨、音频轨,音频轨还可能有多条,例如直播的时候,除了主播的声音外还附加了一些背景音乐,这就说明这个流的音频轨中,除了主播的音频轨还有其他音乐的音频轨叠加在一起达到了“混音”的效果。
至此,本软件的打开和关闭摄像头的功能就实现完毕了,内容实操性强,为了保持文章的简洁性,只列举了核心的代码讲解,读者读完文章后,请务必自己实践代码,相关完整demo代码请在本书源码仓库中提取。总结一下:本节就实现了摄像头的打开和关闭,单纯从技术的技术来分析,这两个功能实在太简单(打开和关闭摄像头无非就是getUserMedia和track.stop),不足以用这么大的篇幅来讲解,但是我们要做的是软件开发,而不仅是程序的编写, 一个完整的软件由很多个程序联合起来的,而且各个程序之间并不是独立的,它们有着复杂的调用关系,因此本节的内容大部分的篇幅与其说是编写“打开、关闭摄像头”的逻辑,不如说是以“面向对象、行为结构样式相分离”的前端软件思维构架一个软件,虽然到最后只实现了两个功能,但总体的软件结构已经建立起来,其他的功能便可按部就班,有条理的实现出来,这便是本节带来的干货。下节内容我们来实现调整视频采集的相关参数。