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();
    }
}
注意: Android 的 PiP 模式会触发 Activity 的 onPause() 和 onResume()。如果你在 onPause() 里释放了摄像头,那 PiP 画面就会黑屏。我曾经因为这个 bug 被测试小姐姐追着骂了三天。

iOS 端实现要点

iOS 的 PiP 基于 AVPictureInPictureController,但前提是你的视频渲染层必须是 AVPlayerLayerAVSampleBufferDisplayLayer。如果你用的是 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 即将停止,准备恢复主界面
    }
}
避坑指南: 我曾经在 iOS 14 上遇到一个坑——如果用户从 PiP 模式切回 App 时,视频画面会卡住不动。后来发现是 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);
    });
}

这个方案的好处是:新旧画面同时存在,用户几乎感觉不到切换的延迟。坏处是内存占用会短暂翻倍,但移动端现在内存都够用,这点代价可以接受。

四、知识体系总览

下面这张图,是我自己总结的视频会议布局管理核心逻辑。你看一遍,基本就能理解这三块功能是怎么串起来的。

视频会议布局管理核心逻辑 布局管理 画中画 (PiP) 屏幕切换 网格布局 | 演讲者模式 | 自定义布局 Android PiP | iOS PiP | 悬浮窗口 预加载 | 动画过渡 | 轨道替换 技术实现层:WebRTC MediaStream + 渲染引擎 + 动画框架 底层依赖:Android SurfaceView / iOS RTCMTLVideoView / WebRTC C++ 层 三者相互依赖,布局变化会触发 PiP 层级调整,屏幕切换需要布局管理配合

五、实战中的避坑指南

最后,分享几个我踩过的坑,希望能帮你省点时间:

  1. 不要频繁重建渲染层:每次切换布局都销毁重建 VideoView,会导致明显的闪烁。正确的做法是复用渲染层,只改变它的尺寸和位置。
  2. 注意音频焦点:画中画模式下,用户可能同时在听音乐。如果你强行抢占音频焦点,用户会骂娘的。建议在 PiP 模式下降低音量或静音非发言者。
  3. 屏幕切换时的黑屏问题:如果你在切换时直接替换 MediaStreamtrack,旧 track 停止后新 track 还没开始渲染,中间会有短暂的黑屏。解决方案是「双缓冲」——先让新 track 开始渲染,再停止旧 track。
  4. 横竖屏适配:移动端用户经常旋转手机。如果你在竖屏下用 2x2 网格,横屏下用 1x3 布局,那切换时的动画一定要平滑。我建议用 AutoLayoutConstraintLayout 做自适应,不要硬编码坐标。
个人建议: 如果你是从零开始做视频会议,先实现「演讲者模式 + 画中画」这两个核心功能。其他花里胡哨的布局,等用户反馈了再迭代。我见过太多团队一上来就想做 Zoom 级别的布局管理,结果半年了连基本通话都还不稳定。

好了,布局管理、画中画、屏幕切换这三块内容,今天就聊到这儿。代码示例都是我在实际项目中验证过的,你可以直接拿去用。如果遇到问题,欢迎在评论区交流。


公众号:蓝海资料掘金营,微信deep3321