7、本地预览:SurfaceViewRenderer使用、本地视频流绑定与预览、切换前后摄像头

好,我们正式开始写代码了。

这一章,我们要做的是——把摄像头画面显示到手机屏幕上。听起来简单,对吧?但这里面有几个坑,我当年第一次做的时候全踩了一遍。今天咱们一个一个说清楚。

7.1 为什么用 SurfaceViewRenderer?

Android 上显示视频,常见的有 SurfaceView、TextureView、还有我们 WebRTC 专用的 SurfaceViewRenderer。

你可能会问:为什么不用普通的 SurfaceView?

嗯,这里有个关键点。WebRTC 的视频帧是 YUV 格式的,而且是从 native 层直接送过来的。如果你用普通的 View,你得先把 YUV 转成 Bitmap,再渲染到 Canvas 上。这一来一回,性能损耗非常大。我早期在项目里试过,720p 的视频就卡得不行。

SurfaceViewRenderer 是 WebRTC 官方封装好的组件。它内部直接对接了 native 层的渲染管线,说白了就是——数据从摄像头出来,经过编码、传输、解码,最后直接画到 Surface 上,中间没有多余的拷贝。效率极高。

核心结论: 在 WebRTC 项目中,显示视频流请始终使用 SurfaceViewRenderer,不要自己造轮子。

7.2 布局中添加 SurfaceViewRenderer

首先,在布局文件里加上它。我个人习惯把它放在一个相对布局中,方便后续添加控制按钮。

<!-- activity_main.xml -->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <org.webrtc.SurfaceViewRenderer
        android:id="@+id/local_surface_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</RelativeLayout>

注意,这里用的是全限定名 org.webrtc.SurfaceViewRenderer,因为它是 WebRTC 库里的类,不是 Android SDK 自带的。

7.3 初始化 SurfaceViewRenderer

在 Activity 或 Fragment 的 onCreate 中,我们需要做几件事:

  1. 创建 PeerConnectionFactory(后面会细讲)
  2. 初始化 SurfaceViewRenderer
  3. 设置渲染器的缩放类型

代码大概长这样:

// 在 onCreate 中
SurfaceViewRenderer localSurfaceView = findViewById(R.id.local_surface_view);

// 初始化 EGL 上下文
EglBase eglBase = EglBase.create();

// 初始化渲染器
localSurfaceView.init(eglBase.getEglBaseContext(), null);

// 设置缩放模式:保持比例,裁剪多余部分
localSurfaceView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);

// 设置是否镜像(前置摄像头需要)
localSurfaceView.setMirror(true);

这里我解释一下几个关键点:

  • EglBase:WebRTC 的 EGL 上下文管理类。所有渲染器共享同一个 EGL 上下文,这样纹理才能在不同组件间传递。
  • ScalingTypeSCALE_ASPECT_FILL 会裁剪边缘以填满整个 View,适合全屏预览;SCALE_ASPECT_FIT 会保持完整画面,但可能有黑边。
  • setMirror(true):前置摄像头默认是镜像的,如果不设置,你会看到自己的脸是反的。嗯,这个我当年就忘了,调试时总觉得哪里不对。
我的习惯: 我会把 EglBase 做成单例,在整个 App 生命周期内复用。因为创建 EGL 上下文开销不小,频繁创建销毁会影响性能。

7.4 获取本地视频流并绑定

渲染器准备好了,接下来要获取摄像头数据。WebRTC 里通过 VideoCapturer 来采集视频,然后通过 VideoSource 把数据传给渲染器。

流程是这样的:

// 1. 创建 VideoCapturer(这里用 Camera2 采集)
VideoCapturer videoCapturer = createCameraCapturer(Camera2Enumerator.getInstance(context));

// 2. 通过 PeerConnectionFactory 创建 VideoSource
VideoSource videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast());

// 3. 创建 VideoTrack
VideoTrack localVideoTrack = peerConnectionFactory.createVideoTrack("local_track", videoSource);

// 4. 把 VideoTrack 绑定到 SurfaceViewRenderer
localVideoTrack.addSink(localSurfaceView);

