事情背景是这样的~
我司有个新的 Web 端产品,同时兼容 PC 端和移动端,主要用于线上研讨、教育培训等音视频通话场景。还在测试阶段时,我们团队内部在各种需要线上沟通的场景都会用这个产品进行连麦通话。真实的用户场景帮助我们发现了不少平时容易忽略的测试用例,比如在电梯里、在车库里、在地铁上等场景通话,并不断地切换场景。今天要解决的,就是我们产品小哥在地铁上发现的其中一个问题。
一个特定组合下的 bug:Android Chrome + 蓝牙耳机 + WebRTC
遇到问题
某一天团队内开会,到了下班时间,大家的兴致依然很高(bushi),没有丝毫要散会的意思。但是关键时刻,产品小哥突然收到嫂子的信息,命令他必须马上回家。会议不能突然终止,嫂子的命令也不能不听,这不巧了,我们的新产品在这个场景刚好能派上用场:留下来的人和产品小哥都加入线上会议房间并进行连麦通话,会议的材料通过屏幕共享和PPT、白板等形式共享到线上会议中。这样产品小哥回家、开会都不会耽误啦。
就这样产品小哥一边开着手机外放参加会议一边回家去了。但是到地铁上之后,产品小哥觉得外放不太公德,于是掏出蓝牙耳机,熟练地戴上耳机并成功连接到手机。这时意外的事情发生了,声音居然没有从耳机播放,而是从手机扬声器输出!怎么回事?是今天耳机坏了吗?产品小哥不敢相信自己新买的耳机怎么这么快就“寿终正寝”了!不可能,明明今天还用这耳机听歌来着。对了,先试试还能不能听歌。(切到音乐APP听歌)没问题啊!(再切回来)怎么耳机还是没有声音?几番尝试之后,终于确认了出问题的不是耳机,而是我们的新产品。
确认问题
为排除业务代码的影响,我们通过一个简单的 demo 进行测试。这个 demo 是由 WebRTC 团队 实现的,通过基础的 WebRTC API 创建本地音视频流,并使用 video
标签进行播放。
先来了解一下 demo 中使用的 WebRTC API,后续的解决方案对这两个 API 也有一定的依赖:
WebRTC API | 作用 |
---|---|
navigator.mediaDevices.enumerateDevices() | 获取媒体输入和输出设备列表。设备类型包括音频输入设备、音频输出设备、视频输入设备。 |
navigator.mediaDevices.getUserMedia() | 使用默认的参数或指定的设备信息等参数捕获一个媒体流,可以使用 video 元素进行播放。 |
考虑到 WebRTC API 对浏览器版本要求较高,并结合国人的浏览器市场份额占比进行考虑,我这里只测试了 Android 下的 Chrome 浏览器,以及 iOS 下的 Safari 浏览器和 Chrome 浏览器。
测试用例及结果:
经过一番测试,基本确认遇到的问题可以在 Android Chrome 上必现。后来又在 Chromium Bug 1285166 中发现 Chromium 团队肯定了该问题的存在,但单子状态被标记为“WontFix”,看来短期内只能由自己来填坑了,对 Android Chrome 进行兼容处理。
按照 Chromium Bug 1285166 的建议,我们本应该通过监听 devicechange
事件,然后通过 setSinkId()
切换音频输出设备。然而,在了解 setSinkId()
兼容性 后我就放弃了这个想法。
结合在测试过程中发现的一个表现——通过 getUserMedia()
使用指定音频输入设备创建视频流后,音频输出通道会自动变更为对应的设备——突然心生一计:通过切换音频输入设备来实现切换音频输出设备的效果。再回顾一下,原本我们的期望效果就是要同时切换音频输入和输出设备的,这样一来岂不是两全其美?
解决问题
备选方案及其优缺点分析
基于上述测试结果,暂时敲定了两种解决方案:
方案一:由用户自主选择音频输入输出设备
思路:
- 使用
enumerateDevices()
获取设备列表,提供一个下拉列表由用户自主选择音频输入设备; - 用户主动选择切换设备后,使用
getUserMedia()
重新创建本地音视频流; - 通过
devicechange
事件监听设备变化,并更新下拉列表。
优点:
实际使用设备与期望设备不符的问题不确定会在什么情况下发生,所以如果无论什么时候发生问题,都能由用户自主选择设备。
缺点:
需要由用户手动切换,成本较高。如果本来就没有音频输出(如,连麦房间内其他人都闭麦或只拉流不推流),用户无法感知自己使用的设备与预期不符,因此可能不会手动去切换设备。
实现过程有两点需要注意:
- 需要使用
getUserMedia()
获得媒体设备权限后才能获取有效的设备列表。 devicechange
事件兼容性较差,在 Android Chrome 上完全不支持。
针对第 2 点,通过定时器轮询设备列表实现了兼容:
// 监听设备变更事件
function initDeviceChangeListener() {
const isSupportDeviceChange = 'ondevicechange' in navigator.mediaDevices;
log(`[support] 是否支持 devicechange 事件:${isSupportDeviceChange}`);
if (isSupportDeviceChange) {
navigator.mediaDevices.addEventListener('devicechange', checkDevicesUpdate);
} else {
setInterval(checkDevicesUpdate, 1000);
}
}
const prevDevices = await getDevices();
async function checkDevicesUpdate() {
// 获取变更后的设备列表,用于和 prevDevices 比对
const devices = await getDevices();
// 新增的设备列表
const devicesAdded = devices.filter(device =>
prevDevices.findIndex(({ deviceId, kind }) =>device.kind === kind && device.deviceId === deviceId) < 0
);
// 移除的设备列表
const devicesRemoved = prevDevices.filter(prevDevice =>
devices.findIndex(({ deviceId, kind }) => prevDevice.kind === kind && prevDevice.deviceId === deviceId) < 0
);
// 设备发生变化
if (devicesAdded.length > 0 || devicesRemoved.length > 0) {
// TODO
}
prevDevices = devices;
}
这个实现方案是建立在测试过程中发现的经验的基础之上:“通过 getUserMedia()
使用指定音频输入设备创建视频流后,音频输出通道会自动变更为对应的设备”。但这是否真实可靠?
在本地开发环境中简单验证过代码可行性后,我把相关文件放到服务器上,继续使用真实的测试机进行验证(MDN 文档这里简单说明了为什么不能使用局域网内 IP 地址来访问测试代码)。
没想到,又踩了另一个坑:麦克风从默认设备切换到蓝牙耳机后,声音从听筒输出。出现问题的测试机型及浏览器:Mi 11 + 微信、HUAWEI Mate 20 Pro + Chrome 94.0.4606.85。
问题原因:选择使用非 deviceId: ’default’ 的音频输入设备,自动切换的音频输出设备不符合预期。详见 Chromium Bug 1277467。这里提到的“非 deviceId: ’default’ 设备”要怎么理解呢?我们在方案二中进行解释。
如此一来,方案一不仅不可靠,还会引入新的问题。
方案二:通过代码为用户自动切换设备
思路:
- 通过
devicechange
事件监听设备变化; - 有音频输入设备新增或移除时,使用系统的默认设备重新创建本地音视频流。
优点:
减轻用户负担,符合用户预期,自动切换过程无感知。
可是,系统的默认设备从何得知?我们先来看看,在大部分 Android Chrome 下获取到的设备列表数据是怎样的。下面的截图是在一台红米手机的 Chrome 上完成的,截图时已连接蓝牙耳机。
截图中可以看到,浏览器返回了 4 个设备信息(MediaDeviceInfo
),设备信息中的 label
代表着该设备的描述。截图中的第一个设备表示的是系统默认设备,后面 2、3、4 分别代表免提、耳机听筒、蓝牙耳机(即方案一中提到的“非 deviceId: ’default’ 设备”)。其中各 Android 设备下系统默认设备的 label
值可能存在差异,但它对应的 deviceId
都是 'default'
,所以我们可以将 deviceId === 'default'
作为系统默认设备的判断依据。
最终兼容方案
至此,问题的解决方案已经呼之欲出了。这里先放上解决方案实现代码的 GitHub 链接。
简单概括一下步骤:
- 如果不是 Android,则无需处理,直接结束。
- 保存一份设备列表,记为 prevDevices。
- 监听设备变更行为。
- 发生设备变更时,重新获取一份最新的设备列表,记为 curDevices。
对比 prevDevices 和 curDevices,得出新增及移除的设备列表。
如果是“主播”角色:
- 如果新增或移除设备列表中包含类型为
audioinput
的设备,使用系统默认的audioinput
设备作为音频源重新创建一路本地音视频流。 - 替换本地音视频流(或仅替换本地音视频流的音轨,使用
RTCRtpSender.replaceTrack()
)。
- 如果新增或移除设备列表中包含类型为
如果是“观众”角色:
- 如果新增设备中包含蓝牙耳机,提示用户可能会没有声音,可以通过刷新页面来解决。
PC 端的设备热插拔处理
为什么需要处理热插拔?
和移动端设备热插拔相比,PC 端的浏览器实现基本没有问题,包括 devicechange
事件兼容性、切换音频输入设备后未自动切换到对应的音频输出通道等问题在 PC 上都不存在。那 PC 端需要处理的又是什么问题呢?
事情是这样的,在某次直播中,推流画面卡在了最后一帧,经过一轮排查,最后确认问题是外接摄像头的数据线接触不良。
这些情况下,我们能做的更多是交互上的优化,比如提醒用户或提供切换设备的快捷入口等。如下为 PC 端新增设备的用户提示效果图。
我们的处理方案
我们把 PC 端的设备热插拔分成了两种情况,新增设备和移除设备。
新增设备时一般情况下不需要自动切换到新设备,而是提醒用户并提供切换到新设备的快捷入口。但是有一种情况例外,当新增设备是该类型设备列表中唯一的设备时,由代码内部实现自动切换。整体流程图如下:
移除设备时,只需要对移除当前在使用设备的情况做处理:自动切换到其他可用设备。整体流程如下:
两个流程中都提到的“正在使用的设备”,这个是怎么判断的呢?
很简单,先拿到正在使用的本地媒体流对象,通过 MediaStream.getVideoTracks()
和 MediaStream.getAudioTracks()
可以分别获取到视频轨道和音频轨道对应的 MediaStreamTrack
实例;再通过 mediaStreamTrack.getSettings().deviceId
就可以得知当前使用设备的 deviceId
,从而可以判断是否为“正在使用的设备”。
实现代码:
/**
* 从给定的设备列表找当前在使用的设备
* @param {MediaStream} localStream - 在使用的本地音视频流
* @param {MediaDeviceInfo[]} devices - 给定的设备列表
* @returns MediaDeviceInfo[]
*/
function getInUseDevices(localStream, devices) {
// localStream 为本地音视频流
if (!localStream) return [];
const audioTrack = localStream.getAudioTrack();
const videoTrack = localStream.getVideoTrack();
const inUseMic = audioTrack ? audioTrack.getSettings().deviceId : '';
const inUseWebcam = videoTrack ? videoTrack.getSettings().deviceId : '';
const inUseDevices = [];
for (let i = 0; i < devices.length; i++) {
const device = devices[i];
if ((device.kind === 'audioinput' && device.deviceId === inUseMic) ||
(device.kind === 'videoinput' && device.deviceId === inUseWebcam)) {
inUseDevices.push(device);
}
}
return inUseDevices;
}
其实 PC 端也有“坑”
虽然 PC 端没有 devicechange
事件的兼容性问题,但是在某些特定情况下获取设备列表的接口 navigator.mediaDevices.enumerateDevices()
也会“失灵”。目前发现的其中一种情况是,在 windows 下使用外接摄像头作为视频源时关闭页面后移除设备,重新打开页面时获取的设备列表里会返回已移除的外接设备。这种情况需要重启浏览器才能恢复正常。如果想要关注问题详情及进度可以查看 Chromium Bug 1336115。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。