ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android相机视频录制全解析:Camera2、MediaCodec与音画同步实战

Android相机视频录制全解析:Camera2、MediaCodec与音画同步实战

做Android相机开发这两年,我有一半的时间都耗在视频录制这条链路上。预览和拍照相对单纯,拿到一帧帧数据做处理就行,但一旦涉及视频录制,面对的就不只是相机的Surface回调,而是相机管线、编码器、muxer、文件管理、音画同步以及设备温控之间的综合博弈。这篇文章我想把“Android camera 篇之视频录制”这条主线的完整逻辑串一遍,从框架选型、参数计算、代码实现到崩溃排查,给大家一份可以直接抄作业的实操总结。

视频录制之所以让很多人头疼,是因为它在Android系统里横跨了Camera、Audio、MediaCodec、MediaMuxer四个大类,任何一个环节掉链子,最终表现就是预览正常但录制文件打不开、画面黑屏、声音对不上。我们团队在某个项目里做扫码和录像二合一的定制ROM适配,踩遍了这块的坑,后来把流程彻底梳理清楚之后,才发现很多问题其实都是同一类原因:数据通道没接对、时序没等对、参数没算对。下面我按实际开发的顺序,把这套东西详细掰开讲。

1. 视频录制的整体框架与选型思路

1.1 从相机采集到视频文件的完整链路

视频录制本质上是一条数据流水线,和工厂流水线很像。最上游是镜头和图像传感器,传感器输出原始数据后,ISP(图像信号处理器)会把RAW数据转成YUV或直出JPEG。对于视频录制来说,我们会拿到YUV帧序列,这就好比工厂里的“半成品原料”。接下来,原料需要交给编码器做压缩,H.264/H.265编码器会吃掉一帧帧的YUV数据,吐出一段段已经压缩的码流,这是流水线的“加工包装环节”。最后封装成MP4容器,也就是把视频编码流和音频编码流按一定时间戳规则交织在一起,写成带索引的文件。

很多新手会把“编码”和“封装”混淆,实际它们是两个独立环节。编码解决“怎么压缩”,封装解决“怎么存储”。Android上MediaCodec负责编码,MediaMuxer负责封装,两者通过时间戳靠齐。我之前遇到一个文件播放卡顿的bug,就是封装时只写了视频轨忘了写音频轨,导致播放器总在等待音频数据,整个文件时长显示正常但进度条拖动异常。

从Camera侧理解,预览和录制的关键区别在于:预览只需要把图像数据实时显示到SurfaceView/TextureView上,不需要关心后续是否消费得完;而录制必须保证编码器有足够的数据喂入,同时还要考虑背压问题。如果编码器处理不过来,图像数据就会被丢弃,表现出来就是视频掉帧、卡顿、甚至编码器崩溃。所以录制与预览消耗的是同一份Surface数据源时,一定要特别留意Surface的缓冲池是否被撑爆。

1.2 Camera2与CameraX怎么选

这个选型问题几乎每个相机项目都要面对。如果是新项目、目标机型范围广、不太在意底层极致控制力,直接选CameraX。CameraX把Camera2复杂的Session配置、线程调度、生命周期绑定全都封了一层,用起来非常省心。如果是做定制ROM、人脸识别门禁、工业扫码这类需要精细控制曝光/对焦/帧率映射的场景,那必须回到Camera2写底层。

我们的经验是:凡是涉及“多路流同时工作”的场景,Camera2才有存在必要。比如同时要预览流、拍照流、视频流,CameraX早期对多路流的支持比较保守,虽然现在已经好了很多,但一些特殊尺寸组合还是会限制。还有一点,Camera2可以拿到非常精细的控制参数,比如手动设置SENSOR_TIMESTAMP、控制3A算法;CameraX封得太严,调试底层问题时很不方便。

Camera2的学习曲线陡,但值得花时间过一遍核心概念:CameraDevice代表物理相机,CameraCaptureSession是数据会话,CaptureRequest描述一次采集请求,Surface是数据出口。视频录制时,我们需要创建一个类型为MediaCodec的输入Surface,把它作为CaptureRequest的输出Target,这样相机采集的数据会直接喂给编码器,不经过额外拷贝。这一块是后面代码实现的基础。