// 5. 开始采集
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
videoCapturer.startCapture(1280, 720, 30);

这里有个容易混淆的地方:addSinksetSink 的区别。我刚开始也搞混过。

  • addSink:可以添加多个渲染器。比如本地预览一个,远端也看一个。
  • setSink:只能设置一个,新的会覆盖旧的。

在本地预览场景下,用 addSink 更灵活。万一你后面想同时显示到两个 View 上呢?

7.5 切换前后摄像头

这个功能几乎是视频会议 App 的标配。WebRTC 的 CameraVideoCapturer 接口提供了 switchCamera 方法。

实现起来很简单:

// 假设 videoCapturer 是 CameraVideoCapturer 类型
if (videoCapturer instanceof CameraVideoCapturer) {
    CameraVideoCapturer cameraCapturer = (CameraVideoCapturer) videoCapturer;
    cameraCapturer.switchCamera(new CameraVideoCapturer.CameraSwitchHandler() {
        @Override
        public void onCameraSwitchDone(boolean isFrontCamera) {
            // 切换成功,更新 UI 状态
            localSurfaceView.setMirror(isFrontCamera);
        }

        @Override
        public void onCameraSwitchError(String errorDescription) {
            // 切换失败,比如另一个摄像头被占用
            Log.e(TAG, "切换摄像头失败: " + errorDescription);
        }
    });
}

注意几个细节:

  • 切换后要重新设置 setMirror,因为后置摄像头不需要镜像。
  • 切换过程中画面会短暂黑屏,这是正常的。如果你觉得体验不好,可以在切换前冻结最后一帧。
  • 有些设备只有一个摄像头,调用 switchCamera 会直接回调错误。
我曾经踩过的坑: 在切换摄像头时,如果同时在做编码或传输,可能会导致崩溃。我的建议是:切换前暂停视频流,切换完成后再恢复。具体做法是调用 videoSource.adaptOutputFormat(...) 来调整输出格式。

7.6 完整流程的 SVG 示意图

下面这张图展示了从摄像头采集到画面显示在屏幕上的完整链路:

本地视频预览流程 摄像头 Camera2 采集 YUV 帧 VideoCapturer 采集器 帧数据 VideoSource 视频源 轨道 VideoTrack 视频轨道 addSink SurfaceViewRenderer 渲染器 屏幕显示 switchCamera() 切换前后摄

7.7 释放资源

最后,别忘了在 Activity 销毁时释放资源。WebRTC 的组件如果不手动释放,会导致内存泄漏。

@Override
protected void onDestroy() {
    super.onDestroy();

    // 停止采集
    if (videoCapturer != null) {
        try {
            videoCapturer.stopCapture();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 释放渲染器
    if (localSurfaceView != null) {
        localSurfaceView.release();
    }

    // 释放 EGL 上下文(如果是单例,注意不要重复释放)
    // eglBase.release();
}

这里有个细节:stopCapture 可能会抛出 InterruptedException,需要捕获处理。另外,释放顺序也很重要——先停止采集,再释放渲染器,最后释放 EGL 上下文。顺序反了可能会崩溃。

我的建议: 把资源释放的逻辑封装到一个工具类中,比如 WebRTCUtils.release(),这样不容易遗漏。我在项目中就吃过这个亏,漏掉了某个渲染器的 release,结果内存占用一直降不下来。

7.8 本章小结

好了,这一章的内容就这些。我们做了三件事:

  • 在布局中添加 SurfaceViewRenderer 并初始化
  • 创建 VideoCapturer 和 VideoTrack,绑定到渲染器
  • 实现前后摄像头切换

这些是 WebRTC 视频通话的基础。下一章我们会在此基础上,加入远端视频流的显示。到时候你会发现,本地预览和远端预览其实用的是同一套机制——都是 VideoTrack + SurfaceViewRenderer 的组合。

嗯,今天就到这里。代码写完了记得跑一下,看看你的脸是不是镜像的——如果是,说明你忘了调 setMirror(true)


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