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把数据分发到三个不同的目标:SurfaceView用于预览,MediaCodec用于编码,ImageReader用于获取YUV原始数据。这三个目标可以同时存在,互不干扰。我在做直播推流时,就是同时把数据送到预览Surface和编码Surface,这样用户能看到画面,后台也在默默编码推流。
好了,这一节的内容就到这里。Camera2虽然复杂,但掌握了它的管道模型,你会发现它其实很灵活。下一节我们会继续深入,聊聊如何优化Camera2的性能,以及如何处理各种边缘情况。
公众号:蓝海资料掘金营,微信deep3321