
视频解码这件事只要落到 Android 平台上MediaCodec 就是绕不过去的一道关。我这次要做的需求很朴素把一个本地视频文件解出原始 YUV 数据一帧一帧地写到 SD 卡上后面拿去做图像算法验证、模型训练样本采集或者干脆就是排查解码链路上的花屏问题。标题里这几个关键词——MediaCodec、YUV、逐帧保存——单拎出来都不复杂但真正串成一条能稳定跑完整个视频、不丢帧、不花屏、不把手机存爆的流水线坑比想象中多得多。这篇内容适合三类人看刚接触 Android 音视频、想知道 MediaCodec 到底怎么用起来的新手已经能解出画面、但被 stride、sliceHeight、NV21 这些名词绕晕的中级开发者以及需要把 YUV 数据落到磁盘做后续处理、但被 IO 速度卡住的工程实践者。我会把整条链路从选型到落盘拆开讲代码给到能直接跑的粒度重点放在那些文档里不写、只有真机跑过才知道的地方。1. 先想清楚整条链路的形状1.1 这个方案到底在解决什么问题很多人第一次听到把视频解成 YUV 存到 SD 卡第一反应是为什么不直接存成图片答案在于后续用途。PNG、JPEG 这类格式是有损或经过色彩空间转换的RGB 三通道每像素 3 字节而 YUV420 每像素平均只有 1.5 字节数据量直接砍掉一半同时保留了亮度全分辨率信息。做画质分析、做色彩校正验证、做视频超分的训练样本YUV 才是没被加工过的原始素材。代价也很直接一个 1080p、30 秒、30fps 的视频一共 900 帧每帧 1920×1080×1.5 ≈ 3.1MB合计接近 2.8GB。这个体量决定了整个方案的设计重心不只是能不能解出来而是怎么解得不卡、写得下、找得到。所以整条链路必须同时考虑解码吞吐、内存占用、磁盘写入三件事任何一环掉链子最终结果就是丢帧或者文件损坏。我的设计原则是先保证正确性再谈速度先让每一帧的像素排布绝对正确再考虑批量写入、缓冲复用这些优化。反过来做很容易在花屏的状态下调试 IO最后两头都搞不定。1.2 解码器的两种驱动模式怎么选MediaCodec 从 API 21 开始提供同步和异步两套接口。同步模式靠dequeueInputBuffer和dequeueOutputBuffer主动轮询代码写起来是线性的 while 循环调试友好逻辑一眼能看懂异步模式靠setCallback回调省掉了轮询空转理论上延迟更低但回调在内部 Handler 线程上执行一旦你在回调里做重活比如写 3MB 的文件会直接把解码器堵死。我这次选同步模式。理由很实际这个需求是离线批处理不是实时播放对延迟完全不敏感而同步循环天然支持我处理完一帧再取下一帧的节奏写文件慢的时候自动形成背压不需要额外做流控。异步模式更适合播放器那种要跟渲染时钟对齐的场景用在批量导出上属于给自己找麻烦。有一点要提醒同步模式下所有dequeue调用都必须带超时不能传 -1 死等。我一般输入用 10000 微秒输出用 10000 微秒超时后检查一下是否已经收到 EOS没收到就继续循环。传 -1 在某些编码异常的文件上会永久阻塞日志里什么都看不到非常难查。1.3 模块拆分与线程模型整个流程我拆成四块解封装MediaExtractor 负责抽轨道、读压缩包、解码MediaCodec 吐 YUV、格式归一把厂商给的千奇百怪的排布统一成 I420、落盘写文件 写元信息。前三块跑在同一个工作线程里第四块如果直接同步写会明显拖慢解码节奏所以我做了一级缓冲队列。队列长度我实测下来取 8 到 16 帧比较合适。太小比如 2起不到削峰作用磁盘偶发卡顿照样传导到解码太大比如 64内存直接吃掉 200MB低端机上很容易被系统干掉。8 帧对 1080p 来说是 25MB 左右是个比较安全的平衡点。队列满的时候解码线程阻塞等待这就是天然的背压比自己去算速率简单得多。线程优先级也值得设一下。解码线程用Process.THREAD_PRIORITY_DISPLAY或者普通后台优先级都行但写文件线程建议用THREAD_PRIORITY_BACKGROUND避免它跟解码抢 CPU。这个细节在小文件上看不出差别跑 4K 素材的时候能明显减少超时次数。2. YUV 不是一种格式是一族格式2.1 Planar 与 SemiPlanar 的排布差异这是最容易出问题的地方。很多人脑子里只有一个YUV的概念实际上 Android 解码器在不同芯片上吐出来的东西完全不一样。最常见的三种I420YUV420Planar先存完整的 Y 平面然后是完整的 U 平面再是完整的 V 平面。Y 有 width×height 个字节U 和 V 各有 (width/2)×(height/2) 个字节。这种排布最规整也最适合做算法输入。NV12YUV420SemiPlanar先存 Y 平面然后 UV 交错存储顺序是 U、V、U、V…… 这种格式在移动端 GPU 上特别友好所以大量硬件解码器默认就吐这个。NV21跟 NV12 一样是交错但顺序反过来先是 V 后是 U。这个在相机预览里很常见解码器输出里相对少一些但不是没有。如果你拿 NV12 的数据按 I420 去解析得到的画面会是彩色的、横向拉丝的鬼影而 Y 通道灰度看起来完全正常。这个现象很有辨识度一旦你看到灰度对、颜色错八成就是平面顺序搞反了。2.2 stride 和 sliceHeight最容易翻车的地方这两个参数是硬件解码器留下的对齐填充。显卡处理图像时为了内存访问效率会要求每行字节数是 16 或 32 的倍数视频高度也可能被补齐到 16 的倍数。于是你拿到的缓冲区里一行有效像素后面可能跟着几十个无用字节图像底部也可能多出几行垃圾数据。具体来说rowStride是每行实际占用的字节数含填充sliceHeight是实际占用的行数含填充。一帧 1920×1080 的 NV12rowStride可能是 1920也可能是 2048高度可能是 1080也可能是 1088。如果你直接用width × height去算偏移画面就会出现整体斜切——每一行都往右偏了几个像素越往下偏得越多形成一条条斜纹。我踩过的最典型的一次某台设备上解 1080pY 平面rowStride是 1920 没问题但 UV 平面的rowStride报的是 1920 而不是 960导致我按 960 去跳行颜色通道整体错位画面出现大面积紫绿色块。后来才明白UV 平面的rowStride是整行字节数包含 U 和 V 两个通道所以它天然就是 Y 平面的一半再乘 2等于跟 Y 平面一样宽。2.3 统一归一到 I420不管解码器吐什么我都在内存里先转成标准 I420 再落盘。这样做有两个好处文件格式统一后续读取的代码只有一套转换过程本身就是一次校验能第一时间发现 stride 计算错误。转换的关键是逐行拷贝绝不整块memcpy。伪代码逻辑是这样的对 Y 平面从第 0 行到第 height-1 行每行从源缓冲区偏移row × rowStride处拷width个字节到目标对 UV 平面情况稍微复杂如果源是 NV12 且pixelStride 1说明 UV 是紧凑排列的可以按行拷width个字节再拆如果pixelStride 2说明 U 和 V 是交错的必须逐字节挑出来分别放到两个平面。从 API 21 开始MediaCodec.getOutputImage()会返回一个Image对象每个 plane 自带getRowStride()和getPixelStride()这比自己猜格式可靠得多。我现在的做法是优先走getImage()拿不到部分老设备会返回 null再退回到ByteBuffer手动解析KEY_STRIDE和KEY_SLICE_HEIGHT。3. 初始化解码器参数怎么填才不花屏3.1 用 MediaExtractor 拿到靠谱的 MediaFormat永远不要自己拼 MediaFormat。视频文件的容器里有 SPS、PPS、时长、帧率、色彩标准这些信息MediaExtractor.getTrackFormat()会把它们解析好塞进去你只需要把 mime 换一下就行。流程是setDataSource(path)打开文件遍历getTrackCount()对每个轨道取getTrackFormat(i)判断KEY_MIME是不是以video/开头找到就selectTrack(i)并把这个 format 缓存下来。这一步看起来平淡无奇但有个细节getTrackFormat返回的对象里KEY_MIME是video/avc这种编码格式而解码器要的就是这个值直接传给createDecoderByType即可不需要做任何映射。有些视频有多条视频轨比如带封面图的要选码率最高或者分辨率最大的那条否则你可能解出来一帧封面就结束了。我加了一个判断优先选KEY_WIDTH × KEY_HEIGHT最大的轨道。3.2 选对 ColorFormat配置解码器时KEY_COLOR_FORMAT怎么填很多教程直接写COLOR_FormatYUV420Flexible这个值0x7F420888是个通配符意思是你爱吐什么吐什么我自己适配。这在 API 21 以上是推荐做法因为getOutputImage()能告诉你真实排布。但我建议做一层兼容先用 Flexible 配置configure之后调getOutputFormat()看实际拿到的KEY_COLOR_FORMAT是什么把它打印出来。我手上几台设备实测有的吐 21SemiPlanar有的吐 19Planar还有的干脆吐 0x7F420888 然后在getImage()里才暴露真实布局。日志里记下这个值后面出问题第一时间能判断方向。如果目标设备是 API 21 以下那就没有getImage()可用必须显式指定 19 或 21并且自己按 stride 解析。这种情况下我一般写一个ColorFormatAdapter把各种情况统一收敛到 I420 输出业务代码完全不感知。3.3 configure / start 的细节configure(format, null, null, 0)这四个参数第二个是 Surface我们不要渲染传 null第三个是 Crypto不加密传 null第四个 flags0 表示普通解码如果需要配合 Secure 播放才用CONFIGURE_FLAG_DECODE_ONLY之类的值。start()之后getInputBuffers()和getOutputBuffers()这两个方法在 API 21 之后其实已经废弃了应该用带 index 的getInputBuffer(index)。老代码里那种一次性拿到 buffer 数组的写法在新版本上虽然还能跑但已经不是推荐路径尤其配合getOutputImage()时容易拿到过期引用。启动后第一件事是取一次getOutputFormat()打印KEY_WIDTH、KEY_HEIGHT、KEY_STRIDE、KEY_SLICE_HEIGHT、KEY_COLOR_FORMAT。这四个值决定了后面所有的偏移计算值得单独打一行日志。注意getOutputFormat()在start()之后、第一帧输出之前调用拿到的可能是输入格式的副本真正准确的输出格式要在收到第一个INFO_OUTPUT_FORMAT_CHANGED之后取。所以我习惯在循环里监听这个事件收到就重新读一次参数并更新本地变量。4. 逐帧解码与落盘实现4.1 主循环骨架同步模式的循环结构大致是这样先尝试取一个输入 buffer取到就喂数据再尝试取输出 buffer取到就处理。两个动作放在一个 while 里任何一边返回TRY_AGAIN_LATER都继续下一轮。val info MediaCodec.BufferInfo() var inputDone false var outputDone false while (!outputDone) { if (!inputDone) { val inIndex decoder.dequeueInputBuffer(10_000L) if (inIndex 0) { val inBuf decoder.getInputBuffer(inIndex)!! val size extractor.readSampleData(inBuf, 0) if (size 0) { decoder.queueInputBuffer( inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM ) inputDone true } else { val pts extractor.sampleTime decoder.queueInputBuffer(inIndex, 0, size, pts, 0) extractor.advance() } } } val outIndex decoder.dequeueOutputBuffer(info, 10_000L) when { outIndex 0 - { if (info.size 0) { handleFrame(decoder, outIndex, info) } decoder.releaseOutputBuffer(outIndex, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM ! 0) { outputDone true } } outIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED - { refreshOutputFormat(decoder.outputFormat) } } }这段代码里有几个必须做对的地方。releaseOutputBuffer的第二个参数是render我们不解到 Surface必须传 false传 true 在某些设备上会触发渲染路径直接崩给你看。info.size 0这个判断也不能省INFO_OUTPUT_FORMAT_CHANGED之后紧接着可能出现 size 为 0 的空帧拿去写文件就是空文件。BUFFER_FLAG_CODEC_CONFIG这个标志也要过滤掉。部分编码器会把 SPS、PPS 当作一帧吐出来info.size不为 0但内容根本不是图像写进去文件长度就乱了。判断方式是info.flags and BUFFER_FLAG_CODEC_CONFIG ! 0就跳过。4.2 从 Image 中抠出有效像素拿到Image之后真正的体力活在这里。以最常见的 NV12 为例Y 平面pixelStride 1rowStride可能是 1920 或 2048UV 平面pixelStride 2rowStride通常等于 Y 的 rowStride。private fun imageToI420(image: Image, out: ByteArray, width: Int, height: Int) { val yPlane image.planes[0] val uPlane image.planes[1] val vPlane image.planes[2] val yBuf yPlane.buffer val yRowStride yPlane.rowStride var pos 0 for (row in 0 until height) { yBuf.position(row * yRowStride) yBuf.get(out, pos, width) pos width } val uBuf uPlane.buffer val vBuf vPlane.buffer val uRowStride uPlane.rowStride val uPixelStride uPlane.pixelStride val vRowStride vPlane.rowStride val vPixelStride vPlane.pixelStride val chromaHeight height / 2 val chromaWidth width / 2 for (row in 0 until chromaHeight) { for (col in 0 until chromaWidth) { out[pos] uBuf.get(row * uRowStride col * uPixelStride) } } for (row in 0 until chromaHeight) { for (col in 0 until chromaWidth) { out[pos] vBuf.get(row * vRowStride col * vPixelStride) } } }这段代码的价值在于它同时兼容了 I420 和 NV12如果pixelStride是 1col * 1就是顺序取如果是 2就是隔一个取一个。rowStride负责处理填充。两个参数一组合四种常见组合全都能覆盖。性能上逐字节get确实慢1080p 一帧大概要 5 到 10 毫秒。优化手段是把buffer转成ByteBuffer后用批量get(byteArray)拷整行到临时数组再从临时数组里按步长取。我实测这么改能把耗时压到 2 毫秒以内。不过第一版别急着优化先跑通再提速。提示image.planes返回的ByteBuffer位置是共享的循环里反复position()会互相影响。要么每次重新rewind()要么直接算绝对偏移用get(index)后者更安全。4.3 写文件的正确姿势每帧往 SD 卡写 3MB写法不对就是灾难。最朴素的做法是每帧FileOutputStream.write然后close我试过1080p 跑下来平均每帧多花 30 毫秒在打开关闭文件上直接把解码速度拖到个位数帧率。正确的做法是整个视频只开一个RandomAccessFile或者FileChannel所有帧顺序追加最后统一关闭。如果按帧分文件比如frame_000001.yuv那就要用BufferedOutputStream包一层缓冲区开到 256KB 以上把系统调用次数降下来。按帧分文件还是合成一个大文件我的建议是两个都留。合成大文件方便后续用dd或者脚本按固定偏移切帧读取时不用管文件系统开销按帧分文件方便肉眼抽查某几帧尤其是调试花屏问题的时候直接拿单帧去工具里看比在大文件里算偏移快得多。我的做法是默认写大文件同时加一个开关调试时切成分帧模式。文件命名不要用时间戳做唯一标识视频多的时候根本分不清。我用视频名 分辨率 帧序号的组合帧序号补零到 6 位这样文件管理器里排序就是正确的帧顺序。5. 性能、内存与存储的平衡5.1 一帧三兆别小看 IO算笔账1080p 30fps每帧 3.1MB一秒就是 93MB一分钟 5.6GB。普通 UFS 存储的持续写入速度在 300MB/s 以上理论够用但那是理想状态。实际写入还受文件系统、缓存刷盘策略、后台其他应用影响尤其是 Android 有写入限流持续大流量写会触发内核的 dirty page 回写出现周期性卡顿。应对办法是控制队列水位加限速。我在写线程里统计最近一秒的实际写入字节数超过 150MB/s 就主动sleep几毫秒。听起来有点土但比让内核突然卡你 500 毫秒要平滑得多。另外每写入 64 帧做一次flush避免缓冲区攒太多进程被杀时丢一大片数据。还有一个容易忽略的点存储权限和分区。Android 10 之后的分区存储限制下往外部存储根目录随便建文件夹已经不行了要么用应用专属目录getExternalFilesDir要么走 SAF 让用户选目录。应用专属目录虽然路径难看但不需要任何权限申请卸载时自动清理做数据采集场景其实更合适。5.2 缓冲区复用每帧ByteArray(width * height * 3 / 2)新建一个1080p 就是 3MB30fps 下每秒分配 90MBGC 会被打爆。必须复用在解码线程里预分配两个缓冲区一个给imageToI420写一个在队列里等着被写线程消费用完之后还回池子。我实现的是一个容量为队列长度的小型缓冲池acquire()从池里取release()还回去。池里没有可用缓冲时阻塞等待。这样整条链路的内存占用是固定的跟视频长度完全无关。顺便说一句MediaCodec的输出 buffer 本身也是复用机制的一部分你在releaseOutputBuffer之前必须把数据拷走因为它随时会被解码器回收。这个坑我见过不少人踩把image.planes[0].buffer直接塞进队列回头再读就是垃圾数据。5.3 采样与裁剪策略不是所有场景都需要全帧全分辨率。如果下游只是做算法验证完全可以支持三个参数隔帧采样比如每 3 帧取 1 帧、区域裁剪只取 ROI 区域、降采样宽高各减半。这三个开关加上去存储需求能降一个数量级。隔帧采样的实现要注意不是简单地跳过handleFrame而是照常解码、照常释放 buffer只是不写文件。跳过解码是不行的因为 P 帧依赖前面的帧你跳着解会直接花屏。这一点必须写清楚我见过有人为了省 CPU 在queueInputBuffer那一步跳数据结果整个视频后半段全是噪点。区域裁剪相对麻烦因为 YUV420 的色度平面是 2×2 下采样的裁剪坐标必须是偶数否则色度会错位。我的做法是强制把裁剪矩形的 left、top、width、height 全部对齐到 2 的倍数宁可少裁两个像素也不引入色度偏移。6. 排查实录那些年踩过的坑6.1 花屏、绿边与斜条纹斜条纹基本可以锁定为 stride 没处理。特征是画面整体向右倾斜越靠下偏移越大每条纹路的间距等于 (rowStride - width)。解决方法是老老实实按 rowStride 跳行。底部绿边或灰边通常是 sliceHeight 大于 height。解码器分配的缓冲区高度是 1088你按 1080 去读最后 8 行读到的其实是缓冲区里的残留数据颜色发绿或者发灰。写文件时按 height 严格截断就行但如果文件长度按 sliceHeight 算就会多出一截垃圾。大面积紫绿色块多半是 UV 平面顺序搞反NV12 当 NV21 用了。验证方法是只看 Y 通道转灰度如果灰度正常而颜色错乱就是这个原因。快速修复可以试着把 U、V 两个平面的读取顺序对调几十秒就能确认。画面上下颠倒这个在直接用 Surface 渲染的场景里常见但我们走的是 buffer 路径一般不会遇到。真遇到了检查一下是不是在configure里传了CONFIGURE_FLAG_ENCODE之类的错误 flag。6.2 卡死与超时dequeue一直返回TRY_AGAIN_LATER然后循环空转日志刷屏但进度不动。这种情况优先查两件事输入是不是已经喂完了EOS 发出去了输出是不是已经收到 EOS 了。如果输入 EOS 已发输出没动静很可能是解码器内部还有数据没吐完继续等就行但要加一个最大循环次数保护比如连续 500 次超时就抛异常退出避免死循环。还有一种更隐蔽的文件本身编码异常或者中途截断解码器吐出一帧之后再也不吐了。这时候dequeueOutputBuffer会一直超时但也不报错。我的做法是记录最后成功输出的 PTS如果超过 5 秒没有任何新帧就认为异常主动stop并release把已经写出去的数据保留下来。configure阶段抛IllegalStateException一般是 format 里缺少关键字段比如KEY_WIDTH为 0或者 mime 跟解码器不支持。这种情况先打印完整的 format 内容逐个字段核对。6.3 帧数对不上解出来的帧数比预期少最常见的原因是漏了BUFFER_FLAG_CODEC_CONFIG的过滤或者把info.size 0的帧也算了进去。反过来帧数比预期多通常是 EOS 之后还多处理了一次空帧。还有一种情况是可变帧率视频。你以为 30 秒 × 30fps 900 帧实际文件里可能是 870 帧因为编码时做了帧率控制。这个不要用时长去推算直接extractor.getTrackFormat().getInteger(MediaFormat.KEY_FRAME_RATE)拿标称值也未必准。最可靠的办法是解完之后统计实际写出的帧数把它写进元信息文件。6.4 常见问题速查表现象最可能原因排查动作画面斜切、条纹rowStride 未处理打印 stride按行偏移读取颜色紫绿、灰度正常UV 顺序反了对调 U/V 读取顺序验证底部绿边sliceHeight 大于 height写文件时按 height 截断帧数为 0未选对轨道或未 advance检查 selectTrack 和 readSampleData 返回值进度卡住不动未处理 EOS 或输出超时加循环保护打印最后 PTS文件大小异常写入了 codec config 帧过滤 BUFFER_FLAG_CODEC_CONFIG内存持续上涨每帧新建 ByteArray改用缓冲池复用写入速度越来越慢未 flush、缓冲区积压定期 flush控制队列水位7. 一些工程化收尾的做法7.1 元信息必须落盘原始 YUV 文件没有文件头你不写元信息三天后自己都不知道这个out_1920x1080.yuv的色度平面是 I420 还是 NV12。我强制要求同目录下生成一个文本文件记录宽高、色彩格式、帧数、每帧字节数、源文件路径、解码耗时、使用的解码器名称。解码器名称这个字段特别有用。MediaCodec.getName()返回的是底层实现名比如带硬件厂商前缀的那种。某些机型上花屏把名字记下来一对比就能发现是特定实现的 bug后续可以针对性地加白名单或者换软解兜底。7.2 校验与回放写完不要直接信任结果。我加了一个轻量校验读出文件长度跟帧数 × 每帧字节数比对不相等就有问题。再进一步从第 0 帧和第 N/2 帧各取一段出来用工具转成 PNG 看一眼能发现大部分排布错误。回放工具我一般用两种一是写个简单的 Java 程序把 I420 转 RGB 存成 PNG纯代码可控二是直接扔进通用播放器用-f rawvideo -pix_fmt yuv420p -s 1920x1080这类参数喂进去。前一种适合自动化验证后一种适合肉眼快速确认。如果你后续要做批量处理建议在文件命名里带上色彩格式标识比如xxx_i420_frame000001.yuv避免不同来源的数据混在一起之后再靠人工回忆区分。我个人在多次实践中的体会是这套流程里 90% 的 bug 都出在格式假设上——假设了 rowStride 等于 width假设了 UV 是交错还是平面假设了 sliceHeight 等于 height。凡是涉及像素排布的地方全部从Image或者MediaFormat里读出来一个都不要猜调试时间能省掉一大半。真机上第一次跑通之后建议立刻在低端机和高端机上各跑一遍同一个文件对比输出的 MD5不一致就说明还有设备相关的问题没处理干净。