1.3 MediaRecorder与MediaCodec二选一

很多人一开始会用MediaRecorder,因为它真的简单:配置摄像头源、输出格式、编码器,然后start/stop就完事。MediaRecorder底层其实也走了Camera和MediaCodec,但从系统角度看,它需要你把相机所有权交出去,由MediaRecorder自己管理Surface和编码流程。

MediaRecorder最大的局限在于灵活性。你没法在录制的过程中对帧数据做处理,比如叠加水印、人脸框、美颜效果。一旦需求变成“相机画面经过OpenGL处理后输出视频”,MediaRecorder就完全不够用,因为它接管了Camera到编码器的整条通道,你只能在Surface上看到预览,却截获不了编码前的那一份数据。

MediaCodec方案相比之下更底层、更可控。你先创建编码器,拿到输入Surface,把它交给Camera或者GL环境渲染,编码器在内部完成YUV到H.264的转换,输出ByteBuffer流,然后写到MediaMuxer。这条路多出来的工作量主要在状态管理上:要管好编码器的生命周期、时间戳递增、EOS通知、音频与视频的同步,还要处理各种厂商设备上的兼容性差异。

打个比方,MediaRecorder是跟团游,路线固定省心省力;MediaCodec是自由行,车票酒店自己搞定,但想绕道去某个景点也不用跟任何人商量。如果你做的只是普通的录像功能,MediaRecorder完全够用;但凡涉及滤镜、游戏录屏、人脸特效、双路合成这类高级需求,MediaCodec是绕不开的。我们后文的主体实现也基于MediaCodec展开。

2. 视频录制的核心参数与计算逻辑

2.1 分辨率、帧率、码率的取舍关系

视频录制在外行看来就是把画面记录下来,但对开发者来说,分辨率、帧率、码率是三个强相关的三角平衡。分辨率决定画面细腻程度,帧率决定流畅程度,码率决定单位时间内编码出的数据量。码率给得太低,高分辨率高帧率下画质裂开;码率给得太高,文件体积膨胀,写入速度可能跟不上。

一个简单的估算方法:对H.264来说,1080p30通常建议4Mbps到8Mbps,4K30建议16Mbps到32Mbps。具体值还要看内容复杂度,一个静止的PPT录屏和一段快速运动的篮球比赛,相同码率下前者画质明显更好。如果你希望画面运动剧烈时不糊,码率必须往上提,或者换更高压缩效率的H.265。H.265在同等画质下码率大概能省30%到50%,但编码耗时更高、兼容性也相对差一些。

帧率的选择需要看目标帧率能否被摄像头真实输出。很多设备的Camera2配置里,StreamConfigurationMap会列出各分辨率的可用帧率范围,不要直接拍脑袋固定30fps。我遇到过某台设备,1080p30和720p30都有,但一旦同时启用预览流和录制流,预览流把带宽占了,录制流实际只能稳定输出15fps,最终视频看起来就一卡一卡的。这种时候要么降录制分辨率,要么牺牲预览分辨率,把总数据量控制在ISP带宽以内。

2.2 码率计算与I帧间隔的设置

码率还和文件录制时长有关。计算文件体积的公式是体积 = 码率 × 时长 / 8,比如8Mbps录制10分钟,体积就是8 × 600 / 8 = 600MB。如果产品上限制单个视频文件不超过200MB,反过来推算码率就不能超过2.7Mbps。我们之前做过一个巡检类App,要求单段录像不超过5分钟、文件体积不超过150MB,算下来码率只能在4Mbps左右,为了保住画质,最后把分辨率定在720p而不是1080p。

