十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android视频录制时长不准?MediaRecorder停止时序与编码器缓冲排查实践

Android视频录制时长不准?MediaRecorder停止时序与编码器缓冲排查实践 前阵子我们视频录制模块收到一个特别典型的反馈用户设置录制10秒画面上的进度条也确实走到了100%但真正生成的文件时长有12.8秒多出来的部分正好是进度条满之后那几秒的空镜。测试组给这个bug起的名字就是标题这句——“camera录制视频进度条满后还会录制3秒”。我最初以为是进度条时间计算错位但把所有时间戳打出来比对之后发现问题远比UI层要深。它涉及进度条驱动方式、MediaRecorder的停止时序、编码器缓冲、甚至主线程调度。这其实是做移动端相机录制最容易踩的坑之一不是某个品牌设备的偶发故障而是很多自研录像功能都会碰到的一个系统性时序问题。今天就把我从现象到根因、从方案对比到最终落地这段完整经历梳理一遍给正在做相册、短视频、直播录像、或者工具类App录制功能的同学一个可以直接参考的排查路径。1. 现象复现清楚进度条、文件时长和用户体感三套数据是错位的先明确问题长什么样。我们App的录制功能大概是这样用户按下录制按钮开始录像顶部一条细进度条从左往右走走到满表示录制结束应用自动停止并保存视频。用户反馈的现象是进度条满之后画面上方的红点还在闪说明录制状态没有立刻退出大概继续闪了3秒左右才真正停保存出来的视频也明显比设定时长多出一截。1.1 复现步骤和测量方法为了把现象量化我用一台测试机重复了以下步骤在录制设置页把最大时长设为10秒。开始录制的同时在日志里打一个startTime用SystemClock.uptimeMillis()记录。每秒打一条当前已用时长和进度条对应值。在进度条满的那一刻记录progressBarFullTime。在录制真正停止、文件写入完成的回调里记录actualStopTime。用工具解析生成的MP4文件读取实际时长。连续跑了5次得到的数据大概是次数进度条满时间真正停止时间两者差值文件实际时长110.02s13.10s3.08s12.9s29.98s13.22s3.24s13.0s310.01s12.85s2.84s12.7s410.03s13.31s3.28s13.1s59.99s12.92s2.93s12.8s这个数据说明两件事第一进度条本身走满的时间点和设定值基本一致进度条的算法没有大问题第二真正停止的时间点比进度条满了晚了大约3秒而且文件里真的录进了这多出来的画面。所以这不是UI显示欺骗是实实在在的“多录了”。1.2 难定位在哪三个环节各有一套“时间”很多人遇到这种问题第一反应是去查进度条代码但进度条只是表象。录制功能从开始到结束其实有完全独立的三个时间维度进度条时间应用层从开始录制时计时驱动UI进度。录制器时间MediaRecorder或CameraX内部从采集到编码的时间。文件时间MP4容器里记录的实际时长。用户感知的“录制结束”取决于这三者是否对齐。当一个环节出现延迟后面所有环节都会被拉长。而这次的情况是进度条时间已经走满MediaRecorder还在继续接收画面编码器也还在吐数据文件名下的时长自然就超了。1.3 为什么这个Bug看起来简单却容易拖很久因为它没有一个“必现”的规律。在高性能旗舰机上可能只多几百毫秒用户根本察觉不到但在中低端机上编码速度跟不上采集速度队列一堆积多出来的时长会被明显放大。我们这3秒的差值就是在中端测试机上复现的放到低端机上甚至可能到5秒以上。如果不能先把这个现象量化后续排查很容易被设备差异带偏方向。2. 根因排查从UI层到Framework层逐条排除嫌疑接下来是完整排查链路。我建议所有做录制功能的人遇到类似问题都按这个顺序走一遍不要上来就怀疑MediaRecorder自身。2.1 第一嫌疑进度条用了墙钟时间最先查的是进度条驱动方式。早期代码里用的是System.currentTimeMillis()相减这有一个严重问题如果用户在录制过程中切换网络、运营商自动校正时间、或者系统时区发生变化墙钟时间会跳变进度条就会跟着跳。不过在我们这次场景里测试时没有发生时间跳变所以进度条走满时间是对的这个嫌疑排除。但这里还是要强调一个原则凡是从开始时间推算耗时的一律用SystemClock.uptimeMillis()或elapsedRealtime()不要用墙钟时间。uptimeMillis不会受系统时间修改影响虽然它不包含深度睡眠时间但对于录制这种亮屏场景完全够用。2.2 第二嫌疑停止动作没有在第一时间执行这层嫌疑直接命中。我们的原始实现是这样的mediaRecorder.setOnInfoListener { _, what, _ - if (what MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { stopRecording() } }从逻辑上看达到最大时长后立刻调用stop没问题。但问题出在stopRecording()里面做了什么。它要执行mediaRecorder.stop()、mediaRecorder.release()、更新UI、切换摄像头状态等等。如果这时候主线程被其他任务占住尤其像页面渲染、动画、后台数据上报stop就会被推迟。我在日志里打印了主线程的执行情况发现从onInfo回调发生到mediaRecorder.stop()真正执行中间隔了1.6秒左右。这已经很能说明问题了。2.3 第三嫌疑MediaRecorder.stop()本身是同步阻塞的即使我们立刻调用了stop()这个方法也不会瞬间返回。MediaRecorder的stop()要做到几件事通知底层编码器停止接收输入帧、把编码器缓冲区的剩余数据全部取出来、把MP4的索引和时长元数据写入文件。这一步的时间跟视频分辨率、码率、编码器缓冲区大小都有关系。1080p/30fps、码率8Mbps的情况下实测stop()本身的阻塞时间大概在300ms到1.2秒之间。也就是说从stop()被调用到文件真正写完还有一段不可忽略的时间窗口。2.4 第四嫌疑编码器缓冲区里本来就积压了大量帧这是最深层的原因。MediaRecorder内部用MediaCodec做编码MediaCodec在编码过程中不会逐帧即时输出而是会缓冲一批帧。尤其是带有B帧的编码序列为了保持帧率顺序编码器必须等后面的帧进来才能把前面的帧输出。通常一个GOP周期内的帧都会被缓冲住。如果设备性能不够或者编码器输出被拉长缓冲区里可能积压了几百毫秒甚至1秒以上的帧。这些帧在stop()被调用后才开始flush写入文件反映到文件时长上就是多了一截画面。2.5 完整证据链把三个阶段时间线画在同一张表上为了确认到底每一段各占多少我在代码里分阶段打上时间戳得到这样一张典型时间线时间点事件相对开始录制时间T0开始录制0sT1进度条走满10.01sT2MediaRecorder触发MAX_DURATION_REACHED10.05sT3系统将回调分发到应用主线程10.50sT4应用调用mediaRecorder.stop()11.65sT5stop()调用返回12.80sT6文件写入完成、录制结束13.10s可以看到10秒到13.1秒的3.1秒被拆成了三段从进度条满到回调分发约0.5秒这是系统内部消息投递耗时。从回调分发到主线程执行stop()约1.15秒这是主线程调度竞争。从stop()开始到文件写完约1.45秒这是编码器flush和MP4索引写入。这三个数字加在一起就是用户感知到的“多录3秒”。搞清楚了这一点解决方案也就有的放矢了。3. 为什么“等进度条满再停止”这个设计本身就埋了雷不少人会觉得最大时长都到了MediaRecorder不就应该自动停吗事实上这个理解不完全对。这里面的机制细节决定了你如果不做额外处理就一定会体验到延迟。3.1 MediaRecorder的maxDuration只是“通知信号”不是“硬停止”MediaRecorder.setMaxDuration()设定之后达到时长上限时底层会发出MEDIA_RECORDER_INFO_MAX_DURATION_REACHED事件。这个事件被发送到应用层之后录制并不会自动干净地结束它更像是一个“该给它下停止指令了”的信号。如果应用收到信号后不调用stop()去正确走完收尾流程文件可能会损坏底层状态也无法回到初始状态。这个设计本意是让应用有机会自行决定何时真正保存文件但也意味着从信号触发到执行停止之间所有延迟都会叠加到最终的视频时长上。这是“多录3秒”的结构性来源不是某个机型独有的bug。3.2 回调分发与主线程调度两个隐性延迟源OnInfoListener回调通常发生在Binder线程你需要把它post到主线程去操作UI和状态。这里如果直接handler.post还得看主线程当时的Looper队列里有多少任务排在前面。尤其录制过程中UI会同步刷新进度条、帧率信息可能还开着滤镜、美颜主线程压力本身就不小。排队几百毫秒很常见遇到GC或者页面测量就能到1秒以上。我在日志里就发现有一帧主线程执行了超过400ms的布局任务就是那个阶段把stop()的执行又往后推了不少。3.3 编码器缓冲区数据层面的“惯性”把录制比作往一个管道里倒水停止录制相当于关上进水口。但管道里已经灌进去的水还会继续流出来进入文件。这个“惯性”就是编码器缓冲。视频编码器为了压缩效率往往需要预读后续帧缓冲区大小在编码器初始化时由厂商决定通常从两百毫秒到一秒钟不等。如果前面采集的帧率高于编码器处理速度缓冲区就会涨到很大。等停止时缓冲区里的帧都要写完文件时长自然就超了。这个超出的时长不是固定值跟设备负载、分辨率、码率都有关。这也是为什么同一套代码在不同手机上表现差异很大。3.4 “预览图还在动”加剧了用户体感还有一个细节进度条满之后录制Surface的预览并没有立即关闭画面还在一帧一帧地动。对用户来说取景器里的内容还在变化红点还在闪那不就是还在录吗所以即使文件只是多了一点点用户的体感也是“过了好几秒才结束”。要彻底解决不能只依赖一个点应当把“进度条满”“停止录制”“关闭预览”这几个动作放进一个明确的状态机里让它们在一个可预期的时序内完成而不是靠系统回调去“随缘触发”。4. 三个可行方案对比提前量、自动停止、还是自建管线定位到根因后我整理了三个候选方案每个都有适用场景也各有代价下面具体拆一下。4.1 方案A在进度条计算上加入“停止提前量”既然停止流程必然有延迟那我们就在进度条上把这个延迟“预支”掉。比如用户设定录制10秒实际我们给MediaRecorder的maxDuration设成9.5秒或者进度条在到达100%之前就提前触发停止逻辑。这么做的好处是改动量最小基本不用动录制管线只需要调整一下计时器和停止触发点。缺点是它本质上是在“赌”一个延迟值如果设备性能波动大这个提前量很难精准覆盖可能出现两种新问题提前量设小低端机上仍然多录。提前量设大高端机上反而少录了一段。所以这个方法只适合对时长精度要求不高的产品例如只限制“最长不超过12秒”的短视频场景不要求精确等于设定值。4.2 方案B切换CameraX用maxDuration自动停止并响应Future回调CameraX的VideoCapture.Recording提供了一个带maxDuration的重载方法传入参数后CameraX内部会在达到时长时自动停止并返回结果回调。它内部对时序的处理比大多数开发者手写的MediaRecorder逻辑要可靠得多。val recording videoCapture.output .prepareRecording(this, mediaStoreOutputOptions) .withAudioEnabled() .start(ContextCompat.getMainExecutor(this)) { event - when (event) { is VideoRecordEvent.Finalize - { // 录制真正结束可以在这里读文件时长 } is VideoRecordEvent.Status - { // 可以用event.recordingStats.getRecordedDurationNanos()获取已录制时长 } } }用CameraX还会多一个好处VideoRecordEvent.Status里直接暴露了已录制时长进度条不再需要自己计时直接用这个时长除以目标时长即可进度条和录制器的时间天然对齐。需要提醒的是CameraX内部虽然封装了时序但最终进行编码的仍然是系统底层它同样会受编码器缓冲影响。不过测试下来CameraX在收到自动停止信号后会立刻走Finalize流程整体比普通MediaRecorder手写方案稳多录时长可以控制在几百毫秒级别体感上基本无感。4.3 方案C自建MediaCodec MediaMuxer管线完全控制帧时间戳如果连几百毫秒都不能接受或者你需要做逐帧特效处理、自定义码率控制那就只能放弃MediaRecorder直接用MediaCodec自己做编码MediaMuxer封装MP4。这条路能把时间精度精确到帧级别因为每一帧的时间戳都是你自己设置的停止时你可以决定“最后写入下一秒PTS的帧是谁它就是文件的最后一帧”。代价是工程量陡增你需要处理相机预览Surface、编码器Surface输入、音视频同步、旋转信息、后台生命周期以及大量厂商兼容问题。对我们这个项目来说还没有到需要这个精度的程度所以没有选这条。但如果你的产品本身就是一个面向专业用户的编辑器那自建管线几乎不可避免因为MediaRecorder的业务中断能力暂停、分片、精确裁剪太弱了。4.4 方案对比总结方案实现成本时长精度适用场景提前量补偿低中依赖设备短视频、聊天拍摄CameraX自动停止中高误差在百毫秒级对精度有一定要求的大多数AppMediaCodecMuxer高帧级精确编辑器、专业录制、特效处理我们最终在内部评估后决定先把方案A最稳妥的变形落地——也就是下文的“提前阈值状态机”组合因为我们的产品并不是专业拍摄工具主要目标是修复“多录3秒”的用户体感问题同时不引入大范围重构。以后如果需要做倍速、暂停、分段拍摄再平滑切换到CameraX或自建管线。5. 实操落地以“提前阈值状态机”为例的代码改造下面这段是我们在现有代码库里实际做的改造不复杂但每一步都有明确目的。如果你也有类似问题可以照着状态机思路去调整不需要照抄关键是理解为什么这样设计。5.1 给录制器设置的maxDuration加一个安全提前量我把目标时长定义成两个值targetDurationMs是用户期望的时长safeHeadroomMs是为了抵消停止流程延迟而预留出的提前量。对应地设置给MediaRecorder的maxDuration为两者之差。private var targetDurationMs 10_000L private val safeHeadroomMs 800L private fun configRecorder() { mediaRecorder.setMaxDuration((targetDurationMs - safeHeadroomMs).toInt()) }这里的提前量取值不是拍脑袋定的是拿我们主力测试机上的T4到T6阶段耗时去估算的。前面日志里T4到T5约1.15秒T5到T6约1.45秒加起来约1.6秒。如果我们在进度条走到大约91%时就开始停止那用户感知到的就是进度条刚满录制已经结束。不过要注意这个值不能设得比T4到T6的耗时还大否则用户会觉得“进度条还没满就停了”。安全起见我建议从500ms开始调跑一轮真机数据再决定。我们最终用了800ms是因为在高负载场景下主线程调度延迟会比空闲时高一些留一点余量更稳。5.2 进度条驱动改掉手写动画直接用录制时长来映射原来的进度条走了Animation直接按动画时长设定。改造后改为每30ms查询一次已录制时长计算进度。private val elapsedTimeHandler Handler(Looper.getMainLooper()) private fun startProgressTimer() { recorderStartTick SystemClock.uptimeMillis() progressRunnable object : Runnable { override fun run() { val elapsed SystemClock.uptimeMillis() - recorderStartTick val progress (elapsed * 100f / targetDurationMs).coerceIn(0f, 100f) progressBar.progress progress.toInt() if (progress 100f) { elapsedTimeHandler.postDelayed(this, 30L) } } } elapsedTimeHandler.post(progressRunnable) }注意这里用的是SystemClock.uptimeMillis()而不是currentTimeMillis()不依赖系统时间。同时因为设置了提前量MediaRecorder会在进度条显示100%之前就触发OnInfo回调此时我们直接进入停止流程让停止动作实际发生在进度条满附近。用户看到的进度条依然是平滑走到头的。5.3 用停止状态机兜住重复停止所有关于重复调用stop()的问题都可以用一个极简状态机解决。我把录制状态定义成枚举IDLE、RECORDING、STOPPING、FINISHED。每次进入停止逻辑时先做状态判断STOPPING之后所有重复触发直接return。enum class RecorderState { IDLE, RECORDING, STOPPING, FINISHED } private var recorderState RecorderState.IDLE private fun stopRecording() { if (recorderState ! RecorderState.RECORDING) return recorderState RecorderState.STOPPING // 这里放到子线程执行避免阻塞主线程 executor.execute { runCatching { mediaRecorder.stop() }.onFailure { // stop()在未start()或异常时会抛RuntimeException这里一定不能吞异常 } mediaRecorder.reset() mediaRecorder.release() runOnUiThread { recorderState RecorderState.FINISHED progressBar.progress 100 closeRecordingIndicator() } } }以前我们是在主线程调mediaRecorder.stop()阻塞期间用户看到画面卡住观感很差。放到子线程后主线程只负责UI状态切换录制收尾的耗时不会阻塞交互。5.4 处理MAX_DURATION_REACHED回调的线程切换OnInfoListener回调本身不一定在主线程但切换到主线程又可能排队。我这里的处理是收到回调后不再post到主线程而是直接投递到前面那个executor让停止动作立刻进入子线程队列。mediaRecorder.setOnInfoListener { _, what, _ - if (what MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { // 不经过主线程直接在工作线程里执行停止 executor.execute { stopRecording() } } }这里有个细节stopRecording()内部已经做了状态判断所以即使重复触发也不会执行两次stop。这个改动直接规避了主线程排队问题也是本次优化里效果最明显的一处。5.5 验证改造后同一台机器上的数据对比改完后同样跑5轮10秒录制得到次数进度条满时间真正停止时间文件实际时长110.01s10.38s10.2s210.00s10.41s10.3s310.02s10.29s10.1s49.99s10.33s10.2s510.01s10.35s10.2s多录的时长从3秒压到了200毫秒左右这个误差已经不明显了文件时长也不会超得离谱。如果还想再进一步可以把提前量从800ms调到1000ms或者后续切到CameraX的方案误差会更稳定。5.6 改造时要注意的几个坑这段改动看着小但我们在测试时踩了几个坑单独提出来mediaRecorder.stop()在未开始录制时调用会抛RuntimeException所以状态机里必须判断当前状态不能只靠OnInfo回调触发。不要在用MediaRecorder时直接在主线程调用stop()它可能阻塞几百毫秒甚至更久造成掉帧和ANR。有些定制ROM在达到maxDuration后不会发送MAX_DURATION_REACHED只会通过错误码回调建议同时监听MEDIA_RECORDER_INFO_MAX_DURATION_REACHED和MEDIA_RECORDER_ERROR_UNKNOWN。如果录制过程中用户手动点停止也要走同一个状态机避免“手动停止”和“自动停止”两个分支相互竞争。6. 实测中的边角情况与一段经验总结改造上线后我们又顺手处理了几个平时不太会注意但真实存在的边界问题它们都可以归类到“录制时长不准”这个主题下。6.1 不同SoC平台的差异比想象中大同一个版本在骁龙和天玑两个平台上跑编码器缓冲的表现就完全不同。有些平台的MediaCodec输出非常及时停止后几乎没有多余帧有些平台为了压缩率GOP拉长缓冲明显更多。如果团队有条件特别建议在低端机上做回归测试不要只拿主力旗舰机对效果。6.2 音频录制会放大问题如果只录视频不录音频编码器缓冲相对小。开了音频之后音视频交错写入MP4时两者要做时间戳对齐停止时如果音频Buffer还没写完文件写入时间会进一步拉长。表现为“进度条已经停了但转圈圈转了很久”。处理思路和视频一样提前停止、异步处理、状态机兜底。6.3 进度条回看时也要用文件时长很多App在“本地作品页”会重新加载视频文件读时长来显示这会造成一个看似矛盾的现象录制时界面显示10秒作品列表里却显示13秒。用户会认为“App算错了”。这个虽然不涉及停止时序但也是同一个问题带来的连锁反应。改造后文件时长已经接近10.2秒进度条和列表信息基本对齐这类投诉也明显减少了。6.4 留一点“录制中”状态异常的自愈能力最后要提的是别把录制功能写得太“脆”。我们后来在STOPPING状态上加了一个超时保护如果进入STOPPING超过3秒还没走到FINISHED就强制释放资源同时把状态重置为IDLE。这么做的原因是极少数手机上mediaRecorder.stop()可能因为底层驱动异常而长时间不返回如果没有兜底用户会卡在一个“不能录也不能退”的状态里只能杀进程。我个人的体会是这类“看起来是UI问题”的bug根源往往藏在底层异步链路里。进度条只是整个录制管线的冰山一角它满不满与编码器是否停止、文件是否写完根本不是一个时间维度的事。处理过一次之后再做其他和视频时长、进度、节流相关的功能我都会先画一遍“事件时间线”把每个回调可能发生的延迟都提前评估进去。这样虽然不能杜绝所有问题但至少能让问题在变成用户反馈之前先被自己发现。
返回列表