15、多视频同屏渲染:多SurfaceView布局、SurfaceView层级管理、Z-order控制、性能优化

多视频同屏渲染,说白了就是在一个屏幕上同时播放好几个视频画面。比如监控墙、视频会议、直播连麦,这些场景都需要同时渲染多个视频流。

我最早接触这个需求,是在做一个安防监控项目的时候。客户要求一个屏幕显示16路摄像头画面,还得能自由切换布局。当时我第一反应是:用16个SurfaceView叠在一起不就行了?结果一跑起来,画面闪烁、黑屏、层级错乱,各种问题全来了。

嗯,今天我就把这块的经验好好梳理一下。多SurfaceView同屏渲染,核心要解决三个问题:布局怎么摆、层级怎么管、性能怎么保。

多SurfaceView布局方案

先说说布局。SurfaceView有个特点——它自带一个独立的绘图表面,这个表面是异步更新的。这意味着你不能像普通View那样,随便用FrameLayout或者ConstraintLayout把它们叠在一起。

我个人习惯用RelativeLayout或者GridLayout来做多SurfaceView的容器。为什么?因为这两种布局对子View的位置控制比较灵活,而且不会因为布局嵌套过深导致性能问题。

举个例子,一个2x2的网格布局:

<RelativeLayout
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <SurfaceView
        android:id="@+id/surface_0"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true"
        android:layout_toLeftOf="@id/divider_v"
        android:layout_above="@id/divider_h" />

    <!-- 其他三个SurfaceView类似,用分割线定位 -->

    <View
        android:id="@+id/divider_h"
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:layout_centerVertical="true"
        android:background="#333" />

    <View
        android:id="@+id/divider_v"
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:layout_centerHorizontal="true"
        android:background="#333" />
</RelativeLayout>

这里有个坑:SurfaceView的宽高不能设为match_parent。你想想看,如果两个SurfaceView都match_parent,它们会完全重叠,后面的会把前面的盖住。我建议用0dp配合layout_alignParent等属性来精确控制位置。

小技巧:动态切换布局时,不要直接removeAllViews再addView。这样会导致SurfaceView重建,画面会闪一下。更好的做法是:用LayoutParams动态调整位置和大小,SurfaceView本身不需要销毁重建。

SurfaceView层级管理

层级管理,这是多SurfaceView方案里最让人头疼的部分。为什么?因为SurfaceView的Z-order机制和普通View不一样。

普通View的层级由它们在布局文件中的顺序决定,后面的盖在前面。但SurfaceView不一样——它有自己的窗口(Window),这个窗口的Z-order是独立于View树的。

我曾经遇到过一个场景:一个视频通话界面,本地预览画面要放在右下角小窗,远程画面占满屏幕。按理说,小窗应该盖在大画面上。但实际效果是,大画面总是盖住小窗,怎么调布局顺序都没用。

原因就是SurfaceView的Z-order默认是按创建顺序决定的,后创建的SurfaceView窗口层级更高。跟布局文件里的顺序没关系。

解决方案有两个:

  1. setZOrderOnTop(true):让某个SurfaceView始终显示在最顶层。但要注意,这个方法会让SurfaceView盖住所有其他View,包括按钮、文字等。
  2. setZOrderMediaOverlay(true):让SurfaceView显示在媒体层之上,但仍在普通View之下。这个更适合视频叠加场景。

看代码:

// 小窗SurfaceView,希望显示在最上面
SurfaceView smallView = findViewById(R.id.surface_small);
smallView.setZOrderOnTop(true);
// 注意:这样它会盖住所有普通View,包括控制按钮

// 更好的做法:用ZOrderMediaOverlay
SurfaceView bigView = findViewById(R.id.surface_big);
bigView.setZOrderMediaOverlay(false);  // 默认行为