I帧间隔(所谓的GOP长度)也是一个关键参数。每N帧设置一个关键帧,播放器从头解码时或者拖动进度条时需要等待下一个关键帧。间隔太大,seek响应慢、断点续录时首帧要等很久;间隔太小,码率浪费严重。常规配置是I帧间隔为帧率的2到4倍,比如30fps时设置60到120,也就是每2到4秒一个关键帧。我们在弱网场景做视频上传时,会把I帧间隔缩到30,这样切流和续传时的花屏时间更短,代价是码率多消耗了大概15%。

2.3 音频录制的参数搭配

视频只有画面没有声音肯定不行。音频采集在Android上走AudioRecord,数据格式一般是PCM 16bit单声道或双声道,采样率44100Hz或48000Hz。音频编码通常用AAC,MediaCodec对AAC编码支持很完善,封装到MP4时用MediaMuxer即可。

音频和视频的时间基准不同,视频帧用Camera的timestamp(纳秒),音频帧用AudioRecord的采样率计数(帧序号 / 采样率),两者在MediaMuxer里必须换算成统一的microseconds时间轴。这里有一个容易踩的坑:不要直接用系统当前时间做视频时间戳,而应该用Camera ImageReader的timestamp属性,否则录像文件的时间轴会忽快忽慢,甚至出现音画不同步。AudioRecord初始化时也要注意读取Buffer大小,推荐用AudioRecord.getMinBufferSize()作为最小值,不然采集线程会频繁丢数据,声音断断续续。

3. 实操:基于Camera2与MediaCodec实现视频录制

3.1 初始化相机与录制会话

下面这段代码展示核心思路,完整的项目工程还包括权限申请、相机设备关闭、错误回调等繁琐部分,我就挑主干讲。

class VideoRecorder(context: Context) { private var cameraDevice: CameraDevice? = null private var captureSession: CameraCaptureSession? = null private var mediaCodec: MediaCodec? = null private var mediaMuxer: MediaMuxer? = null private var videoTrackIndex = -1 private var audioTrackIndex = -1 private var muxerStarted = false fun startRecording(filePath: String, width: Int, height: Int, fps: Int) { // 1. 打开相机 val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager val cameraId = manager.cameraIdList[0] // 传入自定义回调,内部拿到 cameraDevice 后调用 configureSession() manager.openCamera(cameraId, object : CameraDevice.StateCallback() { override fun onOpened(camera: CameraDevice) { cameraDevice = camera configureSession(filePath, width, height, fps) } override fun onDisconnected(camera: CameraDevice) { cameraDevice?.close() cameraDevice = null } override fun onError(camera: CameraDevice, error: Int) { cameraDevice?.close() cameraDevice = null } }, HandlerThread("CameraOpen").apply { start() }.looper) } }

Camera的打开是异步的,所以后面所有配置工作都要放在onOpened回调里。这地方新手容易漏了线程切换,直接在onOpened外继续执行配置,大概率会得到CameraDevice为null或Session配置失败的异常。

configureSession里要做的事情是:创建编码器,拿到输入Surface,创建相机输出的CaptureRequest,把Surface加进去,然后建立CaptureSession。

private fun configureSession(filePath: String, width: Int, height: Int, fps: Int) { val codecFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height) codecFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatSurface) codecFormat.setInteger(MediaFormat.KEY_BIT_RATE, 8_000_000) codecFormat.setInteger(MediaFormat.KEY_FRAME_RATE, fps) codecFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC).apply { configure(codecFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) start() } val videoSurface = mediaCodec!!.createInputSurface() // 这里需要把 videoSurface 作为 CaptureRequest 的 target 之一 val request = cameraDevice!!.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply { addTarget(videoSurface) set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO) set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(fps, fps)) } cameraDevice!!.createCaptureSession(listOf(videoSurface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { captureSession = session session.setRepeatingRequest(request.build(), null, null) } override fun onConfigureFailed(session: CameraCaptureSession) { // 上报失败,通常是因为分辨率不支持或Surface不合法 } }, null) }

这里有个重要细节:TEMPLATE_RECORD模板相比TEMPLATE_PREVIEW会调整3A算法参数,更偏向于稳定曝光和持续录制。不要为了省事用TEMPLATE_PREVIEW代替,特别是光线变化场景下,TEMPLATE_PREVIEW的曝光策略会导致录出来的视频忽明忽暗。

