Android音视频开发:帧率、码率、YUV与MediaCodec核心概念实战
简介:面向Android音视频开发与处理场景的PDF学习资料,较为系统地覆盖了帧、帧率、码率、分辨率、颜色空间、I/P/B帧以及DTS/PTS等核心概念,可帮助正在入门或希望补基础原理的开发者建立知识框架。内容穿插大量FFmpeg实操脚本,包括视频抽帧、图片合成GIF、mp4转YUV、码率控制、关键帧间隔设置与截取等,并对比了CBR、VBR、CVBR、AVBR四种码率模式及适用场景,方便直接参考使用。资料进一步衔接Android平台,介绍了MediaCodec、AudioTrack、Surface等多媒体框架在编码、解码、播放与录制中的应用,同时提及FFmpeg的Android版本等开源工具,并解释了GOP间隔、DTS/PTS时间戳与解码显示顺序的关系,为后续项目落地提供指引。资源仅1个PDF文件,压缩包大小约6.39MB,便于PC端或移动端随时学习;目前已有1213人学习使用,适合音视频入门开发者、视频应用开发人员阅读参考。
1. Android音视频开发前,先把这些概念钉死
去年排查一个远程协助App的画面冻结问题,最初怀疑是网络抖动,最后定位到Android音视频开发里一个非常基础的坑:H.264 NALU中的SEI被当成SPS处理,解码器直接罢工。调了三天,翻回《Android音视频开发与处理》这份PDF资料,才发现自己连IDR帧和I帧的区别都没吃透。这份资料从视频帧、帧率、码率、分辨率这些底层概念入手,一路讲到YUV颜色空间、MP4容器结构和H.264码流细节,每个知识点都绑定了可直接运行的FFmpeg命令。对正用MediaCodec做硬编硬解、或者在Android上移植FFmpeg的开发者来说,它能直接用来对照排查花屏、音画不同步、GOP异常等问题;刚入门的话,按里面的命令跑一遍,也比只看API文档扎实得多。下面把这份资料里最值得落到工程实践的内容展开。
2. 帧率、码率、分辨率与IPB帧:压缩取舍的底层逻辑
2.1 帧率、分辨率与体积的守恒关系
2.1.1 FPS 25 是经验底线
视频由连续帧构成,一帧就是一张图像。人眼有视觉暂留效应,帧率低于25FPS时能明显感到画面中断;高于25FPS之后,流畅度提升的感知边际递减,但数据量仍然线性增长。动态帧率(Variable Frame Rate)能根据场景复杂度动态降帧,实际用得少,因为多数播放器和剪辑工具对VFR的时间戳处理并不友好。在Android设备上,摄像头采集通常固定30FPS或60FPS,编码器配置里也以固定帧率为主。
以1080P为例做一个计算:分辨率1920×1080,YUV420采样下每像素1.5字节,单帧约2.97MB;按30FPS计算,每秒原始数据89MB。如果换成RGB,数值直接翻倍。这也是为什么视频编码压缩看似可选、实则必须——不压缩的视频没法在网络上传输,也没法长时间存储。
2.1.2 压缩切的是哪几类冗余
编码压缩主要在五个维度上动手:
- 空间冗余 :相邻像素颜色相近,可用帧内预测去掉。
- 时间冗余 :相邻帧内容相似,用运动估计和帧间预测表达变化。
- 编码冗余 :像素值出现概率不同,用熵编码(CABAC/CAVLC)缩短高频符号。
- 视觉冗余 :人眼对亮度的敏感度高于色度,对高频细节不敏感,可以牺牲部分细节。
- 知识冗余 :结构化内容可用先验模型减少描述成本。
这五类冗余对应了 x264/x265 里大量参数选项。比如 bframes 控制B帧, me 控制运动估计方法, subme 控制亚像素精度。当你只调 -crf 或 -b:v 时,实际是在给这些工具设定一个“整体预算”,具体怎么分配由编码器内部决定。理解这一点,就不会再问“为什么分辨率相同,码率设置不同画质差距这么大”这类问题。
用FFmpeg抽取视频帧再合成GIF,可以直观感受帧率对体积的影响:
ffmpeg -i sample.mp4 -r 5 -ss 478 -t 3 img/image-%03d.jpg
ffmpeg -r 3 -i img/image-%03d.jpg 11.gif
第一条命令从 sample.mp4 的第478秒开始,持续3秒,按5FPS抽帧;第二条把抽出的图片按3FPS合成GIF。 -r 在首个命令里是输入抽样帧率,在第二个命令里是输出帧率。注意抽帧步长和GIF帧率不一致时,播放节奏会变快或变慢,这也是视频时间基准最容易出错的地方。
2.2 码率模式选错,视频要么糊要么卡
2.2.1 四种码率模式对照
码率是视频文件在单位时间内的数据流量。同样分辨率下,码率越高压缩比越小,画面质量越高。静止场景可以用低码率获得好画质,剧烈运动场景可能高码率也压不住。H.264编码器下常见四种模式:
| 模式 | 码率变化 | 侧重点 | 适用场景 |
|---|---|---|---|
| CBR | 恒定 | 带宽可控,网络传输优先 | 直播、视频会议 |
| VBR | 随内容复杂度变化 | 画质优先 | 文件存储、本地录像 |
| CVBR | 有最大/最小约束的动态码率 | 兼顾画质与带宽 | 设备支持时优先选择 |
| AVBR | 时间窗口内平均恒定,局部可超峰值 | 均值稳定,瞬时灵活 | 点播、云录制 |
实际工程里,CBR并不是纯恒定比特流,而是编码器尽量把每帧码字控制在目标码率附近。场景剧烈变化时,CBR会主动降低画质来维持码率,直观表现是快速运动画面出现马赛克;VBR因为允许码率上探,画面复杂时依然能保住纹理,但文件大小不可控。CVBR最接近“既要又要”,常见用法是设置一个目标码率上下区间,让编码器在区间内自由浮动。
2.2.2 FFmpeg 码率控制命令
ffmpeg -i input.mp4 -b:v 2000k -bufsize 2000k -maxrate 2500k output.mp4
-b:v 2000k 表示平均码率2000kbps; -bufsize 2000k 是编码器码率控制缓冲区; -maxrate 2500k 限制瞬时码率峰值。 minrate 通常和 maxrate 、 bufsize 一起使用才生效。在H.264编码器里,CBR还需要额外设置 -x264-params nal-hrd=cbr ,单纯加 -b:v 并不能得到严格恒定码率。做直播流推送时,推荐用CVBR方式,把 maxrate 设置为 b:v 的1.1到1.25倍,既能平滑网络抖动,又不会让画面质量崩得太厉害。
2.3 IPB帧与GOP:花屏、马赛克从哪来
2.3.1 IDR帧比I帧更强硬
I帧是帧内编码帧,解码只需本帧数据;P帧是前向预测帧,记录的是与前面I帧或P帧的差值;B帧是双向预测帧,需要前后帧联合叠加。 IDR帧一定是I帧,I帧不一定是IDR帧。 IDR帧会清空DPB参考帧列表,后续帧不能参考IDR之前的数据;普通I帧之后出现的P帧仍然可以引用IDR之前的参考帧。因此码流中只有遇到IDR帧才能真正从这一帧开始解码。
实测中常遇到的情况:把普通I帧当成关键帧丢给播放器,解码器可能会输出花屏,因为它还在用旧参考帧补齐画面。Android的MediaExtractor里, SAMPLE_FLAG_SYNC 指向的是同步帧,多数编码器实现里就是IDR帧,但不能假设所有厂商都这么做。
2.3.2 GOP长度决定错误恢复速度
GOP是两个I帧之间的距离,GOP越大,压缩效率越高,但丢包或损坏后需要等待下一个I帧才能恢复画面。设置关键帧间隔的FFmpeg命令:
ffmpeg -i sample.mp4 -t 10 -keyint_min 10 -g 10 -sc_threshold 0 out5.mp4
ffmpeg -i out5.mp4 -vf select='eq(pict_type\,I)' -vsync 2 -s 512*288 -f image2 img/core-%02d.jpeg
-g 10 把GOP大小设为10帧,即每10帧至少一个I帧; -keyint_min 10 限制最小关键帧间距,防止场景切换频繁插入关键帧导致码率暴增; -sc_threshold 0 关闭场景切换检测,保证关键帧间隔严格可控。第二条命令用 select='eq(pict_type\,I)' 筛选I帧,输出为512×288的JPEG。注意在FFmpeg 4.x以上版本,部分编码器默认会输出IDR帧,但 pict_type 返回值不区分普通I帧和IDR帧,所以这个筛选条件在排查GOP配置时只能作为参考。
2.3.3 PTS/DTS:B帧带来的时间戳乱序
没有B帧时,解码顺序和显示顺序一致;有B帧时,为了解码B帧必须先解码后面的P帧,导致解出来的一批缓冲里,显示时间靠前的帧在解码时间上靠后。如果不按PTS重排,播放画面会来回抖动。
用ffprobe查看帧级别的时间戳:
ffprobe -v error -select_streams v:0 -show_entries frame=pict_type,pkt_pts_time,best_effort_timestamp_time -of csv=p=0 out5.mp4
pkt_pts_time 是容器层写入的PTS, best_effort_timestamp_time 是解码器根据参考关系估计出来的显示时间。输出结果里如果出现 pict_type=B 且PTS序列不是单调递增,说明编码器开了B帧。Android MediaCodec 中常见做法是通过 KEY_LATENCY 或 KEY_MAX_B_FRAMES 控制B帧数量;对低延迟直播场景,通常直接编码Baseline Profile禁用B帧,换取稳定的时间戳关系。
3. 颜色空间与像素内存:YUV/RGB 与 Android Bitmap 的对应关系
3.1 为什么视频编码选中 YUV
RGB几乎能描述所有颜色,但对视频编码并不友好:三个分量的能量分布接近,任一分量都不能直接压缩。YUV把亮度(Y)和色度(U/V)分离,人眼对亮度变化敏感、对色度变化迟钝,于是可以对色度通道做降采样。这样原始数据量变为RGB的1/2甚至更少。
RGB转YUV(BT.601限制范围)的常用公式:
Y = (0.257 * R) + (0.504 * G) + (0.098 * B) + 16
Cr = V = (0.439 * R) - (0.368 * G) - (0.071 * B) + 128
Cb = U = -(0.148 * R) - (0.291 * G) + (0.439 * B) + 128
注意这是SDTV范围内的转换,不是全范围(Full Range)。Android相机输出的YUV大多是Full Range还是Video Range,取决于厂商实现,不能猜。硬编码里用 MediaCodec 的 ColorFormat 参数时,如果源数据range和编码器预期不一致,画面会出现发灰或黑色不纯的问题。
用FFmpeg把MP4转成原始YUV:
ffmpeg -i out.mp4 -pix_fmt yuv420p note2.yuv
note2.yuv 没有任何头和宽高信息,它就是连续YUV帧的拼接。后续用FFmpeg或YUV播放器处理时,必须手动指定分辨率、帧率和像素格式,否则读出来就是错位花屏。这在Android端从 ImageReader 拿硬件编码时是同一回事: Image.getPlanes() 返回的数据没有宽高标识,必须由调用方维护。
3.2 YUV420 采样与内存布局
YUV采样率决定色度分量如何共享。常见四种:
| 采样格式 | Y/UV关系 | 每4个Y对应的UV |
|---|---|---|
| 4:4:4 | 每个Y对应一组UV | 4组 |
| 4:2:2 | 每两个Y共用一组UV | 2组 |
| 4:2:0 | 每四个Y共用一组UV | 1组 |
| 4:1:1 | 每八个Y共用一组UV | 1组(水平方向只保留四分之一) |
4:2:0是H.264/H.265里最主流的采样格式。Android ImageReader 默认的 YUV_420_888 格式并不是某一种固定排布,它用三个plane分别暴露Y、U、V分量,U和V实际是否交错取决于设备驱动。因此不能假定拿到的是I420还是NV12。
3.2.1 YUV420 尺寸计算
width, height = 1920, 1080
y_size = width * height
uv_size = width * height // 4
u_offset = y_size
v_offset = y_size + uv_size
total_size = y_size + 2 * uv_size
print(f"Y plane size = {y_size}")
print(f"U offset = {u_offset}")
print(f"V offset = {v_offset}")
print(f"Frame total = {total_size}")
传给硬编码器或libyuv前,YUV420的buffer大小用 w*h*3/2 计算,Y平面占1080×1920字节,U、V各占四分之一。这个脚本对理解 ImageReader 的Plane更直观:在 YUV_420_888 中, plane[0] 是Y, plane[1] 是U, plane[2] 是V,但行间距 rowStride 和像素间距 pixelStride 可能带对齐填充,不能直接用 width*height 拿数据。
YUV420下常见内存排列:
- I420 :YYYY… UU… VV…
- YV12 :YYYY… VV… UU…
- NV12 :YYYY… UVUV…
- NV21 :YYYY… VUVU…
NV12和NV21属于YUV420SP格式,U和V交叉存储在同一个plane。Android相机YUV数据默认输出NV21的场景比较多,但MediaCodec硬编普遍接受NV12或YUV420P。如果直接把NV21喂给期望NV12的编码器,色度通道会红蓝互换,整幅画面偏色。
3.3 Android Bitmap 与像素内存的匹配
Android Bitmap.Config 有四个枚举: ALPHA_8 、 ARGB_4444 、 ARGB_8888 、 RGB_565 。它们的取舍点在于透明度和每像素内存:
| Config | 每像素位数 | 是否支持透明 | 实际建议 |
|---|---|---|---|
| ALPHA_8 | 8位 | 只有透明度 | 做mask用 |
| ARGB_4444 | 16位 | 支持 | 官方不推荐,画质差 |
| ARGB_8888 | 32位 | 支持 | 默认选择 |
| RGB_565 | 16位 | 不支持 | 无透明要求时省一半内存 |
RGB_565 在Android 8.0以后默认关闭了软加速下的高质量采样,直接 BitmapFactory 解码照片时如果用565,会出现明显色带。通常做法是:需要透明通道才用ARGB_8888,不需要透明就尽量用565,但前提是后续不进行颜色空间转换。对H.264裸流转Bitmap的流程,建议直接用 libyuv 把YUV420转换为RGBA,再封装为ARGB_8888,避免Bitamp内部再做一次无损转换。
注意颜色空间和硬件编码的关系:MediaCodec支持通过 Surface 输入,此时摄像头预览帧直接从SurfaceTexture进入编码器,不经过应用层YUV/RGB转换。这是Android音视频开发里最省电的链路,但代价是拿不到原始YUV数据做算法处理。如果要做人脸检测、画面裁剪、水印,就必须用 ImageReader 取帧,这时要正确处理 rowStride 和 pixelStride ,否则只是换了一副马赛克。
4. 封装格式与 H.264 码流:MP4/TS 容器和 NALU 解析实战
4.1 容器与编码格式分离
封装格式解决的是音视频码流怎么存放在文件里,编码格式解决的是图像和声音怎么压缩。两者独立,所以MP4里可以装H.264、H.265,也可以装MPEG-4;TS容器则常用于直播和广播。不同封装格式对流媒体的支持差异很大:
| 格式 | 流媒体支持 | 常用视频编码 | 常用音频编码 |
|---|---|---|---|
| AVI | 不支持 | 几乎所有 | 几乎所有 |
| MP4 | 支持 | MPEG-4、H.264、H.265 | AAC、MP3、AC-3 |
| TS | 支持 | MPEG-2、H.264 | AAC、MP3 |
| FLV | 支持 | Sorenson、VP6、H.264 | MP3、AAC |
| MKV | 支持 | 几乎所有 | 几乎所有 |
| RMVB | 支持 | RealVideo 8/9/10 | AAC、Cook Codec |
做Android点播App优先MP4,因为mp4的 moov box放索引,可以用 moov 位置实现快进;做直播推流用FLV或TS,因为这两种容器的头部固定,可以边生成边推送。如果直接把录好的MP4按直播流推给RTMP服务器,会遇到时间戳跳变、快进无法seek的问题,原因就是MP4的索引结构不是流式友好的。
4.2 mp4 转裸 H264,NALU 开始码差异
MP4里保存的H.264 NALU通常是长度前缀(length-prefixed)方式:每个NALU前有4字节大端长度。而H.264裸流(Annex-B)使用 00 00 01 或 00 00 00 01 作为开始码。两者不能混用。
ffmpeg -i h264.mp4 -c:v copy -bsf:v h264_mp4toannexb -an out.h264
-c:v copy 不重新编码,只做容器转换; -bsf:v h264_mp4toannexb 把MP4的AvcC格式转换为Annex-B格式; -an 丢弃音频流。这个命令在Android上做本地视频分割时非常常用:先用MediaExtractor抽取 csd-0 、 csd-1 (即SPS/PPS),再拼接 hm05 等配置转换为Annex-B流。
必须澄清一点:如果数据是从MP4的 mdat box里直接解析出来的,不能用开始码扫描的方式定位NALU,因为MP4内部没有开始码。这也是为什么转码工具里总有 h264_mp4toannexb 这个独立的bitstream filter。
4.3 MP4 box 与 NALU type 识别
MP4是一个box层级的容器, mdat 存媒体数据, moov 存索引, mvhd 记录时长和时间度量尺标, trak 描述音视频轨道, stsd 里保存解码需要的SPS/PPS。工程上检查MP4结构常用 mp4box -info file.mp4 ,浏览器里也可以用mp4box.js解析。这里给一个用Python扫描裸流NALU type的小工具:
def find_nalus(data):
start_codes = []
i = 0
while i < len(data):
if data[i:i+4] == b'\x00\x00\x00\x01':
start_codes.append((i, 4))
i += 4
elif data[i:i+3] == b'\x00\x00\x01':
start_codes.append((i, 3))
i += 3
else:
i += 1
for idx, (start, code_len) in enumerate(start_codes):
next_start = start_codes[idx + 1][0] if idx + 1 < len(start_codes) else len(data)
# 跳过开始码后的NALU头部字节
header_index = start + code_len
if header_index >= len(data):
continue
nal_type = data[header_index] & 0x1F
print(f"NALU {idx:4d} offset=0x{start:08x} type={nal_type}")
这段脚本按开始码定位NALU,再取开始码后第一个字节的低5位作为 nal_unit_type 。配合H.264规范,type 1是非IDR图像编码条带,5是IDR图像编码条带,6是SEI,7是SPS,8是PPS,9是访问单元分隔符。常见字节值: 0x67 & 0x1F = 7 是SPS, 0x68 & 0x1F = 8 是PPS, 0x65 & 0x1F = 5 是IDR帧。如果你的码流第一个有效NALU是IDR,说明GOP设置生效;如果出现type 1但没有前置SPS/PPS,解码器必然初始化失败。
4.4 从 SPS 计算分辨率,验证关键帧
H.264 SPS里保存了视频的分辨率、宽高比、编码等级等信息。SPS字段需要解析Exp-Golomb编码,直接用ffprobe可以省事:
ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 out.h264
但了解SPS内部计算方式,对排查“播放器显示黑屏但编码没有报错”的问题有帮助。核心关系:
width = ((pic_width_in_mbs_minus1 + 1) * 16) - crop_left*2 - crop_right*2
height = ((2 - frame_mbs_only_flag) * (pic_height_in_map_units_minus1 + 1) * 16) - crop_top*2 - crop_bottom*2
pic_width_in_mbs_minus1 是宏块数减1,宏块尺寸16像素; crop_left/right/top/bottom 是裁剪偏移,单位是2像素,所以乘2。很多硬编解码器初始化时需要手动给SPS/PPS,如果你把MP4文件直接改后缀为H264推给播放器,大概率因为缺少SPS/PPS而无法解码。正确做法是保证每个GOP起始IDR帧前携带SPS/PPS,PPS变化时也必须重新注入。
排查关键帧间隔是否生效,可以扫描NALU type为5的频率。两次IDR之间的帧数如果大于 -g 设置值,说明编码器因场景切换或多线程配置插入了额外关键帧;如果小于 -g ,则说明另有逻辑在强制刷帧。这个检查同样适用于Android MediaCodec的输出,只是Android层 BUFFER_FLAG_KEY_FRAME 标记的是同步帧,并不保证SPS/PPS已包含在同一个输出包里。
5. MediaCodec 编码参数:把码率与关键帧理论做到 Android 上
5.1 在 MediaFormat 中映射码率模式
MediaCodec 的 MediaFormat 提供了与编码器参数一对一的能力。以H.264编码为例,在 Android Studio 里配置一个兼顾画质和码率稳定性的参数集合:
val format = MediaFormat.createVideoFormat(
MediaFormat.MIMETYPE_VIDEO_AVC,
width,
height
)
format.setInteger(
MediaFormat.KEY_BIT_RATE,
2_000_000
)
format.setInteger(
MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR
)
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
format.setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
KEY_BITRATE_MODE 对应上文提到的码率模式。 BITRATE_MODE_VBR 在多数Android厂商编码器上都支持,适合录屏;实时直播场景建议用 BITRATE_MODE_CBR ,避免瞬时码率波动导致上行丢包。 KEY_I_FRAME_INTERVAL 单位是秒,但不同芯片对这个参数的解释有差异:高通倾向于严格按时触发IDR,部分联发科机型会延迟到场景切换后。如果业务要求固定关键帧间隔,最好在编码输出侧统计IDR帧间隔做兜底。
COLOR_FormatSurface 让编码器直接消费Surface内容,省去YUV手动拷贝。这里还有一个隐藏好处:Surface输入模式下 MediaCodec 会自动处理颜色空间转换,你不需要关心 ImageReader 的 pixelStride 。
5.2 用 BufferInfo 验证关键帧与瞬时码率
编码器输出端,从 dequeueOutputBuffer 拿到 bufferInfo 后,三个字段直接与理论对应:
val size = bufferInfo.size
val pts = bufferInfo.presentationTimeUs
val isKeyFrame = bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
用相邻帧PTS差值和字节数,可以算出瞬时码率:
val deltaSec = (pts - lastPts) / 1_000_000.0
val instantBps = (size * 8 / deltaSec).toLong()
size 是编码后码流字节数, pts 是显示时间戳。注意编码器输出顺序不一定是PTS顺序,一旦编码器开启B帧, dequeueOutputBuffer 返回的buffer可能带乱序时间戳,必须在muxer侧按 pts 做排队。实际项目里我习惯在编码输出循环里只计算码率、不直接写buffer;发现瞬时码率超过 maxrate 阈值,就回调给码率控制模块做动态调整。
最后一个可复现的验证方法:把编码输出的第一帧buffer直接dump成文件,检查它是否为IDR帧,同时确认SPS/PPS是否出现在前几个字节里。如果首帧不是IDR,播放器必须等第二个GOP才能出画面,这往往是“黑屏一秒”的真正原因。用上述 bufferInfo.flags 判断关键帧,远比从展示画面上猜测可靠——这也是这篇音视频基础理论最值得回扣到Android工程的一处实践。
更多推荐
所有评论(0)