24、视频会议:布局管理、画中画、屏幕切换
视频会议这个功能,说白了就是多人视频的排列组合游戏。你想想看,一个房间里可能有十几个人同时开着摄像头,怎么把这些画面合理地塞进手机那块小屏幕里?这就是我们今天要啃的硬骨头。
我个人习惯把布局管理、画中画、屏幕切换这三件事放在一起讲,因为它们在工程实现上是紧密耦合的。你改一个布局,往往要同步处理画中画的层级关系,还要考虑用户切屏时的动画过渡。嗯,这里面的坑,我踩过不少。
一、布局管理的核心思路
布局管理,本质上是一个「有限空间分配问题」。手机屏幕就那么大,你要决定每个视频流占多大位置,怎么排列,怎么响应横竖屏变化。
我在项目中遇到过最头疼的情况:一个会议里突然进来20个人,每个人的视频流都要渲染。如果按传统网格布局,每个人的画面会小到看不清人脸。所以,我们得分层处理。
常见的布局策略有三种:
- 网格布局(Grid Layout):所有人画面等大,适合人数少(2-4人)的场景。
- 演讲者模式(Speaker Layout):当前发言者占大画面,其他人缩成小窗排列在底部或侧边。
- 自定义布局(Custom Layout):用户手动拖拽、缩放,类似 Zoom 的 Gallery 模式。
实际开发中,我建议优先实现演讲者模式。为什么?因为移动端屏幕小,用户最关心的是「谁在说话」,而不是「谁在背景里抠鼻子」。
二、画中画(PiP)的实现细节
画中画,英文叫 Picture-in-Picture,简称 PiP。这个功能在视频会议里特别实用——你一边开会,一边切出去回微信,视频画面还能悬浮在屏幕上。
Android 和 iOS 都提供了系统级的 PiP 支持,但实现方式完全不同。我当年第一次做 PiP 时,以为两端代码差不多,结果被 iOS 的 AVPictureInPictureController 折腾了一整天。
Android 端实现要点
Android 的 PiP 基于 PictureInPictureMode,你需要做这几件事:
// 1. 在 AndroidManifest 中声明支持 PiP
<activity
android:name=".VideoCallActivity"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|orientation" />
// 2. 进入 PiP 模式
public void enterPipMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
// 设置宽高比,建议 16:9
Rational aspectRatio = new Rational(16, 9);
builder.setAspectRatio(aspectRatio);
enterPictureInPictureMode(builder.build());
}
}
// 3. 在 PiP 模式下更新画面
@Override
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
if (isInPictureInPictureMode) {
// 切换到精简 UI,只保留视频画面
hideAllControls();
} else {
// 恢复完整 UI
showAllControls();
}
}
iOS 端实现要点
iOS 的 PiP 基于 AVPictureInPictureController,但前提是你的视频渲染层必须是 AVPlayerLayer 或 AVSampleBufferDisplayLayer。如果你用的是 WebRTC 的 RTCMTLVideoView,那就需要额外处理。
// 1. 创建 PiP 控制器
let pipController = AVPictureInPictureController(contentSource:
AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: videoView,
contentViewController: self
)
)
pipController?.delegate = self
// 2. 启动 PiP
pipController?.startPictureInPicture()
// 3. 监听 PiP 状态变化
extension ViewController: AVPictureInPictureControllerDelegate {
func pictureInPictureControllerDidStart(_ pictureInPictureController: AVPictureInPictureController) {
// PiP 已启动,可以隐藏主界面
}
func pictureInPictureControllerWillStop(_ pictureInPictureController: AVPictureInPictureController) {
// PiP 即将停止,准备恢复主界面
}
}
RTCMTLVideoView 的渲染线程在 PiP 模式下被挂起了。解决方案是在 pictureInPictureControllerDidStop 回调里强制刷新渲染层。
三、屏幕切换的平滑处理
屏幕切换,指的是用户主动切换当前显示的视频流。比如,你正在看主讲人的大画面,突然想看看后排同事的表情,点一下他的小窗,画面就切过去了。
这个功能看似简单,但要做好「平滑切换」并不容易。我见过不少 App 在切换时画面会闪一下白屏,或者卡顿半秒。原因通常是:
- 旧的视频流被销毁,新的视频流还没建立
- 渲染层的尺寸变化没有做动画过渡
- WebRTC 的
MediaStream切换时没有处理好轨道替换
我的做法是「预加载 + 动画过渡」:
// 伪代码:平滑切换视频流
function switchToSpeaker(newStream) {
// 1. 先创建新的渲染层,但设置为透明
const newVideoElement = createVideoElement(newStream);
newVideoElement.style.opacity = '0';
// 2. 将新渲染层插入到旧渲染层之上
container.appendChild(newVideoElement);
// 3. 等待一帧,确保新渲染层已经开始渲染
requestAnimationFrame(() => {
// 4. 旧渲染层淡出,新渲染层淡入
oldVideoElement.style.transition = 'opacity 0.3s';
newVideoElement.style.transition = 'opacity 0.3s';
oldVideoElement.style.opacity = '0';
newVideoElement.style.opacity = '1';
// 5. 动画结束后移除旧渲染层
setTimeout(() => {
container.removeChild(oldVideoElement);
}, 300);
});
}
这个方案的好处是:新旧画面同时存在,用户几乎感觉不到切换的延迟。坏处是内存占用会短暂翻倍,但移动端现在内存都够用,这点代价可以接受。
四、知识体系总览
下面这张图,是我自己总结的视频会议布局管理核心逻辑。你看一遍,基本就能理解这三块功能是怎么串起来的。
五、实战中的避坑指南
最后,分享几个我踩过的坑,希望能帮你省点时间:
- 不要频繁重建渲染层:每次切换布局都销毁重建
VideoView,会导致明显的闪烁。正确的做法是复用渲染层,只改变它的尺寸和位置。 - 注意音频焦点:画中画模式下,用户可能同时在听音乐。如果你强行抢占音频焦点,用户会骂娘的。建议在 PiP 模式下降低音量或静音非发言者。
- 屏幕切换时的黑屏问题:如果你在切换时直接替换
MediaStream的track,旧 track 停止后新 track 还没开始渲染,中间会有短暂的黑屏。解决方案是「双缓冲」——先让新 track 开始渲染,再停止旧 track。 - 横竖屏适配:移动端用户经常旋转手机。如果你在竖屏下用 2x2 网格,横屏下用 1x3 布局,那切换时的动画一定要平滑。我建议用
AutoLayout或ConstraintLayout做自适应,不要硬编码坐标。
好了,布局管理、画中画、屏幕切换这三块内容,今天就聊到这儿。代码示例都是我在实际项目中验证过的,你可以直接拿去用。如果遇到问题,欢迎在评论区交流。
公众号:蓝海资料掘金营,微信deep3321