3.2 编码器输出数据的读取与封装

相机采集的YUV数据被送进编码器后,编码器会以ByteBuffer形式通过dequeueOutputBuffer输出编码好的码流。我们需要在一个独立的线程里循环拉取,并交给MediaMuxer封装。

private fun pumpEncoderData() { val bufferInfo = MediaCodec.BufferInfo() while (!isRecordingStopped) { val outIndex = mediaCodec!!.dequeueOutputBuffer(bufferInfo, 10_000) when { outIndex >= 0 -> { val encodedData = mediaCodec!!.getOutputBuffer(outIndex)!! if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { bufferInfo.size = 0 } if (bufferInfo.size > 0 && muxerStarted) { mediaMuxer!!.writeSampleData(videoTrackIndex, encodedData, bufferInfo) } mediaCodec!!.releaseOutputBuffer(outIndex, false) if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { break } } outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { val newFormat = mediaCodec!!.outputFormat videoTrackIndex = mediaMuxer!!.addTrack(newFormat) mediaMuxer!!.start() muxerStarted = true } } } }

CODEC_CONFIG这个Buffer很关键。H.264编码器最开始时输出的SPS/PPS信息存在这里,需要写进MP4文件的特定位置。很多自制录像文件用系统播放器打不开,多半是这帧数据被漏掉或写入顺序不对。我们用条件判断把它丢弃,因为MediaMuxer在addTrack时已经通过outputFormat拿到了csd-0/csd-1,不需要再重复写入。如果你是用字节流直接存储裸H.264文件,就必须把CODEC_CONFIG这个包原样写入。

MediaMuxer的start只能调用一次,而且必须在拿到INFO_OUTPUT_FORMAT_CHANGED之后。这个事件意味着编码器已经准备好输出真实码流,只有拿到新的MediaFormat才能正确添加视频轨道。顺序反过来写会出现“Invalid muxer state”异常。

3.3 音频采集与同步编码

音频通常可以用AudioRecord独立实现,也可以借助MediaCodec对PCM做AAC编码。为了不让博文篇幅无限膨胀,我描述思路:创建一个AudioRecord对象,缓冲区大小设为getMinBufferSize()的两倍,循环调用read读PCM数据,塞进一个为音频单独创建的MediaCodec编码器,编码器输出再写入MediaMuxer的音频轨道。

音视频时间戳的对齐是最核心的事。视频编码器输入的是Camera默认的surface时间戳,单位纳秒,通常从1微秒开始累计;音频编码器输入的时间戳必须是基于同一个起始点的纳秒值,否则muxer会把两条轨道的时间基准错开,播放时声音和画面越差越多。一个稳妥做法是:在startRecording时记一个startRealTimeNanos = System.nanoTime(),视频时间戳直接用bufferInfo.presentationTimeUs,音频则用当前帧的相对时间,也就是(System.nanoTime() - startRealTimeNanos) / 1000,再转成微秒。

AudioRecord成功创建后,read可能会返回负数,常见错误有ERROR_DEAD_OBJECT、ERROR_BAD_VALUE。一旦遇到这两个错误,通常要重新初始化AudioRecord,特别是在应用退后台再回前台、耳机插拔之后,如果不处理就会一直读到错误值,录音静音。

3.4 停止录制与文件收尾

录制结束时必须严格按照“停止相机采集 → 通知编码器EOS → 等待编码器输出所有剩余数据 → 停止并释放MediaMuxer”的顺序执行。很多人直接stop摄像头然后马上close muxer,会导致文件末尾数据缺失,时长缩短。

通知编码器结束的标准动作是向编码器输入Surface提交最后一张Buffer,但因为我们用的是createInputSurface,无法直接拿到输入Buffer,所以通常会:

