Android音视频开发:时间轴与生命周期决定画质
简介:这是一份面向安卓音视频开发者与移动多媒体初学者的PDF学习资料,系统梳理音视频开发最核心的基础理论与处理流程。全书为独立PDF电子书,压缩包仅1个文件,类型为PDF,体积约6.39MB,便于直接阅读与检索。目前已有1213人学习,内容从帧、帧率、码率、分辨率、IPB帧、GOP、DTS/PTS、颜色空间等基础概念讲起,借助10分钟、512×288分辨率下YUV与MP4体积对比案例直观展现原始视频数据量之庞大,结合FFmpeg命令示例展示视频抽帧、图片合成GIF、码率控制、关键帧提取等常见操作,并分别比较CBR、VBR、CVBR、AVBR四种码率模式的适用场景。后续还延伸到Android多媒体框架,包括MediaCodec、AudioTrack、Surface的应用方式,帮助读者把抽象理论与具体开发接口衔接起来。通过学习,可以建立完整的音视频知识地图,理解画质、文件大小、编码效率之间的权衡,为后续项目开发、团队协作或深入源码打好基础。
1. Android 音视频处理的难点不在编码器,而在时间轴和生命周期
接手一个本地录像功能,第一版在实验室机器上跑得很顺,换到另一台真机,画面每隔几秒花一下,声音却正常。排查到最后,视频编码器没坏,是 Camera 回调节奏被系统打断,喂给 MediaCodec 的帧时间戳发生了回退。Android 音视频开发与处理,表面上是 CameraX、MediaCodec、MediaMuxer、ExoPlayer 这些 API 的拼装,底层真正决定成败的是时间轴是否统一、组件生命周期是否同步。摄像头、编码器、封装器、播放器各有各的时钟和状态机,一个误导,最终都会表现成花屏、掉帧或音画不同步。下面按采集、编码、封装、播放、排错这条主线,覆盖本地录制、视频编辑、直播采集三类场景。
2. 采集与预览:CameraX 到 MediaCodec 的 Surface 链路
2.1 CameraX 与 Camera2 的选型边界
搞采集之前先定 API。Camera2 从 API 21 就是正式接口,控制粒度最细,曝光、增益、对焦区域、RAW 回调全暴露在外面,规格书上的参数基本都能手动喂。代价是回调线程和状态机要自己维护,CameraDevice 断开、CaptureSession 重启、CaptureCallback 积压这类问题,写过三年 Camera2 的人也不敢说全踩完。CameraX 相当于在 Camera2 外层补了生命周期管理和用例抽象,预览、图像分析、录制三种 UseCase 可以组合,绑定 LifecycleOwner 后自动开关会话,设备断开也会自动重建。
选型上我的习惯是:产品只需要预览、拍照、录像,走 CameraX;要做人脸检测、超广角切换、多摄融合、RAW 连拍这类需要硬件级参数控制的,回 Camera2。CameraX 保留了 Camera2CameraControl 和 Camera2Interop 两个下钻口,能拿到 CaptureRequest 发送原始控制参数,所以不必担心日后被封装卡死。
| 维度 | CameraX | Camera2 |
|---|---|---|
| 上手成本 | 用例抽象,一个绑定即可出画面 | 自建会话与捕获回调 |
| 生命周期 | 绑定 LifecycleOwner 自动管理 | 手动处理断开与重启 |
| 硬件级控制 | 通过 Camera2Interop 下钻 | 原生支持 |
| 多摄/变焦 | 扩展 API,部分机型受限 | 全手动,适配工作量大 |
| 适合场景 | 本地录制、扫码、普通预览 | 直播前处理、深度/Raw 场景 |
2.2 用 CameraX 打通预览与 YUV 帧回调的最小链路
val analysis = ImageAnalysis.Builder()
.setResolution(Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.build()
analysis.setAnalyzer(executor) { imageProxy ->
val yPlane = imageProxy.planes[0]
val yBuffer = yPlane.buffer
yBuffer.rewind()
val yBytes = ByteArray(yBuffer.remaining())
yBuffer.get(yBytes)
imageProxy.close()
// yBytes 是 Y plane,按 rowStride 逐行拷贝后送编码器
}
setResolution 设置的是分析流内部分辨率,不是预览分辨率,两者互相独立。KEEP_ONLY_LATEST 表示分析速度跟不上时直接丢旧帧,适合扫码、检测这类实时任务;录像场景要保证不漏帧,应改用 BLOCK 生产者策略,让采集端等分析端。OUTPUT_IMAGE_FORMAT_YUV_420_888 是 MediaCodec 硬件编码器原生友好格式,拿到 Y/U/V 三个 plane 后可以直接拷进编码器输入,不需要先转 RGBA,省一次色彩空间转换。最后 imageProxy.close() 每次都必须执行,漏 close 会耗尽缓冲区,表现成一段时间后预览和分析同时黑屏。Analyzer 回调里只做轻量计算,耗时操作交给渲染线程,否则分析线程一旦排队,帧率会被拖到个位数。
2.3 采集回调里的旋转角、行对齐与多路复用
竖屏拍摄时传感器输出的原始图像方向是横的,不处理的话 MP4 一播放就是歪的。两种修法:要么在编码器配置里给 MediaFormat 写 KEY_ROTATION,播放器读取旋转角自行转正;要么在送进编码器前用 OpenGL 把画面 rotate 到目标方向。分析回调里判断当前方向用 imageProxy.imageInfo.rotationDegrees,注意它表示的是传感器坐标到 UI 坐标的旋转补偿角,不是直接往 MediaFormat 里填的值。
YUV_420_888 的 plane 不等于连续紧密排列,每一行实际占用的字节数是 rowStride,行尾到下一行开头之间可能有填充字节。从 Y plane 往编码器输入拷贝时,按行遍历、步长用 plane.rowStride 而不是 width,否则画面会出现斜向撕裂或右侧绿边。不少初版实现都在这里踩过,肉眼看起来很像硬件编码器有问题,其实是数据没按 stride 排。
预览、录像、分析三个 UseCase 同时在跑时,最省路径是把 Preview 的 SurfaceRequest 直接对接编码器输入 Surface,画面从 sensor 到编码器只经过一次 buffer 拷贝;要加滤镜、美颜时再在中间插入一个 FBO 节点。provideSurface 的 ResultListener 回调里,resultCode 等于 SURFACE_USED_SUCCESS 才算真正被消费,其它结果说明 surface 已被替换或会话已关闭,此时要重置编码器并重建采集链路,不然下一次录制直接黑屏。
3. 编码与解码:MediaCodec 的参数表与回调模型
3.1 硬编硬解为什么是默认路径
H.264 编码在 Android 上是硬指标,几乎所有设备都带着 AVC 硬件编码器;Android 8 起默认媒体框架切换为 Codec 2,厂商实现名字里能看到 c2 前缀,老设备则是 OMX 前缀。选硬编不是因为参数好看,而是功耗和延迟:软件 x264 在 1080p30 下 CPU 占用很容易过半,发热降频后帧率直接跳水;硬件编码器走独立通路,同样负载下 CPU 占用通常能压在 5% 以内。直播场景对编码延迟有硬要求时,硬编和软编的端到端延迟能差出一个数量级。
MediaCodec 的默认策略是按 MIME 找硬件优先的编码器,软编只在这三类场景兜底:硬件不支持目标 profile;产品坚持逐帧 YUV 前处理无法用 Surface 输入;要编 HEVC 但真机没有对应硬编。写代码前先用 MediaCodecList 拉一遍编码器,比跑起来再试错省时间。
3.2 MediaCodec 编码 H.264 的关键参数和最小实现
| 参数 | 常见值 | 说明 |
|---|---|---|
| KEY_BIT_RATE | 720p 4Mbps,1080p 8Mbps | 推流带宽不足时降码率,本地录制可放宽 |
| KEY_FRAME_RATE | 24 / 30 / 60 | 编码器不会补帧,实际节奏由喂帧方决定 |
| KEY_I_FRAME_INTERVAL | 本地 2s,直播 1s | 单位秒;需要秒开时配合 requestKeyFrame |
| KEY_PROFILE / KEY_LEVEL | AVC High / Level 4 | 设备不支持时 configure 抛异常,先查 CodecCapabilities |
| KEY_BITRATE_MODE | VBR / CBR | 推流用 CBR 保带宽,录本地用 VBR 保画质 |
| KEY_COLOR_FORMAT | COLOR_FormatSurface | 手动 YUV 输入时才需要指定 |
val format = MediaFormat.createVideoFormat(
MediaFormat.MIMETYPE_VIDEO_AVC, width, height
).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatSurface)
setInteger(MediaFormat.KEY_BIT_RATE, 8_000_000)
setInteger(MediaFormat.KEY_FRAME_RATE, 30)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
setInteger(MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
}
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
encoder.setCallback(encoderCallback, backgroundHandler)
encoder.start()
配置顺序有讲究:configure 之后再 setCallback,start 之前必须把回调挂上,否则会丢开头几帧。异步回调 Android 21 之后可用,别再写旧式循环去取 buffer。bitrate mode 从 API 19 就有,但部分低端设备对 CBR 支持不完整,configure 前用 EncoderCapabilities 查 isBitrateModeSupported,查不到就退回 VBR。编码回调里还有一个容易漏处理的 flag:
override fun onOutputBufferAvailable(
codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo
) {
val buffer = codec.getOutputBuffer(index) ?: return
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
// SPS/PPS 包,MP4 场景由 onOutputFormatChanged 的 format 携带,这里跳过
codec.releaseOutputBuffer(index, false)
return
}
// 将 buffer[0..info.size) 与 info.presentationTimeUs 交给封装层
codec.releaseOutputBuffer(index, false)
}
注意:configure 之前先过一遍 MediaCodecList,确认目标机型支持指定的 profile 和 bitrate mode;多数录制闪退发生在 configure 抛 IllegalArgumentException 这一步。
3.3 把 Surface 帧送进编码器:createInputSurface 的取舍
两种输入方式差异很大。用 createInputSurface 之后,编码器直接从 Surface 取纹理并转码,整个链路没有 YUV 到内存的拷贝,CameraX 的 Preview 可以把 Surface 直接交给编码器:
val inputSurface = encoder.createInputSurface()
previewView.surfaceProvider = surfaceRequest ->
surfaceRequest.provideSurface(inputSurface, executor) { result ->
// surfaceRequest 被替换或关闭时回调,此时释放 inputSurface
}
PTS 在这种模式下由 Surface 生产者决定。Camera2 的 CaptureResult.TIMESTAMP 与 System.nanoTime 同源,所以视频帧的呈现时间戳可以直接用系统单调时钟换算,不需要手动数帧。手动 YUV 输入留给必须做逐帧滤镜的场景,切片要自己按 rowStride 拷,PTS 要自己算。多数录像项目性能瓶颈不在编码器,而在 YUV 转换和 buffer 拷贝,Surface 输入能省掉这两项。createInputSurface 调用时机必须是 configure 之后、start 之前,拿到的 Surface 不能再做二次 setPreview 之类操作。
3.4 时间戳是一切依据:PTS 换算与 33.33ms 基准
固定帧率场景可以按帧序号算:
val presentationTimeUs = frameIndex * 1_000_000L / fps
但这严格要求每帧间隔恒定,真机上曝光调整、丢帧都会破坏它。更保险的是在采集回调里直接读单调时钟:
val presentationTimeUs = System.nanoTime() / 1000L
1 秒等于 1_000_000 微秒,30fps 的一帧基准就是 1_000_000L / 30 = 33_333 微秒,别用 33.3 这种浮点值做乘法,时间久了必然累积误差。音频与视频必须共享同一条时钟轴,视频用 nanoTime,音频就不能用 currentTimeMillis,否则系统改时间后两路永远对不齐。写入 MediaMuxer 的 presentationTimeUs 必须单调不减,出现回退先丢帧而不是硬写,入口处加一行保护:
var lastPtsUs = 0L
fun pushFrame(ptsUs: Long, ...) {
if (ptsUs <= lastPtsUs) return // 回退帧直接丢弃
lastPtsUs = ptsUs
}
4. 封装与播放:MediaMuxer 搭配 ExoPlayer 的闭环
4.1 MediaMuxer 写入 MP4 的操作顺序与轨道配置
MediaMuxer 只做一件事:把编码后的样本按容器格式写盘,顺序和状态约束比编码器更严格。常见流程是编码器第一次 onOutputFormatChanged 时拿到含 csd 的 MediaFormat,此时才能 addTrack 并 start;之后在 onOutputBufferAvailable 里逐包 writeSampleData;全部完成后先 stop 编码器,再 stop muxer,最后 release。
var muxer: MediaMuxer? = null
var videoTrack = -1
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
muxer = MediaMuxer(outputPath,
MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4).apply {
videoTrack = addTrack(format)
start()
}
}
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: BufferInfo) {
// 忽略 CODEC_CONFIG 包
muxer?.writeSampleData(videoTrack, codec.getOutputBuffer(index)!!, info)
codec.releaseOutputBuffer(index, false)
}
两点容易翻车:addTrack 要在 start 之前,所有轨道 add 完之后才调 start;MP4 的 moov 块在 stop 时落盘,如果进程在 stop 前被杀,文件缺 moov,大部分播放器会秒退或永远 loading。所以 stop muxer 的时机必须放在录制结束流程里,不能依赖 onDestroy。视频和音频各占一个回调线程时,writeSampleData 要做锁保护,或把样本集中到单一写线程,MediaMuxer 内部不做多线程写入保证。
| 现象 | 常见原因 | 处理 |
|---|---|---|
| IllegalStateException:Can't start | 有轨道没 addTrack 完 | 打印 track 索引,检查为 -1 的调用 |
| 播放器秒退 | moov 未完整写出 | 保证 stop 路径不中断;录制中崩溃要做文件恢复 |
| 音画不同步 | 两路 PTS 时钟源不一致 | 统一到单调时钟除以 1000 |
| 竖屏视频横着放 | 未设置视频旋转信息 | 在 videoFormat 里写 KEY_ROTATION |
4.2 AudioRecord 采集与 AAC 编码的同步配平
音频采集先用 AudioRecord 读 PCM,再送 AAC 编码器。AAC 输出每包固定 1024 个样本,所以音频时间戳可以直接按样本累计来算,不用依赖系统时钟:
val sampleRate = 48_000
val record = AudioRecord(
MediaRecorder.AudioSource.MIC, sampleRate,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
bufferSizeInBytes
)
var totalSamples = 0L
record.startRecording()
while (isRecording) {
val len = record.read(pcm, 0, pcm.size)
if (len <= 0) continue
val ptsUs = totalSamples * 1_000_000L / sampleRate
totalSamples += len / 2 // PCM16 单声道,一个样本 2 字节
// 将 pcm[0..len) 交给 AAC 编码器,同时传入 ptsUs
}
AudioRecord 的 read 返回时机和真实采集时刻之间存在缓冲延迟,所以音频 PTS 用样本累计法而不是 nanoTime,前者天然单调且与采样率精确对齐。视频如果走了 nanoTime,长时间录制后两边仍能对上,因为单调时钟不漂移。AAC 编码器配置用 MediaCodec.createEncoderByType("audio/mp4a-latm"),profile 设 AACObjectLC,输出 buffer 每包对应一帧 1024 个样本,写入 muxer 的 info 里同样带上按样本累计算出的 ptsUs。
提示:Android 9 起系统对并发录音会话有限制,调试时先确认没有其它应用占用麦克风,否则 AudioRecord.read 会持续返回 0 或抛出错误码。
4.3 用 ExoPlayer 做本地播放验收
val player = ExoPlayer.Builder(context).build()
player.setVideoSurfaceView(surfaceView)
player.setMediaItem(MediaItem.fromUri(Uri.fromFile(outputFile)))
player.prepare()
player.playWhenReady = false // 先出首帧再手动播,方便核对关键参数
playWhenReady 先设 false,是为了播放首帧后停顿,直接确认分辨率、旋转角和首帧画面是否正确。如果封装层 moov 或轨道配置有问题,这一句就会暴露。怀疑是文件封装异常时,不要用默认 MediaItem,改用 ProgressiveMediaSource 强制普通 extractor 路径:
val source = ProgressiveMediaSource.Factory(
DefaultExtractorsFactory()
).createMediaSource(MediaItem.fromUri(uri))
player.setMediaSource(source)
player.prepare()
ExoPlayer 对本地 MP4 的默认行为已经包含音画同步处理,播放器侧基本不需要调参数;出现不同步重点回查两路 PTS 时间轴,而不是在播放器里找开关。
5. 从花屏到绿屏:MediaCodec 现场排错的三个动作
5.1 先读 onError 和 CODEC_CONFIG 有没有被误处理
花屏问题先加编码器回调。onError 里先读 exception.isTransient 和 isRecoverable:transient 表示资源暂时被系统抢占,等一会可能自己恢复;不可恢复时再去拿 getDiagnosticInfo(),常见值是 0x80001005 这类硬件错误码,最直接的止损是释放编码器,降级软编重走。然后查 CODEC_CONFIG:编码器输出的头几个 buffer 带这个 flag,里面是 SPS/PPS。误把它当视频帧写进 muxer,播放首帧就是绿的;正确做法是 addTrack 用 onOutputFormatChanged 返回的 MediaFormat,回调里收到 CODEC_CONFIG 直接跳过。
5.2 查真机编解码器名单:c2 与 OMX 前缀
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos
.filter { !it.isEncoder &&
it.supportedTypes.any { t -> t == MediaFormat.MIMETYPE_VIDEO_AVC } }
.forEach {
Log.d("CodecProbe", it.name + " hw=" + it.isHardwareAccelerated +
" soft=" + it.isSoftwareOnly)
}
isHardwareAccelerated 需要 API 29,老设备看命名:OMX.google、c2.android 是软件实现;厂商硬解码器大多带 qcom、hisi、exynos、mtk 之类的名字。同一个 H.264 码流在不同机型被解析出来的 profile 可能不一样,先拉名单再对照第 3.2 节的 KEY_PROFILE 是否越界。
5.3 用 ffprobe 抽验 PTS 单调性
ffprobe -v error -show_entries packet=pts_time,flags -of csv=p=0 out.mp4 | head -30
每一行第一列是 pts_time,第二列出现 K 表示关键帧。预期增量接近 33ms,出现负值或回退,回到 3.4 节的入口保护排查;出现 66ms、99ms 的跳变,说明采集端实际掉帧了,不是编码器问题。回到你的测试机,先跑一遍 5.2 节的编码器名单,再对照第 3.2 节的参数表逐项核,排掉配置问题后再去怀疑系统调度,这个顺序能减少一半无效定位。
更多推荐

所有评论(0)