9、Video 录像流程:MediaRecorder集成、VideoCaptureSession配置、Audio/Video同步、录制状态机与错误处理

录像这个功能,说白了就是把摄像头采集到的画面和麦克风采集到的声音,打包成一个视频文件存下来。听起来简单,但里面坑不少。我最早做录像功能时,以为就是调个MediaRecorder.start()就完事了,结果各种音画不同步、文件损坏、录制中途崩溃的问题,搞得我焦头烂额。

今天我们就来拆解一下Android录像的完整流程。我会从MediaRecorder的集成讲起,再到Camera2的VideoCaptureSession配置,最后聊聊音视频同步和状态机管理。嗯,这些都是实战中绕不开的点。

9.1 MediaRecorder集成:从入门到实战

MediaRecorder是Android官方提供的录制工具。它封装了底层的音视频编码、复用、文件写入等逻辑。你想想看,如果没有它,你得自己处理MediaCodec、MediaMuxer,那代码量就大了去了。

我个人习惯用MediaRecorder配合Camera2来做录像。为什么不用Camera1?因为Camera1在API 21之后就被标记为deprecated了,而且它在多摄像头、高帧率场景下支持不好。

先看一个标准的MediaRecorder初始化流程:

MediaRecorder mediaRecorder = new MediaRecorder();

// 设置音频源 - 麦克风
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
// 设置视频源 - 摄像头
mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);

// 设置输出格式 - MP4
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);

// 设置输出文件路径
mediaRecorder.setOutputFile(getOutputFilePath());

// 设置视频编码器 - H.264
mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);
// 设置音频编码器 - AAC
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);

// 设置视频参数
mediaRecorder.setVideoSize(1920, 1080);
mediaRecorder.setVideoFrameRate(30);
mediaRecorder.setVideoEncodingBitRate(10_000_000); // 10Mbps

// 设置音频参数
mediaRecorder.setAudioSamplingRate(44100);
mediaRecorder.setAudioEncodingBitRate(128_000);

// 准备
mediaRecorder.prepare();
注意:MediaRecorder的配置顺序是有讲究的。必须先设置Source,再设置OutputFormat,最后设置Encoder。顺序错了会抛IllegalStateException。我曾经在这个问题上debug了一下午,后来才发现是setVideoSource和setOutputFormat的顺序搞反了。

这里有个关键点:MediaRecorder需要从Camera2获取一个Surface作为视频输入源。这个Surface就是Camera2的CaptureSession的输出目标之一。

9.2 VideoCaptureSession配置:Surface的传递

录像时,Camera2的CaptureSession需要同时输出预览画面和录制画面。预览走的是TextureView的Surface,录制走的是MediaRecorder的Surface。

配置流程大概是这样的:

  1. 创建MediaRecorder并获取它的Surface
  2. 创建Camera2的CaptureSession,把预览Surface和录制Surface都加进去
  3. 启动预览的重复请求
  4. 开始录制时,把录制Surface对应的请求也加进去

看代码:

// 获取MediaRecorder的Surface
Surface recorderSurface = mediaRecorder.getSurface();

// 创建输出配置列表
List<Surface> outputSurfaces = new ArrayList<>();
outputSurfaces.add(previewSurface);  // 预览Surface
outputSurfaces.add(recorderSurface); // 录制Surface

// 创建CaptureSession
cameraDevice.createCaptureSession(outputSurfaces, 
    new CameraCaptureSession.StateCallback() {
        @Override
        public void onConfigured(@NonNull CameraCaptureSession session) {
            // 配置成功
            captureSession = session;
            startPreview(); // 启动预览
        }

        @Override
        public void onConfigureFailed(@NonNull CameraCaptureSession session) {
            // 配置失败 - 这里要处理
            Log.e(TAG, "CaptureSession配置失败");
        }
    }, null);

启动录制时,我们需要创建一个新的CaptureRequest,把录制Surface作为目标:

// 创建录制用的CaptureRequest
CaptureRequest.Builder recordBuilder = 
    cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
recordBuilder.addTarget(previewSurface);
recordBuilder.addTarget(recorderSurface);

// 设置自动对焦、自动白平衡等
recordBuilder.set(CaptureRequest.CONTROL_AF_MODE, 
    CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO);
recordBuilder.set(CaptureRequest.CONTROL_AE_MODE, 
    CaptureRequest.CONTROL_AE_MODE_ON);

// 开始录制
captureSession.setRepeatingRequest(recordBuilder.build(), 
    null, null);
mediaRecorder.start();
小技巧:我建议在开始录制前,先调用mediaRecorder.prepare(),然后再创建CaptureSession。这样可以避免Surface还没准备好就启动录制的问题。另外,录制过程中不要频繁切换分辨率,否则MediaRecorder会重新配置,容易丢帧。

9.3 Audio/Video同步:时间戳的艺术

音视频同步,这是录像里最头疼的问题之一。你想想看,如果画面和声音对不上,用户看视频时会非常难受。

MediaRecorder内部其实已经做了基本的音视频同步。它会把音频和视频的时间戳对齐到同一个时间基线上。但为什么有时候还是会出现不同步呢?

原因主要有两个:

  • 音频和视频的时钟源不同:音频用AudioRecord的时钟,视频用Camera2的帧时间戳。这两个时钟可能有偏差。
  • 处理延迟不一致:音频处理快,视频处理慢,导致累积偏差。

