19、视频采集进阶:Camera2与SurfaceView/TextureView、Camera2与MediaCodec结合、YUV数据处理

各位同学,欢迎来到第十九节。前面我们聊了Camera1的采集,说实话,那套API虽然简单,但坑也不少。今天咱们直接上硬菜——Camera2。这套API从Android 5.0开始引入,设计上更灵活,但复杂度也上了一个台阶。我刚开始接触Camera2的时候,也被它那个回调套回调的模型搞得有点晕。不过别怕,今天咱们把它拆开揉碎了讲。

19.1 Camera2 的核心设计理念

Camera2说白了,是一个管道模型。你想想看,它把整个拍照或录像过程,抽象成了一条流水线。这条流水线上有几个关键角色:

  • CameraManager:系统相机服务的管家,负责打开和关闭摄像头。
  • CameraDevice:代表一个物理摄像头,比如后置或前置。
  • CameraCaptureSession:一次拍照或录像的会话,负责管理请求的发送和结果的回调。
  • CaptureRequest:每一次捕获的请求,里面包含了各种参数,比如对焦、曝光、输出目标等。

我个人习惯把Camera2理解成一个「请求-响应」模型。你向CameraDevice提交一个CaptureRequest,它处理完后,通过CameraCaptureSession.CaptureCallback把结果返回给你。嗯,这里要注意,这个回调是在Camera2的内部线程里执行的,千万别在里面做耗时操作。

核心要点:Camera2不再像Camera1那样直接操作预览画面,而是通过Surface来传递数据。你要把Surface绑定到CaptureRequest上,Camera2才会把数据输出到这个Surface上。

19.2 SurfaceView 与 TextureView 的选择

说到Surface,就不得不提两个常用的View:SurfaceView和TextureView。很多新手会纠结到底用哪个。我直接说结论:

对比项 SurfaceView TextureView
渲染方式 独立窗口,有独立的Surface 在View层级中,与普通View共享Surface
性能 更高,支持硬件加速 稍低,需要额外的纹理拷贝
动画/变换 不支持,只能通过窗口位置移动 支持,可以像普通View一样做缩放、旋转
生命周期 需要手动管理Surface的创建和销毁 跟随View的生命周期
适用场景 纯视频预览、播放 需要叠加UI、做特效的场景

我在项目中遇到过一个问题:用TextureView做预览,然后叠加一个自定义的滤镜View。结果发现帧率从30fps掉到了20fps。为什么?因为TextureView每次渲染都要把数据从GPU拷贝到CPU再拷贝回去,这个开销不小。所以,如果你的场景只是简单的预览+编码,我建议直接用SurfaceView,省心又高效。

19.3 Camera2 与 SurfaceView 结合

咱们直接上代码。先看怎么把Camera2和SurfaceView绑在一起。

// 1. 获取CameraManager
CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);

// 2. 打开摄像头
manager.openCamera(cameraId, new CameraDevice.StateCallback() {
    @Override
    public void onOpened(@NonNull CameraDevice camera) {
        mCameraDevice = camera;
        // 3. 创建CaptureSession
        Surface surface = mSurfaceView.getHolder().getSurface();
        try {
            mCameraDevice.createCaptureSession(
                Arrays.asList(surface),
                new CameraCaptureSession.StateCallback() {
                    @Override
                    public void onConfigured(@NonNull CameraCaptureSession session) {
                        mCaptureSession = session;
                        // 4. 创建预览请求
                        CaptureRequest.Builder builder = 
                            mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
                        builder.addTarget(surface);
                        // 5. 重复发送请求,开始预览
                        mCaptureSession.setRepeatingRequest(builder.build(), null, null);
                    }
                    // ... 其他回调
                }, null);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }
    // ... onDisconnected, onError
}, null);

这段代码的逻辑很清晰:打开摄像头 -> 拿到Surface -> 创建Session -> 提交预览请求。注意,setRepeatingRequest是持续预览的关键,它会不断地向摄像头请求新的帧数据。

小技巧:SurfaceView的Surface在surfaceCreated回调之后才可用。我建议在surfaceCreated里再打开摄像头,避免出现Surface还没准备好就提交请求的情况。

19.4 Camera2 与 TextureView 结合

TextureView的使用稍微有点不同。它需要你提供一个SurfaceTexture,然后通过这个SurfaceTexture来获取Surface。

// TextureView的初始化
mTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
        // 在这里打开摄像头
        Surface previewSurface = new Surface(surface);
        // 后续步骤与SurfaceView类似,用previewSurface创建Session
    }
    // ... 其他回调
});

这里有个坑:SurfaceTexture的尺寸最好和预览分辨率匹配,否则画面会被拉伸。我曾经遇到过一个问题,预览画面被压扁了,查了半天才发现是SurfaceTexture的宽高比和摄像头输出的宽高比不一致。解决办法是在onSurfaceTextureAvailable里设置一下surface.setDefaultBufferSize(width, height)

