1. 先把需求拆开这件事的难点在哪里“Android 上用 MediaCodec 解码视频把每一帧 YUV 数据取出来一帧一帧写到 SD 卡上”——这句话我第一次听到的时候也有过“不就是读帧、解码、写文件”的想法直到真正动手才发现它涉及三个彼此独立的难点解码器的输出格式由设备决定你无法指定、YUV 的内存排布里藏着对齐和跨距、每秒几十兆的写入量会超过手机闪存的承受能力。这三条只要有一条没处理好最终结果就会从“跑通了”变成“存了一堆打不开的文件”。这篇内容要解决的就是这三件事。MediaCodec 是 Android 系统自带的硬解码接口它能借助设备的解码芯片把 MP4、MKV 等容器里的 H.264 / H.265 压缩流还原成一帧帧原始画面而 YUV 是原始画面最紧凑的表达形式之一YUV420 一个像素平均只占 1.5 字节比 RGB 省一半。把解码出来的 YUV 帧按固定规则写进存储卡是做算法验证、做图像处理 Demo、做数据集采集、做播放器排障时非常常见的一步。这篇内容适合谁看已经会用 MediaCodec 播个视频、但一到“把裸帧拿出来”就卡住的 Android 开发者需要给视觉算法准备离线测试素材的算法同学以及手上有设备、想自己动手验证解码结果对不对的技术爱好者。下面我按照“思路选型 → 底层认知 → 工程骨架 → 完整实现 → 排错 → 优化”的顺序把整套流程讲清楚代码可以直接拿去改。2. 为什么这样设计解码方案和保存路径的取舍2.1 三个真实会遇到的场景决定了方案细节第一个场景是算法离线验证。你在手机上跑一个人脸或者滤镜算法想确认输入到底长什么样最直接的办法是把每一帧 YUV 存下来拷到电脑上用工具打开看。这时候对速度的要求不高对“数据完全正确”的要求极高因为你要拿它当基准。第二个场景是视频内容采集。比如做某种动作识别的样本收集需要把一段时间内解码出来的帧按顺序落盘再打包上传。这时候单个文件的体积和总帧数就变成主要矛盾1080p 无差别全存几分钟就上 GB。第三个场景是播放器或者解码器排障。用户反馈某个视频在某个机型上花屏你需要把出问题的那几帧原样抠出来看是解码器输出的问题还是后面渲染的问题。这时候你要的是“精确到帧号”的定位能力。这三个场景的共同点是中间产物必须落成文件不能只停留在内存里。所以整条链路的设计目标就是四个字——准确、可控。准确指每一帧的宽高、跨距、分量顺序都不能错可控指帧率、帧数、目录、体积要能干预。2.2 选择系统 MediaCodec而不是自己编译一套软解有人会问为什么不用 ffmpeg 那一套软解我的答案很直接在 Android 上硬解是成本最低的路。系统 MediaCodec 背后接的是设备厂商提供的解码器功耗低、速度快1080p 甚至 4K 的实时解码基本不占 CPU。而软解方案要么带一个几十兆的 so 库要么自己处理编译和 ABI 适配为了“存几帧 YUV”付出这个代价不划算。但选 MediaCodec 也有代价必须先讲明白它的输出格式不由你决定。你可以在 configure 的时候传一个KEY_COLOR_FORMAT进去但绝大多数解码器会忽略它最终给你什么格式要等INFO_OUTPUT_FORMAT_CHANGED之后看outputFormat里写的值。这就是很多人第一次写这段代码时“明明按 I420 的规则拆了出来却是花屏”的根本原因——你以为的格式和解码器实际给你的格式不是一回事。所以整套方案的核心设计原则就是不预设格式先问解码器要格式再按格式分派处理逻辑。这个原则后面会贯穿所有代码。2.3 YUV 裸帧为什么值得单独存以及它的体积代价YUV 相比 RGB 的优势在于亮度与色度分离。人眼对亮度敏感、对色度不敏感所以色度分量可以做下采样。YUV420 就是水平、垂直各减半于是 U 和 V 各只有 Y 的四分之一大小总大小 W×H W×H/4 W×H/4 W×H×1.5。代价也很直白——体积大得离谱。这里先把这笔账算清楚因为很多人是写完代码跑起来才发现存储不够分辨率单帧 YUV420 体积30fps 每秒写入10 秒总占用640×360345,600 B约 337 KB约 10.4 MB约 104 MB1280×7201,382,400 B约 1.32 MB约 41.5 MB约 415 MB1920×10803,110,400 B约 2.97 MB约 93.3 MB约 933 MB3840×216012,441,600 B约 11.9 MB约 373 MB约 3.7 GB计算方式很简单W × H × 3 / 2。1080p 就是 1920 × 1080 × 1.5 3,110,400 字节。再乘以帧率就是每秒写入量。看到这张表你就明白了1080p 30fps 全量存 YUV写入速度接近 90 MB/s这已经逼近不少中端机闪存的持续写入上限。所以后面在实现里我一定会给你留几个“降量”的开关只存关键帧、按间隔抽帧、只存 Y 分量、降低分辨率。这不是可选项是必选项。提示如果你的目的只是“验证解码结果对不对”完全可以只存前 30 帧肉眼确认没问题就停。全量落盘留给你真正需要完整序列的场景。3. 底层认知MediaCodec 到底交给你什么3.1 同步模式和异步模式选哪个更适合落盘MediaCodec 从 API 21 起提供了两种工作模式。同步模式是你自己调dequeueInputBuffer和dequeueOutputBuffer轮询代码是一条顺序执行的循环逻辑线性、容易打断点、出错时状态清晰。异步模式是通过setCallback注册回调解码器在内部线程里通知你输入可用、输出可用。对于“解码 写文件”这个任务我强烈建议用同步模式。原因有两个一是写文件本身是阻塞 IO放在回调线程里容易拖慢解码节奏甚至触发解码器内部缓冲溢出二是同步模式下“取到一帧 → 立刻写一帧 → 释放缓冲区”是一个天然连续的流程你很容易控制写入顺序和帧号而回调模式里帧的到达顺序虽然也有保证但和写盘的耦合会让异常处理变复杂。同步模式的骨架非常固定四步循环喂输入 → 取输出 → 处理输出 → 释放输出。真正需要动脑的地方不在循环本身而在“处理输出”那一步。3.2 有 Surface 和没 Surface 是两条完全不同的路这是新手最容易搞混的地方。codec.configure(format, surface, null, 0)里的第二个参数如果传了一个 Surface解码器会直接把画面渲染到那块画布上dequeueOutputBuffer拿到的 buffer 里没有任何图像数据你只能拿到一个 index 和时间戳。所以要拿 YUV必须传 nullcodec.configure(format, null, null, 0)传 null 之后解码器的输出缓冲区里才是真正的像素数据你才有机会把它读出来。这一点如果搞错了代码会“正常跑完”但一个字节的图像都拿不到而且不报错非常容易被误判成“解码器不支持输出 YUV”。3.3 输出格式无法指定需要从解码器读取重点来了。当解码器完成第一次输出前会先给你一个信号dequeueOutputBuffer返回INFO_OUTPUT_FORMAT_CHANGED。这时候调codec.outputFormat你能拿到一份真实的输出格式里面最关键的是KEY_COLOR_FORMAT。在真机上你大概率会看到COLOR_FormatYUV420Flexible值是 0x7F420888。这个“Flexible”的意思是它承诺是 YUV420但具体是平面排布还是打包排布由Image接口来统一描述。这其实是好消息因为 API 21 之后提供了一个统一的读取入口codec.getOutputImage(index)它返回一个Image对象里面有 planes、rowStride、pixelStride、cropRect把这些信息用对了就能同时兼容平面和半平面两种排布。除了 Flexible还有几种老格式会出现在部分设备上colorFormat 常量值内存排布处理难度COLOR_FormatYUV420Flexible0x7F420888由 Image 描述低优先走 ImageCOLOR_FormatYUV420Planar19YYYY…UUUU…VVVVI420中需自己算偏移COLOR_FormatYUV420SemiPlanar21YYYY…UVUVUV…NV12 风格中需自己算偏移COLOR_FormatYUV420PackedSemiPlanar39打包排布跨距可能不等于宽度高不推荐硬解我个人的做法是优先走 Image 路径拿不到再退回到 ByteBuffer 路径。因为 Image 已经把跨距和对齐信息都告诉你了你不需要猜。只有当设备上的解码器不支持getOutputImage极少数老机型才去读 ByteBuffer 并自己按 colorFormat 拆。3.4 动手前先把存储空间和权限路径想清楚在 Android 10 之后分区存储Scoped Storage全面生效以前那种WRITE_EXTERNAL_STORAGE加/sdcard/xxx的写法基本走不通了。你只有三条路第一条写应用专属外部目录也就是context.getExternalFilesDir(...)下面。这条路径不需要任何运行时权限卸载应用时自动清理用 ADB 或者文件管理器也能拷出来是本场景最推荐的方式。缺点是它在 Android/data 下面部分机型上用户手动访问不方便。第二条写公共媒体目录通过 MediaStore 插入。这条路正规但要处理 Uri 和输出流帧文件几千个的话MediaStore 里会塞进几千条记录体验很糟糕不建议用于帧序列。第三条用SAF存储访问框架让用户选一个目录拿到DocumentFile树再写。适合需要用户自己指定位置的场景但要处理权限持久化。我的建议很直接调试和算法验证阶段一律走应用专属外部目录。省事不需要处理权限弹窗adb pull也照样能拉。4. 工程骨架从容器到解码器的最小闭环4.1 Extractor 选轨与 format 传递MediaExtractor 负责拆容器它把 MP4 里的音视频轨道分开告诉你每一条轨的编码参数。对我们要做的事来说只需要视频轨而且需要把这条轨的 format原封不动地传给解码器——这一点很关键里面包含了 SPS/PPS也就是csd-0、csd-1解码器必须靠它才能初始化。val extractor MediaExtractor() extractor.setDataSource(videoPath) var trackIndex -1 var format: MediaFormat? null for (i in 0 until extractor.trackCount) { val f extractor.getTrackFormat(i) val mime f.getString(MediaFormat.KEY_MIME) ?: continue if (mime.startsWith(video/)) { trackIndex i format f break } } require(trackIndex 0 format ! null) { 没有找到视频轨 } extractor.selectTrack(trackIndex) val mime format.getString(MediaFormat.KEY_MIME)!!这里有个容易忽略的细节getTrackFormat返回的 format 里KEY_WIDTH/KEY_HEIGHT是编码尺寸不一定等于最终显示尺寸。如果视频带旋转元数据KEY_ROTATION常见于手机竖拍的视频解码器输出的帧是没有旋转的原始方向。也就是说你存下来的 YUV 可能是横着的画面。这一点必须在后续处理里自己转或者至少在文档里写清楚否则拿去训练模型的人会一头雾水。4.2 configure 与 start 的关键参数val codec MediaCodec.createDecoderByType(mime) codec.configure(format, null, null, 0) // 第二个参数必须是 null codec.start()三个参数分别对应format含 SPS/PPS、surfacenull 表示要裸数据、cryptoDRM一般传 null、flags0 表示普通解码要当编码器才传 CONFIGURE_FLAG_ENCODE。有个小技巧如果你的视频分辨率很大而设备解码器有最大分辨率限制configure会直接抛MediaCodec.CodecException。这时候可以先用MediaCodecList查一下当前设备支持的能力把错误信息打出来而不是盲目试。4.3 输入队列的提交节奏与时间戳同步模式下喂数据是“能喂就喂”dequeueInputBuffer返回非负值就说明有空闲输入槽。时间戳用extractor.sampleTime原样传进去这个值会跟着帧一路传到输出端的BufferInfo.presentationTimeUs这是你后面给文件命名做对齐的唯一可靠依据千万别自己造一个自增的序号当时间戳。输入喂完的标志是readSampleData返回负数这时候给一个带BUFFER_FLAG_END_OF_STREAM的空 bufferif (size 0) { codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) inputDone true } else { codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0) extractor.advance() }这里有个坑你只喂了 EOS不代表已经拿完了所有帧。解码器内部还有 B 帧重排、还有缓冲没吐出来。所以输出侧必须一直循环到收到带BUFFER_FLAG_END_OF_STREAM的输出帧为止才能退出循环。少了这一步你会稳定地丢掉最后几帧而且是那种“总帧数对不上差个两三帧”的诡异现象。5. 核心实现一帧一帧把 YUV 取出来写进文件5.1 主循环的标准写法把上面这些拼起来主循环就是这样val info MediaCodec.BufferInfo() var inputDone false var outputDone false var frameIndex 0 var outputFormat: MediaFormat? null val timeoutUs 10_000L while (!outputDone) { // 1. 喂输入 if (!inputDone) { val inIndex codec.dequeueInputBuffer(timeoutUs) if (inIndex 0) { val inBuf codec.getInputBuffer(inIndex)!! val size extractor.readSampleData(inBuf, 0) if (size 0) { codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) inputDone true } else { codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0) extractor.advance() } } } // 2. 取输出 val outIndex codec.dequeueOutputBuffer(info, timeoutUs) when { outIndex 0 - { if (info.size 0) { dumpFrame(codec, outIndex, info, frameIndex) frameIndex } codec.releaseOutputBuffer(outIndex, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM ! 0) { outputDone true } } outIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED - { outputFormat codec.outputFormat // 打印一下真实格式排障时非常有用 Log.d(YuvDump, outputFormat $outputFormat) } outIndex MediaCodec.INFO_TRY_AGAIN_LATER - { // 正常情况继续下一轮 } } }这段代码有几个值得说的地方。第一if (info.size 0)这个判断不能省。有些解码器在流末尾会吐出一个 size 为 0 的空 buffer只是为了带 EOS 标记。如果你不判断会多写一个空文件或者在一个空 Image 上崩溃。第二INFO_OUTPUT_FORMAT_CHANGED只在最开始出现一次个别流中途分辨率变化会出现第二次所以要用变量把它存下来后面每个帧都参考它。第三releaseOutputBuffer(outIndex, false)的第二个参数是render我们没 Surface传 false 就对了。如果手误传了 true在某些机型上会直接抛异常。5.2 推荐路径用 Image 拿平面数据这是整套代码的核心。codec.getOutputImage(outIndex)返回的Image对象把 YUV 的内存布局描述得清清楚楚private fun dumpFrame( codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo, frameIndex: Int ) { val image codec.getOutputImage(index) ?: run { Log.w(YuvDump, getOutputImage 返回 null帧 $frameIndex 跳过) return } try { val crop image.cropRect // 有效区域可能有偏移 val w crop.width() val h crop.height() val file File(outDir, frame_%06d_%d.yuv.format(frameIndex, info.presentationTimeUs)) BufferedOutputStream(FileOutputStream(file), 1 shl 16).use { bos - // Y 分量全分辨率 writePlane(bos, image.planes[0], crop.left, crop.top, w, h) // U/V 分量420 下采样坐标要除以 2 writePlane(bos, image.planes[1], crop.left / 2, crop.top / 2, w / 2, h / 2) writePlane(bos, image.planes[2], crop.left / 2, crop.top / 2, w / 2, h / 2) } } finally { image.close() } }这里最关键的一行是image.close()放在 finally 里。Image是个资源对象它内部持有解码器输出缓冲区的引用。不 close 的后果不是内存泄漏那么简单而是解码器缓冲区不被归还跑几十帧之后dequeueOutputBuffer就会永远返回 TRY_AGAIN_LATER整个循环卡死。这个坑埋得极深因为表象是“解码卡住了”很少有人第一反应会想到是 Image 没关。再看writePlane这是真正处理“跨距”的地方private fun writePlane( out: OutputStream, plane: Image.Plane, cropLeft: Int, cropTop: Int, width: Int, height: Int ) { val buffer plane.buffer val rowStride plane.rowStride // 一行实际字节数可能大于宽 val pixelStride plane.pixelStride // 相邻采样点间隔 val rowBuf ByteArray(rowStride) val outBuf ByteArray(width) val baseOffset buffer.position() // 必须从当前位置起算 for (r in 0 until height) { val offset baseOffset (cropTop r) * rowStride cropLeft * pixelStride buffer.position(offset) val readable minOf(rowStride, buffer.remaining()) if (readable 0) break buffer.get(rowBuf, 0, readable) if (pixelStride 1) { out.write(rowBuf, 0, width) } else { // 半平面排布时U/V 交错存放需要跳着取 var src 0 var dst 0 while (dst width src readable) { outBuf[dst] rowBuf[src] src pixelStride dst } out.write(outBuf, 0, dst) } } }为什么rowStride可能大于宽度因为 GPU 和解码器都喜欢按 16 或 32 字节对齐1080 宽度可能被对齐成 1088 甚至 1152。如果你天真地按width去切行第一行没问题第二行开始就整体错位画面上表现出来就是从左上到右下的一根斜的彩色条纹非常典型。pixelStride则是半平面排布的产物。NV12 风格的排布里U 和 V 交错存成UVUVUV...此时planes[1]的pixelStride是 2它指向的是 Uplanes[2]的pixelStride也是 2但它指向的是 V偏移差 1 个字节。所以上面这段代码用同一个函数处理两种排布pixelStride 1就是平面直接整行写pixelStride 2就跳着取。注意buffer.position()不一定是 0有些解码器会把 plane 的起始位置设在缓冲区中间。所以一定要以buffer.position()作为基准偏移不要硬编码从 0 开始读。这样写出来的文件格式是I420YYYY...UUUU...VVVVY、U、V 三个平面按顺序紧密排列宽高都是 crop 后的实际尺寸。这个格式几乎所有 YUV 查看工具、几乎所有图像处理库都支持通用性最好。5.3 绕过方案的备用路径直接读 ByteBuffer极少数设备上getOutputImage会返回 null。这种情况下退回 ByteBuffer 路径但前提是colorFormat必须是 Planar 或 SemiPlanar不能是 Flexibleval colorFormat outputFormat!!.getInteger(MediaFormat.KEY_COLOR_FORMAT) val width outputFormat.getInteger(MediaFormat.KEY_WIDTH) val height outputFormat.getInteger(MediaFormat.KEY_HEIGHT) val outBuf codec.getOutputBuffer(index)!! when (colorFormat) { MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar - { // 布局Y[W*H] U[W*H/4] V[W*H/4]无对齐填充 outBuf.position(0) outBuf.get(yuvArray, 0, width * height * 3 / 2) } MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar - { // 布局Y[W*H] UV 交错[W*H/2]需要拆成 I420 outBuf.position(0) outBuf.get(nv12Array, 0, width * height * 3 / 2) splitUvToI420(nv12Array, yuvArray, width, height) } else - Log.e(YuvDump, 不支持的 colorFormat: 0x${colorFormat.toString(16)}) }这条路的风险在于宽高是不是已经对齐过不同厂商的实现不一样。有的设备输出的 buffer 是按 width 排的有的是按对齐后的 stride 排的而 ByteBuffer 路径拿不到 stride 信息。所以如果你发现这条路上出来的是斜纹基本可以断定是厂商用了对齐 stride。这也是我为什么把它放在备用位置——Image 路径能拿到 stride问题少一半。5.4 写文件命名规则、缓冲策略和目录规划文件命名我一般用frame_%06d_%d.yuv前面是帧序号后面是presentationTimeUs。带上时间戳的原因很实际当你需要和音频对齐或者需要知道某一帧在原始视频里的准确位置时帧序号是靠不住的因为中间可能丢帧或者跳帧只有时间戳是可信的。缓冲策略上BufferedOutputStream给 64KB 是个比较稳妥的值。单帧 3MB64KB 缓冲意味着大约 48 次系统调用比不缓冲的几千次强太多。再大的缓冲收益递减反而增加内存压力。目录规划上我习惯给每次任务建一个独立子目录用时间戳命名跑完再统计帧数和总大小val outDir File( context.getExternalFilesDir(Environment.DIRECTORY_MOVIES), yuv_${System.currentTimeMillis()} ).apply { mkdirs() }跑完之后用一个简单的统计确认一下val files outDir.listFiles()?.sortedBy { it.name } ?: emptyList() val totalBytes files.sumOf { it.length() } Log.i(YuvDump, 共 ${files.size} 帧总计 ${totalBytes / 1024 / 1024} MB)这个统计别嫌土它是最快发现问题的办法。如果帧数和预期对不上或者每帧大小不一致除了最后一帧那就是跨距或者 crop 出了问题。5.5 收尾EOS、释放顺序和异常保护释放顺序是有讲究的顺序错了会偶发崩溃try { // 解码循环 } finally { runCatching { codec.stop() } runCatching { codec.release() } runCatching { extractor.release() } }先 stop 再 release这是 MediaCodec 的硬性要求直接 release 在部分机型上会抛 IllegalStateException。用runCatching包一层是因为如果解码过程中已经出过异常codec 可能已经处于 error 状态这时候 stop 也会抛不能让收尾逻辑再炸一次。另外建议把整个解码任务放在一个后台线程里跑主线程只负责 UI 状态更新。写 90MB/s 的 IO 放在主线程ANR 是必然的。6. 常见问题与排查实录6.1 画面花屏、绿边、斜纹按这个顺序查花屏是最高频的问题我把它拆成四种典型表现对应的成因完全不同现象最可能的原因排查动作整体彩色噪点、看不出图像U/V 顺序搞反或色度坐标没除以 2交换 U/V 试一次检查 crop 计算从左上到右下的斜条纹忽略了 rowStride 对齐填充按 rowStride 逐行读不要按 width 拼右侧或底部一条绿色/黑色边cropRect 没生效把 padding 区域当成了图像打印 cropRect确认宽高画面整体偏移、错位buffer.position() 起点没算进去用 buffer.position() 作基准偏移只有第一帧正常之后全乱Image 未 close缓冲区复用异常检查 finally 里有没有 image.close()我遇到最久的一次是“右侧一条绿边”。查了很久以为是 crop 的问题最后发现是 crop 算对了但写文件的时候 Y 平面按 crop 宽写、U/V 平面却按原始宽的一半写导致后面所有行错位。Y 和 U/V 的宽高必须严格是 2:1 的关系这个一致性要自己保证。验证方法也分享一下写完之后立刻用一张图确认。你可以取第一帧的 Y 平面单独存成一张 8 位灰度图前面加个 12 字节的 BMP 头或者直接当 raw 用工具打开Y 平面没问题的话画面明暗轮廓就对了剩下的问题一定出在色度上。这个小技巧能把排查范围缩小一半。6.2 帧数对不上、末尾缺几帧、卡死不动帧数问题是第二高频。三种情况总帧数比预期少 1 到 3 帧——九成是没等 EOS 输出就跑完了。检查输出循环是不是收到了带BUFFER_FLAG_END_OF_STREAM的输出帧才退出。总帧数多出来几个——多半是没判断info.size 0把空 buffer 也写了。也可能是某些流带了封面帧或者有 B 帧解码重复输出。中途卡死永远 TRY_AGAIN_LATER——除了前面说的 Image 没 close还有一个可能是写入太慢导致解码器缓冲区耗尽。可以试着把写文件挪到一个单独的消费者线程解码线程只负责把数据拷出来丢进队列。用LinkedBlockingQueue加一个有界长度比如 8 帧写入慢的时候就阻塞解码侧形成背压比无限堆积内存安全得多。6.3 权限、路径和拷不出来的问题存储这块的坑集中在“文件写了但找不到”。第一个可能写到了应用内部目录context.filesDir这个目录 ADB 也拷不出来除非设备已 root。检查一下你用的是不是getExternalFilesDir。第二个可能Android 11 之后adb pull访问/sdcard/Android/data/下的内容需要额外的权限声明。在AndroidManifest.xml的 application 或者 activity 上加一句application android:requestLegacyExternalStoragetrue ...或者更现代的做法直接用adb shell run-as 包名 ls看文件在不在。第三个可能文件名里有非法字符。我用presentationTimeUs做文件名一部分这个值是 long没问题但如果你用了一些奇怪的分隔符就可能在某些文件系统上失败。冒号、问号、星号一律别用。6.4 性能与内存别让写盘拖垮解码性能上我有三条实战经验。第一条输出缓冲区别每次都 new。writePlane里的rowBuf和outBuf应该是复用的成员变量按最大可能尺寸分配一次就行。每帧 new 两个数组GC 压力会在几十秒后体现出来。第二条FileOutputStream 每帧新建是必要的但可以复用 ByteArray。每个帧一个文件流对象没法复用这是设计决定的。但拷贝用的缓冲区可以复用能省不少分配。第三条别忽略闪存的随机写惩罚。一帧 3MB 是顺序写还好但如果你是 720p 每秒 30 个文件那就是每秒 30 次文件创建 关闭 元数据更新这部分开销在某些机型上比数据本身还贵。如果确实需要这种粒度考虑把若干帧写进同一个文件用固定长度分隔最后再拆。或者干脆降低抽帧频率——很多场景 5fps 的样本量已经够了。7. 优化清单与延伸玩法7.1 一套可以直接照抄的降量参数如果你只是需要离线素材我建议按这个配置起步跑通了再放开分辨率优先用 720p 源实在不行 1080p 先只取前 100 帧抽帧每 6 帧取 1 帧30fps 源相当于 5fps时长先截取 10 秒片段试跑只存 Y如果只是验证轮廓/亮度只存 Y 平面体积直接降到 2/3目录getExternalFilesDir(DIRECTORY_MOVIES)/yuv_时间戳/按这个配置720p 抽帧后 10 秒大概是 50 帧 × 1.32MB ≈ 66MB很轻松。7.2 从存文件再往前一步能做什么把 YUV 落盘这件事做通之后其实打开了好几个方向。一是YUV 转 RGB 做实时预览。你可以在写文件的同时把 Y 平面丢给一个灰度渲染或者做一次快速的 YUV→RGB 转换画到自定义 View 上这样能直观看到解码是否正确比拉文件到电脑上快得多。二是接算法做在线处理。既然每帧的 YUV 都在手上了直接送给人脸检测、超分、滤镜这些模块就行省掉一次编解码往返。三是做编码回环验证。把解出来的 YUV 再喂给 MediaCodec 编码器重新压成 H.264对比一下码率和画质这是验证编码参数最土也最有效的办法。7.3 我个人踩过的最深的一个坑最后分享一个当时卡了我整整一个下午的问题我在configure的时候顺手写了format.setInteger(MediaFormat.KEY_COLOR_FORMAT, COLOR_FormatYUV420Planar)然后在代码里就按 I420 的规则硬拆。本地几台测试机都正常换了一台设备就稳定花屏。原因很简单那台设备的解码器忽略了这个请求实际输出的是 SemiPlanar而我没去读outputFormat里的真实值一直按 I420 拆。NV12 被当成 I420 解读画面就是那种上半部分还能看出轮廓、下半部分完全糊掉的诡异样子。从那以后我就定了一条规矩任何跟硬件相关的格式、尺寸、能力一律在运行时读出来绝不写死。代码里那行Log.d(YuvDump, outputFormat $outputFormat)就是那时候加上的每次接新设备第一件事就是看这行日志。这个习惯后来帮我省掉的问题远比多写一行日志的成本高。
企业数字化 ERP 产品动态
相关推荐
Unity MCP:AI驱动的游戏开发框架解析 1. Unity MCP:AI驱动的游戏开发革命在游戏开发领域,Unity引擎长期占据主导地位,而AI技术的爆发式发展为开发者带来了全新可能。Unity MCP(Model Context Protocol)正是这两大技术交汇的产物——它不是一个简单的代码生… · 2026/11/4 12:15:32
从零搭建语音工作台VoiceStudio:语音采集、降噪、转写与合成全流程实战 1. 从零搭建一个语音工作台:VoiceStudio 到底在解决什么问题 第一次看到 VoiceStudio 这个名字,我脑子里蹦出来的不是某个具体产品,而是一类需求:把“声音”这件事从零散的录音、剪辑、转写、变声、合成里抽出来,做成一… · 2026/11/3 9:17:13
JavaScript Date对象全面指南:从基础到高级应用 1. JavaScript Date对象深度解析 作为前端开发中最基础也最常用的内置对象之一,Date对象承载着处理日期和时间的重要职责。从简单的页面计时器到复杂的日历应用,Date对象无处不在。但你真的了解它的全部能力吗?
2. Date对象基础使用
2.1 创… · 2026/11/4 20:53:58
EasyU优启通PE维护盘实战指南:制作、启动与核心操作全解析 1. 为什么一个PE维护盘值得单独拿出来聊装系统、修引导、救数据、清病毒,这些活儿但凡干过几次的人都知道,真正决定效率的往往不是你手速多快,而是手边那套PE环境靠不靠谱。EasyU(圈内习惯叫优启通)就是在这个场景下被… · 2026/11/5 16:49:35
Rust原来很简单二:所有权系统 Part 2 核心篇 —— 所有权系统
本篇覆盖 第 6 ~ 10 章:所有权、借用与引用、生命周期、字符串与切片、内存模型。 ⚠️ 这是全书最重要的一篇。 把它吃透,Rust 从此“很简单”;跳过它,你会处处碰壁。请慢下来,动手敲每一段代码。 第 6 章 所有权:Rust 的第一性原理
6… · 2026/11/5 16:26:08
基于Hadoop+Spark的民宿智能推荐系统设计与实现 1. 项目概述:大数据时代的民宿智能推荐这个毕业设计项目融合了当前大数据领域最主流的技术栈,构建了一个完整的民宿推荐系统解决方案。作为一名长期从事大数据系统开发的工程师,我认为这个选题非常贴合实际应用场景——随着旅游业的复苏和共享… · 2026/11/5 14:59:25
ADS122U04IPWR+STM32F427ZI工业级温度测量信号链设计 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 14:10:41
UART接口24位ADC搭配RS485,解决远距离温度监控难题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 14:10:41
UART接口24位ADC搭配PIC18F46K22的RTD与热电偶测温实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 14:10:41
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:07
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:07