MediaCodec硬解码+OpenGL渲染:Android MP4录制完整管线解析 简介面向 Android 音视频开发者的 MediaCodeC 硬编码录制源码项目完整演示了如何将 Camera 数据渲染到 OpenGL ES 图像中再通过 MediaCodeC 硬编码写入 mp4 视频文件适合需要实现相机预览、特效渲染与视频录制功能的中高级开发者参考学习。资源包共 35 个文件核心为 15 个 Java 类用于实现 EGL 线程渲染纹理、MediaCodec 输入 Surface 绑定与编码流程6 个 XML 处理界面和资源配置另有 Gradle 构建脚本、properties 配置、jar 依赖、许可证与说明文档等整体压缩后 1.22MB目录结构清晰。目前已有 1516 人浏览学习项目覆盖 MediaCodec.createInputSurface、EGLDisplay 构建与 eglSwapBuffers 缓冲交换等关键知识点通过 EGL 渲染 GL_TEXTURE_EXTERNAL_OES 纹理并交换缓冲直接输出可播放的 mp4 文件。压缩包内附带完整工程目录与说明文档可以帮助读者对照源码梳理 Android 平台从 Camera 采集、OpenGL 渲染到硬编码录像的完整实现链路便于在 Android Studio 中导入、调试与二次开发功能扩展。1. 用 MediaCodec 和 OpenGL 硬解码录制 mp4这条管线到底在拼什么要做 Android 平台的 mp4 录制摆在前面的方案有两条一条是 FFmpeg 软解码加软编码另一条是 MediaCodec 硬解码加 OpenGL 图形处理再用编码器输出 H.264。前者适配性广但在 4K、高帧率视频上既费电又容易扛不住。后者的核心优势是解码帧不经过 CPU 拷贝通过 Surface 直接进入 GPU 纹理录制时再把纹理喂给编码器。这里就围绕这条硬解管线展开从解码器配置、EGL 环境、SurfaceTexture 回调、MediaMuxer 封装到音视频同步给出能直接改的源码片段和参数边界。适合已经会用 MediaCodec 做基础播放、想进一步把 OpenGL 渲染结果录制成 mp4 的 Android 工程师。2. MediaCodec 硬解码到 OpenGL 纹理EGL、Surface 和缓冲区的边界2.1 为什么硬解码不能直接把 Pixels 交给 OpenGLMediaCodec 在 buffer 模式下输出 ByteBuffer里面是 YUV 原始数据。要把 YUV 转成 GL 纹理就得自己做颜色空间转换常见做法是拆出 Y/U/V 三个平面分别建纹理再用 shader 组合。这个路径不是不行但有明显问题每帧都发生一次 YUV 数据拷贝和纹理上传NV12 的 stride 不齐时还要处理 padding。到了 4K 60 帧这几笔内存操作会带来持续发热和掉帧。MediaCodec 硬解码真正有价值的通道是 Surface。在configure()时传入一个 Surface 后解码器内部会把 Surface 当成输出设备图像由硬件合成写到 BufferQueue 上CPU 全程不碰像素。这样录制端 OpenGL 渲染的对象不是“解码器输出的 ByteBuffer”而是“GPU 上的纹理”。这个结构和相机预览很像区别只是生产者从相机变成了解码器。构建录制管线时只要抓住 SurfaceTexture 这层缓冲关系后面所有代码都是围绕它展开的。2.2 配置 MediaCodec 解码器输出到 Surface 的参数和格式常见做法是从 MP4 文件里用MediaExtractor读出轨道信息再把 csd 数据交给解码器。下面这段是完整的初始化代码MediaExtractor extractor new MediaExtractor(); extractor.setDataSource(filePath); MediaFormat format extractor.getTrackFormat(trackIndex); String mime format.getString(MediaFormat.KEY_MIME); MediaCodec decoder MediaCodec.createDecoderByType(mime); decoder.configure(format, outputSurface, null, 0); decoder.start();代码里outputSurface就是由 SurfaceTexture 包装后的 Surface它可以同时被 EGL 作为渲染目标。MediaFormat里的csd-0和csd-1对应 H.264 的 SPS 和 PPS从 MP4 容器读取时已经自动带上了所以这段代码不需要手动塞数据。如果从裸流解码必须自己解析 Annex B 格式用setByteBuffer(csd-0, sps)和setByteBuffer(csd-1, pps)补进去否则部分设备会一直黑屏。解码器相关参数可以用这个表核对实际设置以configure()前拿到的 format 为准参数含义录制场景建议KEY_WIDTH / KEY_HEIGHT解码分辨率与源视频一致不要随意改KEY_COLOR_FORMAT输出颜色格式配置 Surface 输出时无需手动指定csd-0 / csd-1编码器描述信息MP4 容器自动带裸流时必填KEY_FRAME_RATE解码帧率提示播放可用录制时只作参考注意 API 实际拼写是 MediaCodec标题里写成 MediaCodeC 不影响理解代码里大小写错了会直接编译失败。我一般会把MediaFormat.KEY_COLOR_FORMAT打日志确认一次如果看到输出端变成COLOR_FormatSurface说明 Surface 模式生效了。2.3 创建 EGL 环境与解码 Surface 绑定要让解码帧进入 OpenGL 管线需要先创建 EGLDisplay、EGLContext再绑定 SurfaceTexture。下面这段是核心初始化SurfaceTexture surfaceTexture new SurfaceTexture(textureId); surfaceTexture.setOnFrameAvailableListener(listener); Surface outputSurface new Surface(surfaceTexture); EGL14.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext);注意textureId要用GLES20.glGenTextures()先申请一个纹理 id。SurfaceTexture 和 EGLSurface 的关系是解码器把图像放进 BufferQueuenew Surface(surfaceTexture)是生产者端窗口EGLSurface 则是渲染线程访问同一队列的消费端。录制视频时这个 EGL 环境建议单独创建不要直接复用 GLSurfaceView 的主线程上下文否则编码器输入 Surface 的切换会和 UI 渲染互相干扰。EGL 属性列表中颜色缓冲按 8888 来配渲染类型选EGL_OPENGL_ES2_BIT。不要在这里打开 MSAA因为编码器输入端不认多采样 buffer开启后输出画面会有各种兼容性问题。需要抗锯齿时在离屏 FBO 里做后处理过滤或者用 shader 做降采样。3. 从解码循环到 MP4 封装录制端完整的实现路径3.1 SurfaceTexture 把解码帧变成 GL 纹理解码循环是这个管线的中枢。配置好onFrameAvailable回调后在渲染线程里每次拿到新帧都要执行一次surfaceTexture.updateTexImage(); surfaceTexture.getTransformMatrix(texMatrix); drawFrame(texMatrix);updateTexImage()是消费者从 BufferQueue 取最新一帧的入口。取完之后上一帧纹理会被覆盖所以录制状态必须及时把内容提交给编码器。这个调用必须在 GL 线程执行不能放在普通的 handler 回调里直接做。getTransformMatrix()拿到的 4x4 矩阵包含了 SurfaceTexture 对图像方向的修正。直接用单位矩阵贴视频帧在多数设备上会上下颠倒或左右翻转。shader 里要乘上这个矩阵再采样precision mediump float; varying vec2 vTexCoord; uniform mat4 uTexMatrix; attribute vec4 aPosition; attribute vec4 aTexCoord; void main() { gl_Position aPosition; vTexCoord (uTexMatrix * aTexCoord).xy; }这一步处理完之后顶点坐标和纹理坐标已经对齐到解码器内部的方向。录制编码时渲染目标不再是屏幕而是编码器输入的 Surface。3.2 编码端 MediaCodec从 GL 帧到 H.264 码流录制端需要另一个 MediaCodec配置为编码器。视频尺寸建议和解码器输出一致或者用 GL 视口转换。最小可运行配置如下MediaFormat encodeFormat MediaFormat.createVideoFormat(video/avc, width, height); encodeFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); encodeFormat.setInteger(MediaFormat.KEY_BIT_RATE, 8_000_000); encodeFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30); encodeFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); MediaCodec encoder MediaCodec.createEncoderByType(video/avc); encoder.configure(encodeFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface encoderInputSurface encoder.createInputSurface(); encoder.start();这里的关键是encoder.createInputSurface()。它创建的 Surface 会成为 OpenGL 的渲染目标。当所有 EGL 绘制完成后切换eglMakeCurrent到编码器输入 Surface然后提交纹理EGL14.eglMakeCurrent(mEglDisplay, encoderSurface, encoderSurface, mEglContext); GLES20.glViewport(0, 0, width, height); drawFrame(texMatrix); EGL14.eglSwapBuffers(mEglDisplay, encoderSurface);eglSwapBuffers()在这里相当于“提交这一帧给编码器”。编码器不会为每一帧自动打时间戳需要在后续的 BufferInfo 里手动指定。编码器输入 Surface 属于生产者端口如果你在解码暂停时反复绘制同一帧就会产生大量重复帧。录制时应基于解码回调或帧率计时器驱动不要用while(true)盲目循环提交。下面是编码端常用参数表方便在 android studio 里调试时对照参数含义录制时常用设置KEY_COLOR_FORMAT编码器输入颜色格式固定为COLOR_FormatSurfaceKEY_BIT_RATE视频码率1080p 用 8-12 Mbps720p 用 4-6 MbpsKEY_FRAME_RATE编码帧率与源视频一致通常 30 或 60KEY_I_FRAME_INTERVAL关键帧间隔1 秒方便拖动和切片KEY_BITRATE_MODE码率控制模式VBR 画质更好CBR 更稳定3.3 音频录制AudioRecord 和音频编码器mp4 录制需要音频轨。这里不能直接用 MediaRecorder因为它的输出和 OpenGL 渲染是两条独立管线无法混流到同一个 mp4。常见做法是用AudioRecord采集 PCM再送进音频编码器AudioRecord recorder new AudioRecord( MediaRecorder.AudioSource.MIC, sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes); recorder.startRecording(); byte[] audioData new byte[4096]; while (isRecording) { int read recorder.read(audioData, 0, audioData.length); if (read 0) { submitAudioToEncoder(audioData, read); } }submitAudioToEncoder内部要做一次 MediaCodec 编码把 PCM 转成 AAC。编码器格式用audio/mp4a-latm采样率 44100 或 48000比特率 128 kbps 足够。音频编码器和视频编码器一样输出 buffer 最终通过 MediaMuxer 写入同一个 mp4。这里最容易犯的错误是音频线程和视频线程各自维护独立时间戳导致音画不同步后面第 4 章专门说。3.4 MediaMuxer 写入 mp4关键参数和坑编码器输出的 H.264 和 AAC 都要交给 MediaMuxer 封装成 mp4。初始化时指定MUXER_OUTPUT_MPEG_4MediaMuxer muxer new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); int trackIndex muxer.addTrack(encoder.getOutputFormat()); muxer.start(); muxer.writeSampleData(trackIndex, encodedData, bufferInfo); muxer.stop(); muxer.release();addTrack要在拿到第一个非 config 的编码输出后调用也可以在拿到BUFFER_FLAG_CODEC_CONFIG之后调用。有音频轨时需要分别addTrack两次返回两个 index再各自writeSampleData。MediaMuxer 内部会按各轨时间戳排序所以写入顺序不要求严格交替但时间戳必须单调递进。这里有个常见的坑muxer.addTrack()完成后返回值如果没有保存之后writeSampleData会写入错误的轨道。另外mp4 输出路径不要用content://开头写 app 内部缓存目录再用 FileProvider 分享能避开 Android 分区存储带来的文件访问问题。.mp4后缀在mediaRecorder场景会自动加但 MediaMuxer 不会路径要显式写好filename.mp4。4. 音视频同步、帧率和旋转录制质量最常用的三个调参点4.1 时间戳计算与同步窗口时间戳是 mp4 音视频录制里最影响播放体验的参数。视频时间戳不能简单用解码器的 PTS 直接写进 muxer因为 OpenGL 渲染链路里可能丢帧也可能插入重复帧。更稳的做法是用一个单调时钟作为基准在每次提交编码器时记录当前时间。音频这边时间戳要从 PCM 采样累计推算公式是long durationUs readSamples * 1000000L / sampleRate; audioPresentationTimeUs durationUs;每次从AudioRecord读到一段数据就累加这一段对应的时长。不要用System.currentTimeMillis()去算它会受系统时间跳变影响。视频编码器的BufferInfo.presentationTimeUs也要使用同一个单调时钟基准保证两条轨道走同一套时间轴。如果录制完成播放时声音和画面差几百毫秒问题基本都出在音频时间戳使用了独立计数而视频时间戳来自编码器 PTS。4.2 帧率控制与丢帧策略录制过程中解码器回调速度可能超过编码器处理速度。如果不做控制编码器输入队列会堆积mp4 时长变长甚至内存上涨。常见做法是只保留最新一帧丢弃中间帧if (frameAvailable.compareAndSet(true, false)) { surfaceTexture.updateTexImage(); submitToEncoder(); } else { // 上一帧还没提交当前帧直接丢弃 }frameAvailable可以用一个AtomicBoolean在onFrameAvailable里置为 trueGL 线程里检查并提交。这样录制的 fps 会跟随编码器处理能力不会无限制堆积。真机上如果经常触发 else 分支说明编码器性能不足应该降低分辨率或码率而不是增加缓冲。4.3 旋转角度、分辨率对齐和 Android 方向mp4 播放时不带方向信息播放器默认按水平方向渲染。如果源视频来自竖拍或屏幕旋转录制结果需要调用setOrientationHint()muxer.setOrientationHint(rotation);rotation可取 0、90、180、270。这个 hint 只影响播放时的旋转显示不影响编码码流内部的像素排列。更完整的做法是在 GL 渲染阶段就完成方向修正让编码器输入画面已经是期望方向然后setOrientationHint(0)避免播放器二次旋转。分辨率对齐也很重要。编码器输入 Surface 的尺寸必须和MediaFormat里的宽高一致否则部分硬件编码器会初始化失败。竖屏录制时EGL 视口应该设置成height x width而不是沿用源视频横向宽高。用MediaExtractor读取输入视频的KEY_ROTATION能省去很多真机方向适配的调试时间。5. 验证 mp4 结果的三个实际操作android studio 和命令行下的自查5.1 用 MediaExtractor 检查轨道信息录制完成后先在 android studio 里写一段验证代码用 MediaExtractor 读回 mp4 的轨道信息MediaExtractor extractor new MediaExtractor(); extractor.setDataSource(outputPath); for (int i 0; i extractor.getTrackCount(); i) { MediaFormat format extractor.getTrackFormat(i); Log.d(Verify, track i mime format.getString(MediaFormat.KEY_MIME)); }能看到video/avc和audio/mp4a-latm两条轨道说明 muxer 没有丢轨。如果只有视频轨检查音频编码器是否调用了signalEndOfInputStream()或者最后一段 buffer 是否打上了BUFFER_FLAG_END_OF_STREAM。5.2 用命令确认关键帧和时间戳在 android studio 的 Terminal 中把 mp4 拉到电脑后用 ffprobe 检查时间戳adb pull /sdcard/Android/data/com.example.recorder/files/output.mp4 ./output.mp4 ffprobe -v error -select_streams v:0 -show_entries packetpts_time,duration_time -of csvp0 output.mp4正常的 PTS 序列应该是连续递增的。如果出现负值或大段回退说明写入 muxer 的时间戳有问题。再用下面这条命令对比音视频时长ffprobe -v error -show_entries formatduration -of defaultnoprint_wrappers1 output.mp4音视频同步误差在 100ms 内播放体感通常没问题。这一步可以用常见的 mp4 测试视频做自检先录一段已知时长源视频再对比输出文件时长。5.3 性能和兼容性的速查方法性能方面在 android studio 的 Profiler 里看 GL 线程耗时比只看帧率更直接。重点观察eglSwapBuffers是否经常超过 10ms如果是编码器输入 Surface 的切换可能不流畅优先降低编码码率。兼容性方面用MediaCodecList查设备支持的分辨率组合MediaCodecList list new MediaCodecList(MediaCodecList.ALL_CODECS); for (MediaCodecInfo info : list.getCodecInfos()) { if (info.isEncoder() info.getSupportedTypes().contains(video/avc)) { // 检查编码器能力避免配置不支持的分辨率 } }遇到黑屏时先确认updateTexImage()是不是在 GL 线程执行遇到花屏时给纹理设置GL_LINEAR而不是默认的GL_NEAREST。录制链路跑通后还要用系统播放器和第三方播放器各播一遍检查拖动、方向、音画同步三项都正常才算真正交付。本文还有配套的精品资源点击获取