H5切换手机摄像头竟然上演“无间道”
受新冠疫情的影响,大多数同学应该和我一样,无论工作和生活,都受到了日常行为的适应和改变。生活上只能宅在家里不敢出门,看看直播,工作上安排远程办公,在线协同办公,同步在线会议,实时音视频通话。在这期间呢,我也很有幸能参与到这方面的开发中,然后提供给我司一些业务线进行应用。然后在这个过程中呢,还见证了挺多“前无度娘,后无知乎”的坑。在这里想把一些开发痛点和心得做分享,本期分享主要会由浅入深的聊一下手机摄像头在web(H5)如何被调起,以后如何切换,并且切换过程中发现规律和如何避免“无间道”。
一、H5在使用摄像头的场景
目前来看,常见的应用场景主要集中在以下这些情况。
1. 直播
2. 音视频通话、在线会议、在线面试、在线教育
3. 拍照传图凭证
或许还有其它的应用场景,如果你在做其它关于这方面的场景也可以留言告诉我们。
二、H5如何获取你的所有视频输入(摄像头)设备
当你需要使用你的手机摄像头,那肯定需要知道你的手机有哪些摄像头能提供给你。 这里给大家先介绍一下navigator.mediaDevices.enumerateDevices这个方法,它主要是用于获取一份当前设备所有媒体输入和输出信息的一个列表。它会以promise语法的形式返回一个MediaDeviceInfo对象的数组。既然说到了输入和输出,它就会分为两类:音频的输入(audioinput)/输出(audiooutput)和视频的输入(videoinput)/输出(videooutput)。每次获取成功以后,获取到的列表中,分别对应的有deviceId,groupId,kind,label。
let videoInputList = [];
navigator.mediaDevices.enumerateDevices().then(res => {
console.log("所有媒体输入/输出设备信息",res);
res.forEach(item => {
if (item.kind == "videoinput") {
videoInputList.push(item);
}
});
});
// 以华为p40为例(总共6个)
deviceId:307cf789196800f39db5e402310766b809266a3acf395b9140a0fa396dfdc140,
groupId:04bd96314fc0a5a1c2933c87915747f4bd8e923259d7db92563d7528ed87b0e8,
kind:videoinput,
label:,
...中间还有4个
deviceId:a8a545e5bf756a728498bf0c23c05f6c5f7b1c57cdc6d1d4340e45821ccf8a38,
groupId:634a6a55b90dadca24ebcc63a7ee0b1c088b7d821bc8d1b2308c0cd618393842,
kind:videoinput,
label:
deviceId主要表现为本次获取时所赋予的一个随机id,这个id是临时的,并不是唯一id,它是我们使用和切换的重要关键,每当重新获取,他就会重新随机生成;groupId主要表现为同设备的标识,比如说你当前手机插着耳机,耳机既满足输入条件又满足输出条件,这个时候,你就可以通过groupId去找到对应的audioinput和audiooutput,这个id是临时的,并不是唯一id,至于kind就是上面标注的类型;label在当前是为空的,因为你当前只是获取,但还没通过授权进行调取,所以这个时候它是不能公开它的身份的。
看难点:这里其实就是埋下了伏笔,我虽然拿到他们的信息,但我却不知道他们哪些是前置,哪些是后置?这个时候,其实也悄悄的发现,貌似“卧底”已经悄悄走入到大伙之中。上述的华为p40手机从外型表层看,无非就是有2个前置摄像头,3个后置摄像头。但为啥我们获取的输入设备会有6个呢?隐藏的很深哦,让我们继续把它揪出来。(这个可能要视厂商暴露出来的设备信息因“机”而异,有些厂商暴露出来的并不存在“卧底”,甚至“派代表”出席,后面会介绍这个,也是破案关键)
三、H5如何调起使用你获取到的摄像头?
function test() {
let getDeviceId = document
.getElementById('deviceIdInput').value;
let constraints = {
deviceId: {
exact: getDeviceId
}
};
navigator.mediaDevices.getUserMedia({video: constraints})
.then(res => {
console.log("返回的就是你指定摄像头采集到的流", res);
const getMTracks = res.getTracks();
console.log('看视频流的具体参数: ', getMTracks);
})
.catch(err => {
console.log(err);
})
}
//以华为p40为例
传入的deviceId:307cf789196800f39db5e402310766b809266a3acf395b9140a0fa396dfdc140
获取到的视频具体信息:
MediaStreamTrack:[{
kind: "video"
label: "camera2 1, facing front"
}]
这里又给大家介绍一下navigator.mediaDevices.getUserMedia这个方法,它主要是根据的配置的请求参数constraints(约束),去请求调取相应媒体设备的授权,一旦通过允许,媒体输入流产生一个MediaStream,该constraints主要就是分为视频流和音频流,另外还有很多其它的约束配置,可以去MDN关于约束配置的参数进行详细了解。这个MediaStream返回流你通过获取video标签实例进行srcObject挂载在该属性上,即可看到当前摄像头采集到的影像。 这个时候,你想查看到这个流里面包含的所有播放的影像轨(一般来说分为视频轨和音频轨),你就可以通过getTracks这个方法获取,你甚至可以通过相应的增加或移除的方法,实现对你采集到的流进行处理。 getTracks方法之后,你就可以知道你每个轨对应类型和其它信息,(由于我的方法只获取视频流,所以只拿到了视频轨一个,没有音频轨),这个时候,大家可以重点观察label,前面我们看到的label为空,但通过授权获取以后发现,这里可以看到front,这就是代表你使用的是前置摄像头,back代表后置摄像头。
看难点:这个时候其实大家也明晰了,其实就是通过deviceId去指定使用哪个摄像头,然后通过获取其视频轨的label,我就可以确定这个是前置还是后置摄像头。这个时候肯定也会有人问,constraints中video有配置facingMode可以指定使用前置和后置。的确是可以通过这个方法去实现切换,但实际出来的效果却是苹果手机对其支持非常好,但在安卓手机的表现却是各有千秋,大部分都是切换不过来,尤其现在的手机都是多个前置多个后置这个问题。这就是摄像头切换闹别扭的重要问题,我们需要去解决它。
四、如何顺利解决切换前后置摄像头的问题呢?
其中大家看完前面两个方法,你们应该可以总结出大概的思路:(知识点)
- 首先通过enumerateDevices获取到所有摄像头deviceId
- 然后通过getUserMedia对每一个摄像头deviceId进行遍历调起,找出属于前置还是后置
- 对前置和后置分为两个队列进行维护,分别从两个队列中选定一个进行切换
- 每次切换,先对前一次切换的所有正在进行流轨道进行stop暂停,再利用流轨道替换或者移除添加的形式,将新切换的流代替原有的流。
这样就基本完成了一次切换摄像头的流程。接下来我们对这个过程进行解析。 首先,第一个方法是完成前三个步骤的处理。
//这个方法主要是如何根据苹果和安卓分别进行处理
//苹果直接遵守facingMode切换的模式
//安卓对前后置两个队列进行维护
switchCamera() {
let that = this;
//首先,苹果手机直接遵守facingMode的设置,所以直接利用该属性完成切换,无需对deviceId进行处理
if (Boolean(navigator.userAgent.match(/iphone|ipod|iOS|ipad/gi))) {
that.frontCamera = !that.frontCamera;
let deviceAll = {
front: that.frontCamera,
deviceId: false,
audioStatus: this.audio
};
EventBus.emit("switchCamera", deviceAll);
} else {
//暂停当前媒体流
EventBus.emit("stopMedia", false);
//checkCameraFlag这个变量主要是标识,观察是否已经完成前后置队列维护
if (this.checkCameraFlag) {
that.frontCamera = !that.frontCamera;
//这里切换前后置摄像头,我分别都是取队列的第一个
//(那时听客户端同学的推荐,让我取第一个)
let deviceAll = {
front: that.frontCamera,
audioStatus: that.audio,
deviceId:
that.frontCamera == true
? that.frontCameraList[0]
: that.backCameraList[0]
};
EventBus.emit("switchCamera", deviceAll);
} else {
//这里就是我们前面两个方法的结合
let videoInputList = [];
let videoStreamList = [];
let frontList = [];
let backList = [];
navigator.mediaDevices.enumerateDevices().then(res => {
console.log("res枚舉所有設備: ", res);
res.forEach(item => {
if (item.kind == "videoinput") {
videoInputList.push(item);
}
});
if(videoInputList.length > 0){
//这部分分别找出两个前后置队列就是遍历分插,我们就忽略这部分了
...
}
}
}
}
然后,第二个方法是完成切换。我们主要将其封装在sdk里面
switchCamera(front, customConstraints = null, times = 0) {
if (front.deviceId !== false) { //如果不是苹果手机
if (this.localMediaStream) {
let getOrginTrack = this.localMediaStream.getTracks();
getOrginTrack.forEach(item => {
item.stop(); //先把前面所有的流轨道都进行暂停
});
}
let self = this;
//把传入的新的约束进行处理
this.front = front.front;
let constrains = customConstraints || this.constrains;
constrains = this.deepClone(constrains);
constrains.video.facingMode = this.front ? "user" : "environment";
if (front.deviceId !== false) {
constrains.video.deviceId = front.deviceId;
}
getUserMedia(constrains,stream => {
//这里就是处理你新切换的流拿到以后,去替换你旧的流部分
this.localMediaStream = stream;
this.replaceSenderTrack(this.localMediaStream);
...
})
}
}
看难点:看到这里,貌似基本已经顺理成章的完成了切换摄像头的处理。但实际并非如此,还记得那个“卧底”吗?以华为p40为例,只有5个摄像头的手机,为啥我们采集的时候发现会有6个?那多出来的那个摄像头设备究竟是啥呢?我们去把那个卧底揪出来。
五、如何避开“卧底”?
其实大家认真看看你的后置摄像头旁边通常还有个啥,你就知道“卧底”是谁啦。对的,没错,其实**“卧底”就是闪光灯**。当我们调取这个闪光灯对应的deviceId时,就会捕获到一个err信息。
code: 0
message: "Could not start video source"
name: "NotReadableError"
认真看看报错的name和message。再看看MDN上面的描述:
NotReadableError[无法读取错误] 尽管用户已经授权使用相应的设备,操作系统上某个硬件、浏览器或者网页层面发生的错误导致设备无法被访问。
所以说,当你把“卧底”进行调起时,它的确也遵守授权,但一旦出错,就会影响到整个前后置队列的维护和管理。(知识点),我们还发现一个问题,华为p40一旦被该摄像头抢占当前的使用资源,就无法释放其占用规则。当你重新刷新浏览器都无法再次调起其它摄像头了。这时候,你只能暴力手动重新打开关闭对应浏览器的摄像头权限,并重新打开应用,才能重新去调起其它摄像头。但这还是有问题啊,我们还是没解决啊,一旦再点切换,这不还是会出问题吗? 其实,并不是所有安卓手机都像华为p40那样,一旦捕获“卧底”失败就会抢占。有些还是会自动释放的。你可以在维护前后置队列中,捕获到错误进行跳过它再进行其它摄像头采集就可以了。但这也是解决一些可以释放摄像头的手机的可用方法。但还是没解决最根本的“卧底”占用不释放的问题。 既然我们干不掉它,那我们可以想办法去避免它。 我试了对各大手机厂商当下比较热门的款式进行了一次采样,发现得出来的结果规律非常令我震惊:
采样机型:
华为p40 (闪光灯报错占用)、华为荣耀30、华为p30、华为 mate30 pro 5g (闪光灯报错不占用)
小米8、小米9、小米6、红米 k30 5g
oppo r17s、vivo x27
一加5T
**重点**
采样结果总结:(通过enumerateDevices和getUserMedia方法结合)。
华为:
前置
闪光灯(有的报错,有的不报错)(华为p40报错抢占,华为mate30报错不抢占)
后置
小米 / 红米 / vivo / oppo / 一加:
只有2个提供
一前一后
苹果:
直接遵守facingMode前后user/environment
这个时候,看完这个总结分析是不是就很明晰。其实enumerateDevices会在收集你的摄像头设备信息时,已经根据排列组合,帮你处理好每个厂商暴露出来给我们使用的设备序列。有的像华为它会把闪光灯也包括在内,并暴露多个前后置给你使用;有的像小米,它无论有多少个前后置,就是以"派代表"的形式给予你切换使用。 所以,弯道超车的想法就可以实现,如何避免它,其实大家心里就有想法了,就是每次通过enumerateDevices收集完设备信息后,比较稳妥的做法去避免处理。前置取第一个,后置取最后一个。这样,不仅仅不用去维护两个摄像头队列去遍历,而且大大提升了整体的切换速度。从而既避免了“卧底”,又提升了速度,何乐而不为呢?
总结
- 注重观看enumerateDevices方法的设备收集排序
- 摄像头一旦出错是否会释放不独占(主要是围绕“闪光灯”是卧底的问题)
- 前置取第一个,后置取最后一个
疑惑提问
如果大家看完这篇文章发现什么问题或者有什么建议的,可以在**“前端良文”公众号上进行留言。或者可以直接发邮件到526742460@qq.com给我,我都会对你们的提问或者建议进行收集最后汇总,最后统一进行处理。我是CNLIANG(世恩)**,非常感谢你们能读完这篇分享。