SurfaceView smallView = findViewById(R.id.surface_small);
smallView.setZOrderMediaOverlay(true); // 显示在媒体层之上
注意:setZOrderOnTop(true)和setZOrderMediaOverlay(true)不能同时使用。如果你调用了setZOrderOnTop,setZOrderMediaOverlay就会失效。这两个方法是互斥的。

Z-order控制进阶

如果你需要更精细的层级控制,比如3层以上的SurfaceView叠加,那就要用到SurfaceView的窗口层级了。

每个SurfaceView背后都是一个SurfaceControl,它对应一个Window。Window的Z-order由Session中的顺序决定。Android系统提供了SurfaceView.setSecure(boolean)SurfaceView.setLayerType()等方法,但这些对层级控制帮助不大。

真正能控制层级的是SurfaceView的创建顺序。后创建的SurfaceView窗口层级更高。所以如果你想让某个SurfaceView显示在最下面,就先创建它。

但实际项目中,SurfaceView往往是动态创建和销毁的。这时候怎么控制层级?

我建议的做法是:预创建所有SurfaceView,然后通过setVisibility(View.GONE/VISIBLE)来控制显示隐藏。这样它们的窗口层级在创建时就固定了,不会因为动态添加而乱掉。

// 预创建4个SurfaceView,按层级从低到高排列
SurfaceView[] surfaces = new SurfaceView[4];
for (int i = 0; i < 4; i++) {
    surfaces[i] = new SurfaceView(context);
    surfaces[i].setVisibility(View.GONE); // 先隐藏
    container.addView(surfaces[i]);
}

// 需要显示第2个画面时
surfaces[1].setVisibility(View.VISIBLE);
// 需要隐藏第0个画面时
surfaces[0].setVisibility(View.GONE);

这样做的好处是:窗口层级在addView时就确定了,后续的显示隐藏不会改变Z-order。

性能优化实战

多SurfaceView同屏渲染,性能是最大的挑战。我见过不少项目,一开4路视频就卡成PPT。这里我总结几个关键优化点。

1. 减少SurfaceView数量

每个SurfaceView都是一个独立的窗口,需要占用GPU资源。Android系统对窗口数量有限制,一般是32个左右。超过这个数,系统会报错。

所以,不要每个视频流都用一个SurfaceView。如果画面数量超过4路,我建议用TextureView或者自定义GLSurfaceView来替代部分SurfaceView。

方案 优点 缺点 适用场景
多SurfaceView 独立绘制,互不干扰 窗口数量有限,内存占用高 4路以内,需要独立Surface
TextureView + 共享EGLContext 窗口数量少,内存共享 实现复杂,需要OpenGL基础 4-16路,需要高性能
自定义GLSurfaceView 完全控制渲染管线 开发成本高 16路以上,专业场景

2. 控制SurfaceView的尺寸

SurfaceView的缓冲区大小由它的尺寸决定。如果你把SurfaceView设得很大,比如1920x1080,那每个SurfaceView都要分配这么大的缓冲区。4个就是4个1080p缓冲区,内存一下子就上去了。

我建议:SurfaceView的尺寸不要超过实际显示尺寸。比如你的网格布局中每个格子只有480x270,那SurfaceView就设成480x270。不要设成match_parent然后靠缩放来显示。

核心原则:SurfaceView的缓冲区大小 = 显示尺寸,而不是视频原始尺寸。视频解码后的数据可以通过MediaCodec的setOutputSurface()直接输出到SurfaceView,SurfaceView会自动缩放。

3. 使用SurfaceView的硬件加速

SurfaceView默认就是硬件加速的,它直接使用GPU进行渲染。但要注意一点:不要在主线程中操作SurfaceView的Canvas

如果你通过SurfaceHolder.lockCanvas()获取Canvas并绘制,这个操作会阻塞主线程,而且会触发CPU渲染,性能会大幅下降。

正确的做法是:使用MediaCodec的setOutputSurface()直接输出到SurfaceView,或者使用OpenGL ES在子线程中渲染。

