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等属性来精确控制位置。
SurfaceView层级管理
层级管理,这是多SurfaceView方案里最让人头疼的部分。为什么?因为SurfaceView的Z-order机制和普通View不一样。
普通View的层级由它们在布局文件中的顺序决定,后面的盖在前面。但SurfaceView不一样——它有自己的窗口(Window),这个窗口的Z-order是独立于View树的。
我曾经遇到过一个场景:一个视频通话界面,本地预览画面要放在右下角小窗,远程画面占满屏幕。按理说,小窗应该盖在大画面上。但实际效果是,大画面总是盖住小窗,怎么调布局顺序都没用。
原因就是SurfaceView的Z-order默认是按创建顺序决定的,后创建的SurfaceView窗口层级更高。跟布局文件里的顺序没关系。
解决方案有两个:
- setZOrderOnTop(true):让某个SurfaceView始终显示在最顶层。但要注意,这个方法会让SurfaceView盖住所有其他View,包括按钮、文字等。
- 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); // 显示在媒体层之上
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然后靠缩放来显示。
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闪烁:如果你发现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()来释放。
多SurfaceView同屏渲染,说白了就是布局、层级、性能三个维度的平衡。布局要灵活,层级要可控,性能要够用。没有银弹,只有根据场景选择最合适的方案。
嗯,今天就聊到这里。如果你在实际项目中遇到什么奇怪的问题,欢迎来公众号找我聊聊。