private fun stopRecording() { // 先停止相机重复请求,避免继续向surface喂帧 captureSession?.stopRepeating() // 再通知编码器结束,对createInputSurface的方式,调用signalEndOfInputStream mediaCodec?.signalEndOfInputStream() // 然后循环dequeueOutputBuffer,直到拿到BUFFER_FLAG_END_OF_STREAM drainEncoderUntilEnd() // 最后stop和release mediaCodec?.stop() mediaCodec?.release() mediaMuxer?.stop() mediaMuxer?.release() cameraDevice?.close() }

signalEndOfInputStream和向输入Surface送入EOS是等价的,但只能用于编码器创建的输入Surface。如果你用的是ByteBuffer模式喂数据,应该调用queueInputBuffer并设置BUFFER_FLAG_END_OF_STREAM。两种方式不要混淆,否则会出现“Failed to end stream”之类的错误。

drainEncoderUntilEnd这个循环和上面pumpEncoderData的结构一样,差别在于要一直循环到收到EOS标志才能退出,因为编码器内部还缓存着几帧画面,信号的结束需要它把所有缓存帧编码完才会吐出来。

4. 录制的常见问题与排查实录

4.1 录制文件黑屏或无法播放

这类问题第一排查点永远是输出的二进制数据是否完整正确。常见流程是:录制完一个短文件,用十六进制工具检查文件头是不是ftyp,再看看moov box是否在文件尾部(MP4的moov通常在文件末尾)。如果moov缺失或损坏,播放器无法解析时长和轨道信息,表现就是黑屏或“无法播放此文件”。

moov缺失还有一个常见场景:录制中间App被杀或者闪退,MediaMuxer没有正常stop,文件没有收尾。对策是上线产品时做“损坏文件自愈”处理,要么加一个录制的状态标记,下次启动时扫描未完成文件,要么在服务端对上传的MP4做解析校验,解析失败的提示用户重新录制。我们生产环境里确实遇到过用户录了十几秒文件上传后无法播放的情况,原因就是录制结束前App被系统杀掉了。

另一个黑屏原因是视频轨道写入成功但音频轨道数据为空。虽然播放器一般能播,但某些严格播放器会直接判定为损坏。录制时先判断音频是否真的读到了数据,不要盲目把trackIndex设为-1还继续写。

4.2 音画不同步与时间戳基准问题

音画不同步的表现有两种:一种是两者起始时刻差了几百毫秒,另一种是录制时间越长偏差越大。第一种的常见原因是音频驱动采集就绪需要时间,或者MediaMuxer.start之前的首帧对齐没做好,解决办法是在初始化AudioRecord后等待第一个Buffer到达,再启动相机的repeating request,保证两个轨道的起始时间基准相同。第二种主要就是时间戳基准没有用同一个时钟。记住一句话:所有进入MediaMuxer的presentationTimeUs,必须基于同一个起始参考计算,千万不要一个用System.currentTimeMillis(),另一个用纳秒/1000。

我在某台设备上还遇到过一种Bug:视频戳是均匀的,但音频戳偶尔会往回跳,导致muxer抛IllegalArgumentException。定位后是AudioRecord底层缓冲区乱序回传给上层,处理办法是在音频编码前对时间戳做单调递增校验,发现倒退就强行拉平到上一帧的时间戳,虽然可能损失一小段同步精度,但至少不会崩。

4.3 录制中预览卡顿与掉帧

预览卡顿并不一定是预览本身的问题,很多时候是编码器跟不上。Camera的采集帧率是固定的,如果编码器每次dequeueOutputBuffer耗时超过一帧的间隔,底层的Surface buffer就会被占满,Camera只能丢帧。遇到这种情况用性能工具systrace抓一下编码线程,看它是不是花了很多时间在写文件上。

此外,写MP4文件也要尽量用异步IO。MediaMuxer.writeSampleData本身是同步函数,如果你的磁盘IO遇到慢速SD卡,一次write都可能卡几十毫秒。我们后来把输出文件放在内部存储而不是SD卡,掉帧立刻改善。新手看到writeSampleData卡顿别急着换编码器,先看看是不是IO瓶颈。

还有一种假掉帧是SurfaceView没有用setFrameCallback或者预览线程被其他业务占用。录屏类项目建议把预览直接做成TextureView+OpenGL渲染,这样录制侧和预览侧可以走同一份渲染管线,减少一次CPU拷贝。

4.4 部分设备编码器不兼容的应对

MediaCodec在AOSP源码之外,各芯片厂商的H.264编码器实现存在差异。有的厂商对分辨率必须是16的倍数要求很严格,有的对码率超过上限会自动忽略。因此配置encoder之前,一定要用MediaCodecInfo.CodecCapabilities查一下当前设备是否支持目标分辨率和码率,不支持时降级到720p。很多线上崩溃都是设备不支持4K编码但产品配置强制开了4K导致。

我建议在初始化阶段写一个VideoCapabilityChecker工具类,专门枚举所有可用的video encoder,过滤出包含H.264、surface输入、power等关键Capability的编码器,再回调给UI层做降级决策。这一步别省略,特别是有出海需求的App,海外中低端机型非常容易踩编码器兼容的坑。

5. 性能与体验优化的一些经验

5.1 录制过程产生的高功耗与温控策略

录制30分钟4K视频,任何手机都会发热。发热导致的最直接后果是编码器时钟频率下降,编码速度变慢,然后掉帧。更严重的情况是系统温控触发相机模块降帧,很多内置温控策略只保预览不保录制,此时视频帧率甚至会掉到10fps以下。

在应用层能做的是主动降级,比如检测电池温度和CPU温度,超过阈值就提示用户或自动把录制从4K30降级到1080p30。生产环境里我们做了一个温度监测线程,每5秒读一次PowerManager的thermal状态,超过一定等级就发通知让用户确认是否继续高码率录制。这个机制上线后,由于过热导致的录制失败率降了差不多一半。

编码器参数设置上,可以在丢帧可接受的场景下开启编码器的低延迟模式,也就是setInteger("low-latency", 1)。但要注意,低延迟模式可能会增大码率波动,因为编码器为了降低延迟会牺牲压缩效率,需结合场景判断是否值得。

5.2 生命周期管理与后台录制

视频录制过程中切后台,如果Activity被系统回收,整个录制链路都会断掉。稳妥做法是把录制服务放到前台Service,并绑定一个持续通知,告诉系统这个任务还在运行。如果你不想上通知栏常驻,也可以用startForegroundService并调用startForeground,这是Android从某个版本开始在后台启动服务限制下的必要处理方式。

还有生命周期嵌套问题。我在录制回调里刷新UI,比如显示当前录制时长,这个回调可能来自编码器线程,直接更新TextView会触发线程异常。需要切到主线程或者用协程的Main调度器。最佳实践是录制核心状态全部用LiveData/StateFlow暴露,UI层观察,隔离线程差异。

另外一个不容易注意到的小坑:录制过程中如果设备旋转,Activity重建会导致之前的CameraDevice全部失效。有两种处理路线:一是锁定屏幕方向,保持录制稳定;二是录制开始前把当前方向信息写入MP4的rotation属性,而不是在录制中切方向。我们采用的是锁定方向+旋转元数据方案。录制结束时调用MediaMuxer并没有提供设置rotation的接口,需要在封装前把MediaFormat的KEY_ROTATION设置好,这样播放器就能正确旋转画面。

5.3 文件分片与续录思路

有些业务场景要求录像文件不能过大,比如车载记录仪,一般会按1分钟或者100MB分片。MediaMuxer不像某些高级封装库那样支持同一个文件多段追加,因为它整个是对单文件单muxer实例工作的。所以分片录制的实现思路是:监听时长或体积阈值,到点后执行一次“软停止”,也就是完成当前的录制段并关闭muxer,然后立刻用新的muxer创建下一段文件,同时保持摄像头和编码器实例不销毁,只是把MediaMuxer对象替换掉。

这种软替换的关键在于编码器能否连续工作。实测下来,编码器本身是支持连续输出多段文件的,只要在段与段之间不调用编码器的stop和release,而是持续feed帧,然后在切换点做一次“下一帧是否为关键帧”的控制就可以。想强制让某个时间点为关键帧,可以在切换点之前通过Bundle参数向编码器发一个PARAMETER_KEY_REQUEST_SYNC_FRAME请求。这样下一段文件的起始帧一定是关键帧,播起来就不会从花屏开始。

分片录制还会引入音频问题,比如两个文件之间声音断掉。处理方式是让音频编码器继续使用同一个实例,每段session开始时不重置音频编码器,这样虽然时间戳会有跳变,但人耳听感上比重新初始化要好。音频时间戳在每段起始时要从0重新计,或者给每段文件单独加一个起始偏移,保证单个文件内的时间轴均匀。

5.4 编码器Buffer复用与内存优化

MediaCodec的输出Buffer如果不做特殊处理,每次dequeueOutputBuffer拿到的Buffer对象是复用的,也就是上一次的内容可能还在里面。正确姿势是,在写入MediaMuxer前把这份Buffer的position和limit设置正确,然后writeSampleData只消费bufferInfo指定的区间。如果这一步没有做好,可能把上一帧的残余数据也写进去,导致花屏和帧错位。

另外,不要每帧都new一个ByteBuffer去拷贝编码结果。我见过有人为了保证线程安全,把编码器输出的每一帧都复制一份再做异步队列写入,这在4K@60fps时会产生惊人的内存分配压力,GC卡顿直接导致掉帧。正确方案是同步写入muxer,或者用线程池加一个固定容量的环形Buffer池,帧数据直接引用编码器输出的Buffer区域,写完再释放。

内存方面,Camera2的ImageReader和MediaCodec的Surface都会锁定一部分GraphicBuffer内存,如果同时开多个ImageReader且不及时close,内存会悄悄涨上去。我只保留实际需要的最小路数,比如预览用一个SurfaceView的Surface,录制用编码器输入Surface,拍照用ImageReader,三路Surface就够了,而不是每注册一个回调就新建一个ImageReader。

6. 工具与调试方法总结

6.1 抓取关键日志和系统trace

遇到录制问题时,有几类日志比业务日志更管用。第一类是MediaCodec和MediaMuxer自身输出的NDK日志,通常在1ogcat里以“MediaCodec”或“MediaMuxer”为TAG,包含编码器open/close、错误码、codec config等信息。第二类是Cameraz日志,通常在vendor camera HAL层,需要抓取完整bugreport。第三类是音频服务日志,尤其涉及AudioRecord创建失败时要看AudioFlinger。

处理疑难杂症时,建议先从logcat中过滤出MediaCodec的报错,比如“cannot start audio encoder”或者“failed to configure video encoder”。这类报错一般会把原因说得很明白,比如width/height不合法、bitrate超出范围。千万别一上来就怀疑驱动问题,先看自家代码有没有传错参数。

系统trace是分析掉帧的神器。在Android Studio自带的Profiler里可以对MediaCodec编码线程做采样,观察每一帧数据从dequeueOutputBuffer到releaseOutputBuffer的耗时波动。如果耗时呈脉冲状上涨,大概率是文件IO或内存抖动,如果一直高位匀速,则是编码复杂度太高。用CPU Profiler记录Java方法耗时,再结合systrace对比System Server侧的Camera服务,能快速定界问题是在应用侧还是系统侧。

6.2 模拟多设备兼容性测试

相机录制属于硬件强相关功能,真机测试覆盖面怎么强调都不为过。我建议有条件的团队建立一个小型设备矩阵,至少要覆盖主流芯片平台各一台:高通、联发科、以及某国产芯片方案。芯片平台差异对编码器的影响比品牌差异大得多,同一颗芯片的不同机型往往表现一致,而不同芯片即使品牌相同也可能天差地别。

测试时要覆盖的分辨率组合:720p30、1080p30、1080p60、4K30,还有超广角切换场景。帧率切到60fps时,有些设备会自动把预览也跳到60fps,导致预览画面刷新率变化,用户观感怪异。处理方式是预览流固定30fps,录制流60fps,但需要Camera2支持同分辨率不同帧率的双路流,若不支持则只能退回统一帧率。

录制过程中的中断场景也要纳入测试列表:来电、闹钟、耳机插入、息屏、低电量、存储卡插拔。这些事件看似八竿子打不着,实际都会打断录制链路,不提前处理,线上必然翻车。我们有一个专门的健壮性测试脚本,用自动化跑这些中断场景,跑完自动检查录制文件是否完整可解析。

6.3 录制文件的自动校验脚本

录制完一段MP4后,如果只是靠播放器人工验证,效率太低了。我写过一个简单的脚本,用FFprobe读取文件的时长、轨道信息、编码格式、平均码率和关键帧位置,用来自动校验录制产物是否合格。核心检查项有三个:时长与预期偏差不能超过1秒,视频码率在目标值的80%到150%之间,音频轨存在且时长不小于视频时长的90%。

这个自动校验脚本在自动化测试里非常好用。每次CI构建后,跑一批真实设备的录制用例,收集MP4文件,批量跑校验脚本,能很快发现回归问题。比如某个厂商SDK升级后编码器输出不再是H.264 Main Profile而是High Profile,脚本能第一时间看到编码信息变化,人工排查时可以快速缩小范围。

FFprobe还能输出帧类型分布,比如I帧、P帧、B帧数量。如果视频内B帧占比过高,某些老旧播放器可能出现花屏,这时可以建议编码时禁用B帧,虽然压缩率会略降,但兼容性显著提升。Android的MediaCodec没有统一的B帧开关,但部分厂商的扩展参数支持PV_PLAYER_DISABLE_BFRAME,这也是查设备文档时要注意的点。

7. 常见问题速查表

现象可能原因解决思路
录制文件无法播放muxer未正常stop、CODEC_CONFIG漏写规范收尾流程,校验文件头与moov box
画面黑屏Surface未加入CaptureRequest、分辨率不支持检查addTarget与StreamConfigurationMap
音画不同步时间戳基准不一致、音频轨道未对齐统一使用同一时钟源,等待首帧音频
预览卡顿编码器处理慢、磁盘IO瓶颈降低分辨率/码率,使用内部存储
帧率不足双路流带宽不够、温控降频降低总数据量,主动监控温度
编码器配置异常设备不支持目标分辨率/码率用CodecCapabilities预校验并降级
声音断断续续AudioRecord缓冲区太小缓冲区设为getMinBufferSize()的两倍以上
录音静音AudioRecord未重新初始化监听耳机插拔、前台切换后重建录音链路
上拉进度条卡顿I帧间隔过大适当缩小GOP,比如改为30到60
录制中途App被杀文件未收尾扫描未完成文件,启动时提示或清理

写在最后的录制心得

做视频录制功能,表面上是在写Camera的调用代码,实际上是在处理一套分布在系统各层的异步协作流程。相机采集、编码器状态、AudioRecord读取、MediaMuxer封装,每一步都是独立状态机,想当然地按同步逻辑开发一定会翻车。我的一个体会是,视频录制的坑绝大多数都是“时序”问题,比如start顺序不对、stop顺序不对、时间戳不对、Surface状态不对。调试这类问题没有银弹,就是要把日志打全,把关键代码路径的调用顺序体现在日志里,再结合能复现问题的真机反复二分定位。

另一个体会是不要盲目追求高参数。4K60看着很美好,但对绝大多数设备来说都撑不住长时间稳定录制。产品上如果没有特别需求,1080p30是体验和兼容性的黄金区间。为了让用户录出流畅的视频,宁可分辨率低一点,也不要掉帧和卡顿。

如果你在实际开发中卡在某个录制问题,不妨回到这台流水线上,逐一确认每一段通道是否都符合预期:数据有没有到编码器,编码器有没有输出,muxer有没有正常写入。把链表上的每一个节点都验证一遍,90%的疑难杂症都能水落石出。希望这篇Android camera篇的视频录制总结能帮你少走一些弯路,有问题欢迎在评论区交流,我看到了会尽量回复。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进