news 2026/9/9 21:46:08

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android视频录制时长不准?MediaRecorder停止时序与编码器缓冲排查实践

前阵子我们视频录制模块收到一个特别典型的反馈:用户设置录制10秒,画面上的进度条也确实走到了100%,但真正生成的文件时长有12.8秒,多出来的部分正好是进度条满之后那几秒的空镜。测试组给这个bug起的名字就是标题这句——“camera录制视频,进度条满后还会录制3秒”。

我最初以为是进度条时间计算错位,但把所有时间戳打出来比对之后发现,问题远比UI层要深。它涉及进度条驱动方式、MediaRecorder的停止时序、编码器缓冲、甚至主线程调度。这其实是做移动端相机录制最容易踩的坑之一,不是某个品牌设备的偶发故障,而是很多自研录像功能都会碰到的一个系统性时序问题。今天就把我从现象到根因、从方案对比到最终落地这段完整经历梳理一遍,给正在做相册、短视频、直播录像、或者工具类App录制功能的同学一个可以直接参考的排查路径。

1. 现象复现清楚:进度条、文件时长和用户体感三套数据是错位的

先明确问题长什么样。我们App的录制功能大概是这样:用户按下录制按钮开始录像,顶部一条细进度条从左往右走,走到满表示录制结束,应用自动停止并保存视频。用户反馈的现象是,进度条满之后,画面上方的红点还在闪,说明录制状态没有立刻退出,大概继续闪了3秒左右才真正停,保存出来的视频也明显比设定时长多出一截。

1.1 复现步骤和测量方法

为了把现象量化,我用一台测试机重复了以下步骤:

  1. 在录制设置页把最大时长设为10秒。
  2. 开始录制的同时,在日志里打一个startTime,用SystemClock.uptimeMillis()记录。
  3. 每秒打一条当前已用时长和进度条对应值。
  4. 在进度条满的那一刻记录progressBarFullTime
  5. 在录制真正停止、文件写入完成的回调里记录actualStopTime
  6. 用工具解析生成的MP4文件,读取实际时长。

连续跑了5次,得到的数据大概是:

次数进度条满时间真正停止时间两者差值文件实际时长
110.02s13.10s3.08s12.9s
29.98s13.22s3.24s13.0s
310.01s12.85s2.84s12.7s
410.03s13.31s3.28s13.1s
59.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开始录制0s
T1进度条走满10.01s
T2MediaRecorder触发MAX_DURATION_REACHED10.05s
T3系统将回调分发到应用主线程10.50s
T4应用调用mediaRecorder.stop()11.65s
T5stop()调用返回12.80s
T6文件写入完成、录制结束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自动停止高,误差在百毫秒级对精度有一定要求的大多数App
MediaCodec+Muxer帧级精确编辑器、专业录制、特效处理

我们最终在内部评估后,决定先把方案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()的问题都可以用一个极简状态机解决。我把录制状态定义成枚举:IDLERECORDINGSTOPPINGFINISHED。每次进入停止逻辑时先做状态判断,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.2s
210.00s10.41s10.3s
310.02s10.29s10.1s
49.99s10.33s10.2s
510.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_REACHEDMEDIA_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,根源往往藏在底层异步链路里。进度条只是整个录制管线的冰山一角,它满不满,与编码器是否停止、文件是否写完,根本不是一个时间维度的事。处理过一次之后,再做其他和视频时长、进度、节流相关的功能,我都会先画一遍“事件时间线”,把每个回调可能发生的延迟都提前评估进去。这样虽然不能杜绝所有问题,但至少能让问题在变成用户反馈之前,先被自己发现。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 21:44:27

C++享元模式实战:从内存优化到对象共享的完整指南

如果你写过一段时间 C&#xff0c;大概率遇到过这样一种别扭的场景&#xff1a;一个系统里要创建成千上万个对象&#xff0c;每个对象本身不大&#xff0c;数据也不复杂&#xff0c;但数量一上来&#xff0c;内存就像漏了一样往下掉&#xff0c;性能也肉眼可见地卡顿。你可能第…

作者头像 李华
网站建设 2026/9/9 21:44:12

企业数学建模实战:从业务拆解到参数体系构建

1. 先搞清楚建模对象&#xff1a;企业业务场景的拆解思路 做企业数学建模这么多年&#xff0c;我最大的体会是&#xff1a; 很多模型做出来没落地&#xff0c;根本原因不在算法&#xff0c;而在于一开始就没搞清楚"到底在建模什么" 。企业里的业务场景跟实验室里的…

作者头像 李华
网站建设 2026/9/9 21:41:36

OBS Studio 如何配置长时间录制的自动文件分割?

OBS Studio 如何配置长时间录制的自动文件分割&#xff1f; 【免费下载链接】obs-studio OBS Studio - Free and open source software for live streaming and screen recording 项目地址: https://gitcode.com/GitHub_Trending/ob/obs-studio 长时间录制&#xff08;例…

作者头像 李华
网站建设 2026/9/9 21:41:34

STM32F4串口/RS-485自研BootLoader固件升级方案详解

简介&#xff1a;面向STM32F4嵌入式开发者&#xff0c;提供一套基于串口/485总线的OTA在线升级方案&#xff0c;包含自制Bootloader与应用程序两个完整工程&#xff0c;适用于设备远程固件更新、现场维护及批量产线烧录等场景。包内共277个文件&#xff0c;以C源码与头文件为主…

作者头像 李华