19.5 Camera2 与 MediaCodec 结合

好了,预览搞定了,接下来咱们要编码了。Camera2和MediaCodec的结合,核心思路是把MediaCodec的输入Surface直接作为Camera2的输出目标。这样数据从摄像头到编码器,全程在GPU层面流转,不需要经过CPU,效率极高。

// 1. 配置MediaCodec
MediaCodec codec = MediaCodec.createEncoderByType(MediaFormat.MIME_TYPE_AVC);
MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIME_TYPE_AVC, width, height);
format.setInteger(MediaFormat.KEY_BIT_RATE, 2000000);
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
Surface inputSurface = codec.createInputSurface();
codec.start();

// 2. 将编码器的输入Surface添加到Camera2的请求中
CaptureRequest.Builder builder = 
    mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
builder.addTarget(previewSurface);  // 预览用的Surface
builder.addTarget(inputSurface);    // 编码用的Surface
mCaptureSession.setRepeatingRequest(builder.build(), null, null);

注意,这里我用了TEMPLATE_RECORD而不是TEMPLATE_PREVIEW。为什么?因为录制模板会优化帧率和画质,更适合编码场景。预览模板则更注重实时性。嗯,这个细节很多人会忽略。

警告:MediaCodec的输入Surface一旦创建,就不能再改变尺寸。如果你中途要切换分辨率,必须停止编码器,重新配置。我早期做项目时没注意这个,结果切换分辨率后编码器直接崩溃了。

19.6 YUV数据处理

最后,咱们聊聊YUV数据。Camera2默认输出的格式是YUV_420_888,这是一种灵活的YUV格式。为什么说它灵活?因为它允许数据以多个平面(Plane)的形式存储,而不是像NV21那样固定排列。

我在项目中经常需要把YUV数据转成Bitmap,或者做图像处理。这里给一个通用的YUV转RGB的代码片段:

// 假设从ImageReader获取到了Image对象
Image image = ...;
ByteBuffer yBuffer = image.getPlanes()[0].getBuffer();
ByteBuffer uBuffer = image.getPlanes()[1].getBuffer();
ByteBuffer vBuffer = image.getPlanes()[2].getBuffer();

int ySize = yBuffer.remaining();
int uSize = uBuffer.remaining();
int vSize = vBuffer.remaining();

// 注意:YUV_420_888的U和V平面可能不是连续的
// 需要根据像素步长(pixelStride)来正确读取
byte[] nv21 = new byte[ySize + uSize + vSize];
yBuffer.get(nv21, 0, ySize);

// 将UV数据交错排列成NV21格式
int uvIndex = ySize;
for (int i = 0; i < uSize; i++) {
    nv21[uvIndex++] = vBuffer.get(i);  // V在前
    nv21[uvIndex++] = uBuffer.get(i);  // U在后
}

// 现在nv21就是标准的NV21数据,可以用于编码或渲染

这里有个容易踩的坑:YUV_420_888的U和V平面的pixelStride可能为2,意味着每个像素的U/V值之间可能有空洞。如果你直接按顺序读取,数据就全乱了。我建议在处理前,先打印一下各个平面的信息:

for (int i = 0; i < image.getPlanes().length; i++) {
    Image.Plane plane = image.getPlanes()[i];
    Log.d("YUV", "Plane " + i + ": pixelStride=" + plane.getPixelStride() 
          + ", rowStride=" + plane.getRowStride() 
          + ", bufferSize=" + plane.getBuffer().remaining());
}

这样你就能清楚地知道数据是怎么排列的了。嗯,调试YUV数据时,这个日志能救你一命。

19.7 本章知识体系

为了让大家更直观地理解Camera2的工作流程,我画了一张图:

Camera2 视频采集与编码流程 CameraManager CameraDevice CaptureRequest CameraCaptureSession SurfaceView / TextureView MediaCodec (编码器) ImageReader (YUV数据) 最终输出:预览画面 / 编码流 / YUV处理结果 图例:蓝色-管理类 红色-设备类 紫色-请求类

从这张图可以清楚地看到,Camera2把数据分发到三个不同的目标:SurfaceView用于预览,MediaCodec用于编码,ImageReader用于获取YUV原始数据。这三个目标可以同时存在,互不干扰。我在做直播推流时,就是同时把数据送到预览Surface和编码Surface,这样用户能看到画面,后台也在默默编码推流。

好了,这一节的内容就到这里。Camera2虽然复杂,但掌握了它的管道模型,你会发现它其实很灵活。下一节我们会继续深入,聊聊如何优化Camera2的性能,以及如何处理各种边缘情况。


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