MediaRecorder的解决方案是:在封装成MP4时,它会根据每个音视频帧的PTS(Presentation Time Stamp)来重新排序和同步。只要你的时间戳是单调递增的,它就能处理好。

但如果你用MediaCodec + MediaMuxer自己实现录制,那就得手动处理同步了。我做过一个项目,需要录制高帧率视频,MediaRecorder满足不了需求,只能自己撸码。那时候我用了下面的策略:

// 视频帧回调
MediaCodec.BufferInfo videoInfo = new MediaCodec.BufferInfo();
// 音频帧回调
MediaCodec.BufferInfo audioInfo = new MediaCodec.BufferInfo();

// 同步策略:以视频时间戳为基准
long videoPts = videoInfo.presentationTimeUs;
long audioPts = audioInfo.presentationTimeUs;

// 如果音频落后太多,丢弃音频帧
if (audioPts < videoPts - 100_000) { // 落后超过100ms
    // 丢弃这帧音频
    continue;
}

// 如果音频超前太多,等待
if (audioPts > videoPts + 100_000) { // 超前超过100ms
    // 先写入视频帧
    muxer.writeSampleData(videoTrack, byteBuf, videoInfo);
}
核心要点:音视频同步的本质是时间戳对齐。MediaRecorder帮你做了这件事,但如果你自己实现,一定要保证时间戳的单调递增和时钟源统一。我建议统一使用System.nanoTime()作为时间基准,不要混用不同时钟。

9.4 录制状态机与错误处理

MediaRecorder有一个明确的状态机。不理解这个状态机,你很容易写出崩溃的代码。

状态转换如下:

Initial -> Idle -> Initialized -> DataSourceConfigured -> Prepared -> Recording -> Released

每个状态能调用的方法不同。比如:

  • Initialized状态,可以调用setAudioSource/setVideoSource
  • DataSourceConfigured状态,可以调用setOutputFormat/setEncoder等
  • Prepared状态,可以调用start()
  • Recording状态,可以调用stop()或pause()

如果状态不对就调用方法,会抛IllegalStateException。我曾经在录制过程中不小心调了两次start(),结果直接crash了。

所以,我建议用一个状态变量来跟踪MediaRecorder的状态:

private int recorderState = MediaRecorderState.IDLE;

public void startRecording() {
    if (recorderState != MediaRecorderState.PREPARED) {
        Log.e(TAG, "状态不对,无法开始录制");
        return;
    }
    mediaRecorder.start();
    recorderState = MediaRecorderState.RECORDING;
}

public void stopRecording() {
    if (recorderState != MediaRecorderState.RECORDING) {
        Log.e(TAG, "不在录制状态,无法停止");
        return;
    }
    try {
        mediaRecorder.stop();
        recorderState = MediaRecorderState.INITIALIZED;
    } catch (RuntimeException e) {
        // stop()可能抛异常,比如文件写入失败
        Log.e(TAG, "停止录制失败", e);
        // 重置MediaRecorder
        mediaRecorder.reset();
        recorderState = MediaRecorderState.IDLE;
    }
}

错误处理方面,MediaRecorder提供了OnErrorListener和OnInfoListener:

mediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() {
    @Override
    public void onError(MediaRecorder mr, int what, int extra) {
        // 录制出错,比如磁盘空间不足、编码器崩溃等
        Log.e(TAG, "录制错误: what=" + what + ", extra=" + extra);
        // 停止录制并释放资源
        releaseRecorder();
        // 通知UI层
        notifyRecordingError(what);
    }
});

mediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() {
    @Override
    public void onInfo(MediaRecorder mr, int what, int extra) {
        switch (what) {
            case MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED:
                // 达到最大录制时长
                stopRecording();
                break;
            case MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED:
                // 达到最大文件大小
                stopRecording();
                break;
            case MediaRecorder.MEDIA_RECORDER_INFO_NEXT_OUTPUT_FILE_STARTED:
                // 开始写入下一个文件(分段录制时)
                break;
        }
    }
});
避坑指南:我曾经在录制过程中遇到磁盘空间不足,MediaRecorder直接抛了RuntimeException。后来我加了一个前置检查:在开始录制前,先检查剩余空间是否足够。建议至少保留100MB的余量。另外,stop()方法也可能抛异常,一定要用try-catch包起来。

9.5 整体流程图

下面我用一张SVG图来总结录像的整体流程。从MediaRecorder初始化,到CaptureSession配置,再到录制开始和结束,每一步都有对应的状态和错误处理。

录像流程状态机与核心步骤 1. MediaRecorder初始化 2. 配置音视频参数 3. 获取录制Surface 4. 创建CaptureSession 5. 开始录制 6. 录制中(状态机) 7. 停止录制 8. 释放资源 错误处理:OnErrorListener / OnInfoListener 配置阶段 录制阶段 释放阶段 错误处理

这张图把整个录像流程串起来了。从MediaRecorder初始化开始,到配置参数、获取Surface、创建CaptureSession,然后开始录制,最后停止并释放资源。中间任何一个环节出错,都会触发错误处理回调。

嗯,录像这块内容确实不少。但只要你理解了状态机,掌握了Surface的传递方式,再注意一下音视频同步的细节,基本上就能搞定大部分场景了。我在项目中遇到过最奇葩的问题是在某些低端机上,MediaRecorder的prepare()方法会卡住,后来发现是编码器初始化太慢,加了一个超时机制才解决。

好了,这一章就到这里。记住,录像不是简单的start/stop,背后是一整套状态管理和资源协调的工程。


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