资讯动态

Android音视频开发实战:MediaCodec与Camera2全链路录制与避坑指南

发布时间:2026/10/16 5:04:08 来源:尧图企业网站定制
简介这是一份面向Android音视频开发者的PDF学习资料系统梳理了从视频帧、帧率、码率、分辨率到IPB帧等核心概念并结合FFmpeg脚本演示抽帧、转GIF、码率控制等实操方法。文件为单个PDF文档大小6.39MB内容精炼适合需要快速建立音视频知识框架的初中级开发者阅读。目前已有1213人学习下载。资料还介绍了Android多媒体框架MediaCodec、AudioTrack、Surface与FFmpeg Android版本的使用思路帮助读者将理论应用到实际编码、解码、播放和录制场景中。学习后可理解CBR/VBR/CVBR/AVBR码率模式差异、I/P/B帧与GOP的意义为后续优化画质与体积打下基础。1. 这份PDF最反直觉的地方音视频技术不是“看懂”的是“跑通”的拿到《Android音视频开发与处理.pdf》这类资料多数人的第一反应是找目录、按章节顺序从头读。我建议先放下这个习惯。做音视频开发有个反直觉的规律把这份PDF从头到尾读三遍不如照着它梳理的链路在真机上跑通一条录制管线。原因是音视频每一环都互相咬合——采集分辨率、编码码率、封装时间戳、渲染Surface任何一个参数错位程序都不会报错只是“画面不对”首帧泛绿、声音比画面快半秒、帧率忽高忽低。这类问题在纸面上推演不出来只能靠真机验证。这篇笔记就按这份PDF的地图把采集、编码、封装、渲染的落地路径讲透包括参数怎么设、坑踩在哪以及怎么用工程手段确认链路真的通。适合想做实录、直播、视频编辑底座的开发者也适合被播放器卡了半年想往上层走的人。2. 从PDF目录反推音视频核心链路采集到渲染每一环在解决什么问题不管这份PDF的目录怎么编排底层都绕不开一条主线采集 → 编码 → 封装 → 渲染。先建立整体坐标后面跑工程时才知道自己站在链路的哪一段报错时也知道该去哪一段找原因。这四个环节对应的Android系统能力其实高度集中核心就几个类但每个类的职责边界很容易被混淆。2.1 采集层Camera和AudioRecord在链路上的职责边界采集层负责把物理世界的画面和声音变成原始数据。画面走Camera2产出的是Image对象像素格式通常是YUV_420_888声音走AudioRecord产出的是PCM裸流。两者在链路上的职责只有一个产帧不碰编码。很多人在这里犯的第一个错是把采集和编码混在一个回调里做。比如在ImageReader的onImageAvailable回调里直接同步做编码和写文件结果就是采集帧率被编码拖慢画面开始一顿一顿地动。采集回调应该只做一件事拿帧入队然后立刻close掉Image。Image不close缓冲区池会耗尽预览会莫名其妙卡死这个问题我在真机上排查过一整天才定位到。AudioRecord这边容易被忽略的是bufferSize。官方提供了getMinBufferSize()但那个值只是下限直接用它做缓冲区在高负载下很容易出现overrun声音变成“咔哒咔哒”的断音。我一般会取最小值的三倍左右做实际缓冲区让采集线程有足够的喘息空间。采样率用44100或48000声道数、PCM位深这些参数要和后面编码器期望的输入完全一致不一致时不会报错只是声音变调或速度不对非常难查。2.2 编码层MediaCodec为什么是绕不开的中心Android上做编码绕不开MediaCodec。它既是硬编的入口也是软编的统一抽象。很多刚从FFmpeg转过来的人会下意识想自己集成x264不是说不行而是兼容性和功耗很难控制x264跑在CPU上720p30fps的实时编码就能把中端手机跑热发热降频后又反过来拖累采集帧率。硬编虽然画质调校空间小但胜在低功耗、低延迟而且Android厂商的系统分发时会带各自的硬件编码器MediaCodec帮你做了一层统一调度。MediaCodec是一个典型的状态机Uninitialized → Configured → Executing。configure之后必须startstart之后才能dequeueInputBuffer和dequeueOutputBufferstop之后不能再次start只能release重建。对这个状态机不熟的人最常见的问题是多个线程同时在dequeue触发IllegalStateException这是一个典型的并发边界MediaCodec本身不是线程安全的同步模式下所有dequeue操作必须集中在同一个循环里。同步模式还是异步模式我的建议是单路录制场景先用同步模式。API 21之后有异步回调onInputBufferAvailable、onOutputBufferAvailable看起来很优雅但它引入了背压问题编码器输出突然变快时回调里如果还在做IO写文件就会卡住编码线程继而影响采集。同步模式虽然代码啰嗦但循环里每一步都可以控制超时排错路径清晰得多。2.3 渲染层SurfaceView、TextureView和Surface的三角关系渲染层是新手最懵的部分因为牵扯到三个长得像的东西SurfaceView、TextureView、Surface。Surface是一块可绘制的内存区域背后是BufferQueue生产者和消费者通过它交换图像数据。SurfaceView是自带独立Surface的View它不在ViewGroup的普通绘制流程里性能好、延迟低适合相机预览、视频播放。TextureView是普通View内部靠SurfaceTexture充当生产者和View绘制之间的桥梁可以平移缩放旋转但每帧多一次合成延迟和功耗略高。选型上我一般遵循一个原则只做上屏显示用SurfaceView要做动画变换或嵌套进复杂布局再用TextureView。还有一个容易忽略的用法是Surface可以直接作为MediaCodec的输入和输出——输入Surface模式input surface可以让Camera直接通过Surface送帧给编码器绕开YUV手动拷贝但这也意味着你要理解整条Surface链路才能调好新手阶段先用ImageReader加ByteBuffer的方式等链路通了再优化成Surface输入。链路环节核心类典型数据格式最容易出错的地方采集Camera2 / AudioRecordYUV_420_888 / PCM 16bit帧率与分辨率不匹配编码MediaCodecH.264 / AAC时间戳不单调封装MediaMuxerMP4addTrack与start顺序渲染SurfaceView / TextureViewRGB / YUVSurface生命周期管理3. 把PDF变成可跑工程用MediaCodec实现一条录制链路的最小闭环纸上谈兵到此为止。我一般建议先做“预览录像”这条线它能同时验证采集、编码、封装三段链路。完整工程要考虑的东西很多但最小闭环可以拆成三段Camera2预览、编码循环、MediaMuxer写MP4。下面是能落地的写法代码基于API 21以上按同步模式组织。3.1 建立预览Camera2 SurfaceView的最小配置Camera2的openCamera回调里要做两件事创建CameraCaptureSession并同时绑两个Surface——一个给预览用一个给ImageReader用。ImageReader是录像的数据源格式设成YUV_420_888尺寸必须和编码器输入尺寸一致我这里统一存储到成员变量width、height。// 3.1 预览 录像Camera2 打开后的最小会话 private void openCamera() { CameraManager manager (CameraManager) getSystemService(CAMERA_SERVICE); try { String cameraId manager.getCameraIdList()[0]; // 实战中要挑后摄 CameraCharacteristics chars manager.getCameraCharacteristics(cameraId); // 传感器方向竖屏预览要拿它来旋转画面 int sensorOrientation chars.get(CameraCharacteristics.SENSOR_ORIENTATION); // 录像帧源YUV_420_888尺寸按编码器对齐到16的倍数 mImageReader ImageReader.newInstance( width, height, ImageFormat.YUV_420_888, 3); mImageReader.setOnImageAvailableListener(mOnImageAvailable, mHandler); manager.openCamera(cameraId, mStateCallback, mHandler); } catch (CameraAccessException | SecurityException e) { Log.e(TAG, openCamera failed, e); } }这段代码的关键点是ImageReader的maxImages参数。设2是最小值但编码线程一旦因为GC或写文件抖动采集侧的Image就会堆积队列满了之后预览直接“假死”所以我通常设3。sensorOrientation拿到的值用来计算预览和录像的旋转角度Camera传感器默认是横置的不做旋转处理竖屏拍出来的视频旋转信息会丢后面避坑章再展开。3.2 编码循环MediaCodec的同步模式怎么写才不丢帧编码器初始化的核心是MediaFormat。mime用video/avc颜色格式优先选ColorFormatYUV420Flexible因为Camera2输出到Image里的YUV_420_888是flexible布局兼容性最好。如果写死COLOR_FormatYUV420SemiPlanar部分设备的编码器会直接拒绝configure。// 3.2 编码器初始化 MediaCodec mEncoder MediaCodec.createEncoderByType(video/avc); MediaFormat format MediaFormat.createVideoFormat( video/avc, width, height); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible); format.setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2); // 秒 format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline); format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31); mEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); mEncoder.start();参数说明4Mbps码率是720p30fps的保守起步值画面复杂度高的场景可以提到6MbpsI帧间隔2秒适合录播直播再缩到1秒Profile用Baseline兜底兼容性最好老解码器也不会花屏Level31对应720p30fps的规格上限超出这个规格的设备上configure阶段就会失败。编码循环分两个方向入队是往里送原始YUV帧出队是往外取编码后的H.264流。入队时要特别注意Image用完必须closeYUV转换必须在入队前完成。// 3.2 每一帧 YUV已转 NV12送入编码器 int inIndex mEncoder.dequeueInputBuffer(10_000); if (inIndex 0) { ByteBuffer buffer mEncoder.getInputBuffer(inIndex); buffer.clear(); buffer.put(nv12Data); // nv12Data 来自 Image 转换 // pts 必须单调递增单位是微秒 long ptsUs (System.nanoTime() - mBaseTimeNs) / 1000; mEncoder.queueInputBuffer(inIndex, 0, nv12Data.length, ptsUs, 0); }这里用System.nanoTime()减一个起始时间作为时间戳来源单位是微秒。不要用帧号乘1000/帧率去估算PTS因为实际采集帧率一直在抖动估算出来的时间戳必然导致音画不同步。出队循环要把编码后的数据交给MediaMuxer。关键帧的Buffer前面会带SPS/PPS信息这部分MediaMuxer在addTrack时已经从outputFormat里取走了写数据时不要手动拼头手动拼了反而会花屏。// 3.2 编码输出循环 MediaCodec.BufferInfo info new MediaCodec.BufferInfo(); int outIndex mEncoder.dequeueOutputBuffer(info, 10_000); if (outIndex 0) { ByteBuffer outData mEncoder.getOutputBuffer(outIndex); // 关键帧的 csd 信息由 MediaMuxer 在 addTrack 时自动处理 mMuxer.writeSampleData(mVideoTrackIndex, outData, info); mEncoder.releaseOutputBuffer(outIndex, false); }3.3 MediaMuxer封装addTrack与时间戳的唯一原则MediaMuxer负责把编码后的H.264流封装成MP4文件。它有一个严格的顺序要求addTrack必须先于startstart之后才能writeSampleData。addTrack时传入的是编码器的outputFormat里面带着SPS/PPS等编解码信息MediaFormat的csd-0和csd-1字段会被Muxer自动写到文件头里。// 3.3 MediaMuxer 封装 mMuxer new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); mVideoTrackIndex mMuxer.addTrack(mEncoder.getOutputFormat()); mMuxer.setOrientationHint(rotation); // 竖屏录像必须设置 mMuxer.start();setOrientationHint要在start之前调用。rotation来自Camera2的sensorOrientation和DisplayRotation换算不设置的话竖屏拍出来的视频在播放器里是横着的。这段逻辑本身不复杂但顺序错了会直接抛RuntimeException定位起来像玄学。收尾顺序也容易踩坑先stop编码器再stop Muxer最后release。如果先stop Muxer后面再往里面写数据就会抛异常如果编码器还没产出剩余帧就stop Muxer文件尾部会缺数据播放时最后几帧丢或花。我一般会先调用mEncoder.stop()然后mMuxer.stop()最后mMuxer.release()。YUV_420_888转NV12是这个闭环里绕不开的转换直接贴一下核心搬运逻辑。Camera2出来的Image每个plane的rowStride很可能大于图像宽度也就是每行末尾有padding不能直接array()整体拷贝必须按行拷// 3.2 YUV_420_888 - NV12逐行搬运处理 rowStride/pixelStride private byte[] yuv420888ToNV12(Image image) { Image.Plane[] planes image.getPlanes(); int w image.getWidth(), h image.getHeight(); byte[] nv12 new byte[w * h * 3 / 2]; // Y 平面 ByteBuffer yBuf planes[0].getBuffer(); int yRowStride planes[0].getRowStride(); int pos 0; for (int row 0; row h; row) { for (int col 0; col w; col) { nv12[pos] yBuf.get(row * yRowStride col); } } // UV 平面半平面 V、U 交错成 NV12 布局 ByteBuffer uBuf planes[1].getBuffer(); ByteBuffer vBuf planes[2].getBuffer(); int uRowStride planes[1].getRowStride(); int vRowStride planes[2].getRowStride(); for (int row 0; row h / 2; row) { for (int col 0; col w / 2; col) { nv12[pos] vBuf.get(row * vRowStride col); nv12[pos] uBuf.get(row * uRowStride col); } } return nv12; }这段代码假设pixelStride为1很多设备上U/V平面pixelStride是2实战代码要读planes[1].getPixelStride()做跳步处理否则颜色会错乱。这份PDF里如果只是讲概念不贴这个细节真机调试时你一定会卡在这一步。4. 决定成败的6个编码参数码率、帧率、GOP、Profile、颜色格式怎么设编码参数是音视频开发里最像“玄学”的领域因为改一个值画质可能没变化但文件体积变大一倍或者某台设备上一切正常换个芯片直接花屏。下面这张表是720p录播场景的起步配置后面逐项展开为什么这么设、失控时坏在哪。参数含义720p录播起步值失控后果码率每秒编码字节数4Mbps低码率高帧率→花屏帧率每秒编码帧数30运动画面顿挫GOP两个关键帧间隔2秒拖拽/seek困难Profile编码特性集BaselineHigh在老设备花屏Level分辨率和帧率上限31超规格初始化失败颜色格式YUV排列方式Flexible写错颜色错乱或绿屏4.1 码率和帧率清晰度与体积不是唯一约束码率决定每一秒钟有多少比特分配给画面帧率决定画面每秒更新多少次。这两个参数放在一起看才有意义同样4Mbps30fps下每帧约133Kb15fps下每帧约266Kb后者单帧质量更高但运动不流畅反过来同样30fps码率压到1.5Mbps静态场景勉强能看镜头一甩就是满屏马赛克和花屏。动态场景的码率需求会指数上升因为H.264的帧内压缩在画面剧烈变化时会产生大量残差数据。拍人的场景都算复杂我一般会比起步值上浮50%。CBR还是VBR也要在此刻定CBR码率平稳适合推流和实时传输但静态场景浪费比特VBR在静态时省码率、动态时冲高适合本地录播。MediaCodec里通过KEY_BITRATE_MODE控制录播我选VBR直播选CBR避免上行带宽抖动。4.2 GOP与Profile/Level兼容性由关键帧间隔决定GOP是I帧关键帧之间的间隔。I帧是全量编码体积大P帧依赖前面的帧才能解码。间隔设2秒意味着播放器最多等2秒就能找到可解码的入口点seek时也是先跳到最近的关键帧再往后解码。直播场景要缩到1秒甚至更短因为丢包后要等关键帧到达才能恢复画面间隔太长就是长时间黑屏或花屏。本地录播间隔可以适当拉长到3到4秒文件体积更小但拖进度条时定位变慢且解码负担大。Profile和Level解决的是“编码出来的流别人能不能解”。Baseline不支持B帧解码器负担小兼容性最好我一般拿它当兜底。Main和High画质更好但一些老设备硬件解码器不支持播放时直接花屏或只有声音没有画面。Level对应规格上限AVCLevel31是720p30fps的档位如果设了1080p却用Level30编码器初始化就会失败。4.3 颜色格式YUV420的三种包装不能混YUV420的“420”指色度采样但同一采样格式有不同包装I420是Y后跟整个U平面再跟整个V平面NV12是Y后跟UV交错排列NV21是Y后跟VU交错。三者数据量一样排列顺序不同。Camera2输出的YUV_420_888是flexible格式具体底层布局因设备而异编码器期望的又往往是NV12或I420。这就带来了一个很隐蔽的坑设备A上你的转换代码工作正常因为它的底层恰巧是半平面设备B上底层布局变了颜色就出现红蓝对调或整体偏绿。所以不能赌设备的默认布局必须在运行期读取planes数组的rowStride和pixelStride动态决定搬运方式。还有颜色范围的问题部分编码器输出VideoRange部分输出FullRange混用时画面会灰蒙蒙的对比度发灰这个在MediaFormat里也可以设置KEY_COLOR_RANGE如果确认播放端支持的色域后仍然发灰就去查这个字段。5. 音视频开发踩坑清单从黑匣子到能定位问题的排查路径音视频技术最折磨人的不是概念难而是出错时症状和原因离得太远。下面这些坑是我在真机上一个个踩过的每条都按现象、原因、解决三个步骤记录希望能帮你减少几天的黑匣子排查。5.1 首帧泛绿或花屏现象录制文件开头一小段是绿色噪点画面后面逐渐正常或者整个文件在某个播放器里全都花屏。原因H.264流需要有SPS和PPS参数集才能解码这部分信息通常由MediaCodec在关键帧输出时以csd-0、csd-1字段带出。MediaMuxer在addTrack时自动把它们写进MP4的文件头和解码器描述里但如果你在writeSampleData时手动了拼接、或者用了错误的outputFormat做addTrack解码器拿到的SPS/PPS就是错的首帧甚至前几秒就解不出来。解决addTrack时严格使用mEncoder.getOutputFormat()不要自己new MediaFormat拼参数。writeSampleData只负责写码流数据不要手动往sample前头塞SPS/PPS。想确认csd有没有正确写入可以用MediaExtractor读出视频轨的格式检查csd-0、csd-1是否非空。5.2 音画不同步现象声音比画面快几百毫秒或反过来。最糟糕的是随时间推移偏移量越来越大前10秒感觉不到1分钟后差了一秒。原因时间戳来源不统一。视频帧的PTS来自采集回调里某个单调时钟音频帧的PTS来自AudioRecord的frame count换算两个独立时钟本来就有漂移。还有人用System.currentTimeMillis()做时间戳它受系统时间修改、NTP校时影响会跳变造成PTS回退解码器一见到回退就会丢帧或错序。解决所有帧的时间戳基于同一个单调时钟源。标准做法是录制开始那一刻取一次System.nanoTime()作为基准之后每帧的PTS都是当前nanoTime减基准然后除以1000转成微秒视频和音频共用这一个基准。不要用帧号乘帧间隔去反推采集帧率从来不稳定。5.3 MediaCodec buffer泄漏和死锁现象录制一段时间后画面卡死日志里dequeueInputBuffer一直返回-1或者ANR问题随机出现压测时必现。原因同步模式里所有dequeue和queue的操作必须集中在同一个线程。如果ImageReader的回调线程和编码循环线程同时访问编码器或者有人在主线程直接做编码循环一旦编码输出挤压主线程就被dequeueOutputBuffer阻塞整个App无响应。解决编码循环放独立线程ImageReader回调里只做入队或只把YUV发到队列绝不在回调里直接dequeue。dequeueOutputBuffer传一个有限的超时值比如10000微秒返回-1表示超时此时应该循环继续而不是无限等。还有一点getInputBuffer拿到ByteBuffer后即便这一帧不需要编码也要queueInputBuffer或clear否则buffer池被占满dequeueInputBuffer永远拿不到可用的输入槽。5.4 硬编兼容性与厂商差异现象同一份代码A品牌的设备录出来一切正常B品牌的设备录出来的视频在电脑上播放花屏或者在C设备上颜色全部偏绿。原因硬件编码器是各家芯片厂商提供的MediaCodec只是统一接口底层对颜色格式的支持、码率控制精度、Profile/Level的实现细节差异很大。比如某些设备只支持NV12不认I420某些设备编码器的VBR实现不严谨码率会冲到设定值的两倍。解决在configure之前先用MediaCodecList跑一次能力探测确认设备支持的color format并按优先级选择。兼容性优先时Profile用BaselineLevel不要想当然按实际分辨率去匹配。颜色偏绿优先怀疑YUV转换代码写死了布局改成运行期读pixelStride和rowStride。这类问题没有银弹只能靠设备矩阵测试来兜底。5.5 旋转信息丢失竖屏录的横着放现象手机竖屏录出来的MP4在电脑上或某些播放器里是横的画面像被转了90度。原因Camera传感器默认是横置的Image输出画面本来就是横的。竖屏拍摄时旋转处理通常靠两件事完成一是预览渲染时用Surface的变换矩阵把画面转正二是把最终旋转角度写进视频文件的metadata。录像链路只做了第一个漏了第二个文件本身没有记录旋转信息播放器自然按原样播。解决在MediaMuxer.start()之前调用setOrientationHint(rotation)rotation由sensorOrientation加Display.getRotation()计算得出。注意setOrientationHint只影响播放器的旋转展示如果想在视频编码时就转正画面需要额外在编码前做旋转处理那又是另一套矩阵逻辑。最省事的可靠方案是前者录横的写旋转metadata让播放器负责转。6. 从能录到能放用工程手段验证你的音视频链路是否真的通6.1 用PTS检查代替肉眼验证“能播放”不等于“链路正确”。肉眼只能看出有没有画面看不出来时间戳是否单调、关键帧间隔是否合理、首帧是否丢了SPS/PPS。我拿到一个录完的文件第一步是抽PTS检查而不是双击播放器。用MediaExtractor把每一帧的PTS和关键帧标志打出来看看有没有回退、间隔是否均匀// 用 MediaExtractor 检查 MP4 的时间戳与关键帧分布 MediaExtractor extractor new MediaExtractor(); extractor.setDataSource(path); MediaFormat format extractor.getTrackFormat(videoTrackIndex); extractor.selectTrack(videoTrackIndex); long lastPts -1, lastKeyFramePts -1; MediaCodec.BufferInfo info new MediaCodec.BufferInfo(); ByteBuffer buffer ByteBuffer.allocate(1024 * 1024); while (true) { info.size extractor.readSampleData(buffer, 0); if (info.size 0) break; info.presentationTimeUs extractor.getSampleTime(); info.flags extractor.getSampleFlags(); if (info.presentationTimeUs lastPts) { Log.w(CheckPts, PTS 回退: info.presentationTimeUs lastPts); } long interval info.presentationTimeUs - lastPts; if (lastPts 0 (interval 200_000)) { Log.w(CheckPts, 帧间隔异常: interval us); } if ((info.flags MediaCodec.BUFFER_FLAG_KEY_FRAME) ! 0) { if (lastKeyFramePts 0) { long gop (info.presentationTimeUs - lastKeyFramePts) / 1_000_000; Log.i(CheckPts, GOP 间隔: gop s); } lastKeyFramePts info.presentationTimeUs; } lastPts info.presentationTimeUs; extractor.advance(); }PTS回退、帧间隔突变、GOP间隔和设定值明显不符三条里任何一条命中都说明链路里有隐藏问题这条链路后面的诸如合流、推流都做不稳。6.2 从录制管线延伸出去的三条进阶路链路确认没问题后就可以往三个方向延伸了一是把ByteBuffer入队改成input surface模式让Camera通过Surface直接送帧给编码器省掉YUV手动拷贝性能明显提升但要对Surface生命周期非常小心二是接AudioRecord加AAC编码把音频轨合进同一个MediaMuxer验证多轨时间戳对齐三是把渲染层从SurfaceView升级到TextureView加OpenGL ES在编码前插入滤镜或特效那就进入了视频编辑的范畴。我现在的习惯是任何一段新写的音视频代码先跑一个最小的录制用例跑完立刻抽PTS查关键帧间隔再做改动。这套流程帮我过滤掉了大量“看着能跑”的假象。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