// 错误做法:在主线程中lockCanvas
SurfaceHolder holder = surfaceView.getHolder();
Canvas canvas = holder.lockCanvas();
// 绘制... 这会阻塞主线程
holder.unlockCanvasAndPost(canvas);

// 正确做法:使用MediaCodec直接输出
MediaCodec codec = MediaCodec.createDecoderByType(mimeType);
codec.configure(format, surfaceView.getHolder().getSurface(), null, 0);
codec.start();
// 解码后的帧会直接渲染到SurfaceView上,无需手动绘制

4. 减少SurfaceView的创建和销毁

创建SurfaceView是一个开销很大的操作。它需要分配缓冲区、创建窗口、注册到SurfaceFlinger。频繁创建销毁会导致明显的卡顿。

我建议使用对象池来复用SurfaceView。比如一个视频通话应用,用户可能会频繁开关摄像头。这时候不要每次开关都创建新的SurfaceView,而是复用已有的。

public class SurfaceViewPool {
    private final Queue<SurfaceView> pool = new LinkedList<>();
    private final Context context;

    public SurfaceViewPool(Context context) {
        this.context = context;
    }

    public SurfaceView acquire() {
        SurfaceView sv = pool.poll();
        if (sv == null) {
            sv = new SurfaceView(context);
            // 初始化...
        }
        sv.setVisibility(View.VISIBLE);
        return sv;
    }

    public void release(SurfaceView sv) {
        sv.setVisibility(View.GONE);
        // 清空Surface上的内容
        SurfaceHolder holder = sv.getHolder();
        if (holder.getSurface().isValid()) {
            Canvas c = holder.lockCanvas();
            if (c != null) {
                c.drawColor(Color.BLACK);
                holder.unlockCanvasAndPost(c);
            }
        }
        pool.offer(sv);
    }
}

SVG:多SurfaceView层级与渲染流程

多SurfaceView同屏渲染架构 应用层(Activity/Fragment) RelativeLayout / GridLayout 容器 SurfaceView 层级(Z-order 从低到高) SurfaceView 1 Z-order: 最低 setZOrderMediaOverlay(false) SurfaceView 2 Z-order: 中 setZOrderMediaOverlay(true) SurfaceView 3 Z-order: 高 setZOrderOnTop(true) SurfaceView 4 Z-order: 最高 后创建,层级最高 层级方向 性能优化要点 预创建SurfaceView → 控制尺寸 → 复用对象池 → 避免主线程lockCanvas

避坑指南

最后,分享几个我踩过的坑:

  • SurfaceView闪烁:如果你发现SurfaceView在切换时闪烁,多半是因为Surface被销毁重建了。检查一下是不是调用了removeView或者setVisibility(GONE)后又重新addView。解决方案是用setVisibility(INVISIBLE)代替GONE,或者用SurfaceHolder.addCallback监听Surface变化。
  • 黑屏问题:有时候SurfaceView显示黑屏,但日志没有报错。这通常是因为Surface没有准备好就开始了渲染。一定要在SurfaceHolder.Callback.surfaceCreated()回调之后,再开始向Surface输出数据。
  • 内存泄漏:多SurfaceView场景下,每个SurfaceView都持有Surface引用。如果忘记在Activity销毁时释放,会导致内存泄漏。我习惯在onDestroy()中遍历所有SurfaceView,调用holder.getSurface().release()来释放。
我的经验:如果你需要同时渲染6路以上的视频,建议放弃纯SurfaceView方案,改用TextureView + 共享EGLContext。虽然实现复杂一些,但性能和稳定性会好很多。我上一个项目就是16路视频墙,用TextureView方案跑得很稳。

多SurfaceView同屏渲染,说白了就是布局、层级、性能三个维度的平衡。布局要灵活,层级要可控,性能要够用。没有银弹,只有根据场景选择最合适的方案。

嗯,今天就聊到这里。如果你在实际项目中遇到什么奇怪的问题,欢迎来公众号找我聊聊。

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