
简介面向音视频开发者的安卓硬编码录制示例工程解决摄像头预览数据经OpenGL ES渲染后再通过MediaCodec硬编码生成MP4视频的问题。整体方案围绕MediaCodec的输入Surface与EGL渲染线程展开先创建输入Surface随后将摄像头采集到的YUV帧绑定到外部纹理GL_TEXTURE_EXTERNAL_OES经过着色器绘制到EGL表面完成一帧渲染后调用缓冲交换把图像内容同步送入MediaCodec编码器最终封装成可播放的MP4文件。压缩包共35个文件以Java源码为主体XML资源文件与Gradle构建脚本配合完成工程配置另含PNG示意图便于对照理解整个资源包仅1.22MB轻量清晰。目前已有1516人学习下载源码目录覆盖Camera预览、EGL环境搭建、纹理渲染、MediaCodec输出等关键模块调用链完整代码中还包含EGL初始化、纹理创建、渲染循环及编码器状态流转等可复用工具适合需要快速搭建硬编码录制能力或研究OpenGL与MediaCodec协作机制的开发者直接移植参考。1. 为什么 Android 视频处理要把 MediaCodec、OpenGL 和 mp4 录制绑在一起在 Android Studio 里做视频剪辑、连拍动画或实时特效时最容易被卡住的一环不是算法而是“解码出来的帧怎么既能在屏幕上预览又能被重新编码写回 mp4”。MediaPlayer 把渲染流程封得太死拿不到帧纯软解码又根本扛不住 4K 60fps。MediaCodec 硬件解码后把画面交给 OpenGL 纹理再用 OpenGL 做滤镜、旋转、裁剪最后把处理结果回送给编码器输入 Surface这条链路就是当前 Android 端视频轨处理的标准答案。下面直接讲清“解码—处理—编码—录制”四个环节的参数和时序让每一步都能在一套工程里对接上。2. MediaCodec 硬解码一条视频MediaExtractor 选流、Surface 模式与喂帧循环MediaCodec 解码器是整个链路里最容易“先跑起来但不知道对不对”的模块。它要求输入侧是压缩码流输出侧要么是 ByteBuffer要么是 Surface录制场景必须选 Surface 模式否则 GPU 再绕一圈把纹理拷回内存性能就白做了。下面从容器读取开始逐步落到 PTS 与 Buffer 循环。2.1 从 mp4 里挑出视频轨道MediaExtractor 的 setDataSource 与 selectTrackMediaExtractor 负责把 mp4 容器里的视频流、音频流拆出来。使用它时不要凭轨道下标写死而是逐个检查 MIME 类型因为同一个容器里顺序可能是音频在前。MediaExtractor extractor new MediaExtractor(); extractor.setDataSource(inputPath); int videoTrack -1; int audioTrack -1; for (int t 0; t extractor.getTrackCount(); t) { MediaFormat fmt extractor.getTrackFormat(t); String mime fmt.getString(MediaFormat.KEY_MIME); if (mime ! null) { if (videoTrack 0 mime.startsWith(video/)) { videoTrack t; } else if (audioTrack 0 mime.startsWith(audio/)) { audioTrack t; } } } if (videoTrack 0) { throw new IllegalArgumentException(no video track); } extractor.selectTrack(videoTrack);这里setDataSource可以传文件路径、FileDescriptor 或 uri。如果项目里拿的是 content 形式的 uri优先用setDataSource(context, uri, null)。selectTrack之后readSampleData只读当前选中轨道的样本所以音频轨要等音频编码器准备好后再切轨。视频轨的宽高、帧率、时长都从getTrackFormat里取保存到成员变量里后面编码器和 OpenGL 都要用。2.2 配置解码器走 Surface 模式MediaFormat 里这些键才管用解码器和编码器不同它不需要手动指定输出颜色格式而是靠传入的输出 Surface 决定底层输出方式。把上一节拿到的MediaFormat原样传给解码器另外传入一个从SurfaceTexture构造的Surface。MediaCodec decoder MediaCodec.createDecoderByType( decodeFormat.getString(MediaFormat.KEY_MIME)); SurfaceTexture st new SurfaceTexture(/* 待绑定 OES 纹理 id */); Surface stSurface new Surface(st); decoder.configure(decodeFormat, stSurface, null, 0); decoder.start();SurfaceTexture必须在 OpenGL 环境中绑定一个外部纹理后创建所以实际代码里要先完成第 3 章的 EGL 初始化再把纹理 id 传进来。configure 的第三个参数crypto传 null第四个 flags 传 0代表普通硬解如果把 Surface 换成 null解码器会退回 ByteBuffer 模式那就拿不到 OpenGL 纹理后续录制链路就断了。下表是解码时常用MediaFormat键在硬解流程中的角色键名谁决定值说明KEY_MIME容器决定创建哪种解码器KEY_WIDTH / HEIGHT容器供 OpenGL 视口与编码器尺寸使用KEY_COLOR_FORMAT解码器/驱动不要手动设置ByteBuffer 模式下要看Surface 模式下没有意义KEY_ROTATION容器mp4 的旋转信息在 API 23 前不入 codec需要自己从 metadata 拿KEY_MAX_INPUT_SIZE解码器部分设备要求设置否则 dequeue 不出 input buffer2.3 循环喂码流dequeueInputBuffer / queueInputBuffer 与 PTS 纪律Surface 模式的输出侧不用手动取 ByteBuffer但输入侧还是老一套向解码器申请输入 Buffer拷入压缩数据再交给解码器带上时间戳。MediaCodec.BufferInfo outInfo new MediaCodec.BufferInfo(); boolean inputDone false; while (!isFinish) { if (!inputDone) { int inIndex decoder.dequeueInputBuffer(10_000); if (inIndex 0) { ByteBuffer inBuf decoder.getInputBuffer(inIndex); int sampleSize extractor.readSampleData(inBuf, 0); if (sampleSize 0) { decoder.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); inputDone true; } else { long pts extractor.getSampleTime(); // 单位µs decoder.queueInputBuffer(inIndex, 0, sampleSize, pts, 0); extractor.advance(); } } } int outIndex decoder.dequeueOutputBuffer(outInfo, 10_000); if (outIndex 0) { decoder.releaseOutputBuffer(outIndex, true); } else if (outIndex MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // Surface 模式下同样会先通知一次输出格式变化 } else if (outIndex MediaCodec.INFO_TRY_AGAIN_LATER) { // 没有可用输出继续循环或稍等 } }releaseOutputBuffer(outIndex, true)里的最后一个布尔值必须为 true才能把解码帧渲染到 SurfaceTexture传 false 只是释放 BufferOpenGL 那边永远看不到新帧。时间戳pts单位是微秒MediaMuxer、MediaExtractor 都遵守微秒别在中间某处换算成毫秒再传回去。喂完最后一个样本后一定要显式送BUFFER_FLAG_END_OF_STREAM然后在输出侧收到同样 flag 时退出循环否则解码器会一直占着硬件节点不释放第二次初始化可能失败。3. OpenGL 把解码帧变成可回收的纹理EGL 初始化、OES 采样与编码器输入 SurfaceMediaCodec 解码器输出的 Surface 是一层窗口真正的帧数据要通过 SurfaceTexture 的 updateTexImage 变成 GPU 纹理。这一章把 OpenGL 环境配置和“从解码纹理到编码器输入”的桥搭好。注意这里不是渲染到屏幕而是为了处理帧再送回去所以全部用离屏 Surface。3.1 OpenGL 环境配置EGL 上下文、Surface 与 OpenGL ES 版本选择OpenGL 环境配置的核心是 EGL它负责建立 display、config 和 context。视频处理建议用 OpenGL ES 2.0 打底因为 MediaCodec 的 Surface 模式从 API 21 起就稳定支持如果要用高阶特效或 GLES3 的 PBO可以把 client version 提到 3但必须做回落。EGLDisplay dpy EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); EGL14.eglInitialize(dpy, new int[2], 0, new int[2], 0); int[] attrList new int[]{ EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8, EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, EGL14.EGL_NONE }; EGLConfig[] configs new EGLConfig[1]; int[] numConfig new int[1]; if (!EGL14.eglChooseConfig(dpy, attrList, 0, configs, 0, 1, numConfig, 0) || numConfig[0] 0) { throw new RuntimeException(eglChooseConfig failed); } int[] ctxAttrs new int[]{ EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE }; EGLContext eglContext EGL14.eglCreateContext(dpy, configs[0], EGL14.EGL_NO_CONTEXT, ctxAttrs, 0);先初始化 EGLDisplay再选 config最后创建 context。EGL_RENDERABLE_TYPE必须配成 ES2否则后面 createWindowSurface 绑定编码器输入 Surface 时会报 EGL_BAD_MATCH。建议写个工具类把 EGL 操作包起来并让当前线程只干 OpenGL 的活再调用eglMakeCurrent。编码器输入 Surface 的 EGLSurface 需要创建成 window surface不能是 pBuffer surface。EGL 属性本例取值用途EGL_RED_SIZE / GREEN_SIZE / BLUE_SIZE / ALPHA_SIZE8让配置匹配大多数解码 SurfaceEGL_RENDERABLE_TYPEEGL_OPENGL_ES2_BIT固定为 OpenGL ES 2.0EGL_CONTEXT_CLIENT_VERSION2与 EGL_RENDERABLE_TYPE 对应3.2 SurfaceTexture 与 OES 纹理外部纹理采样与 TransformMatrixSurfaceTexture 必须绑定到外部纹理GL_TEXTURE_EXTERNAL_OES。这种纹理所对应的片段着色器不能直接用 sampler2D而是要声明samplerExternalOES扩展。#extension GL_OES_EGL_image_external : require precision mediump float; varying vec2 vTexCoord; uniform samplerExternalOES sTexture; void main() { gl_FragColor texture2D(sTexture, vTexCoord); }每次拿到解码帧后先调用st.updateTexImage()再调用st.getTransformMatrix(mtx)。getTransformMatrix拿到的是 4x4 矩阵它会把视频的旋转、翻转、缩放都处理好顶点着色器里传入这个矩阵后采样出的画面才是正的。float[] texMatrix new float[16]; st.updateTexImage(); st.getTransformMatrix(texMatrix); GLES20.glUseProgram(program); GLES20.glUniformMatrix4fv(uTexMatrixLoc, 1, false, texMatrix, 0); GLES20.glActiveTexture(GLES20.GL_TEXTURE0); GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId);这里不要自己根据KEY_ROTATION手工转除非你从 MediaMetadataRetriever 里也拿到了旋转角。SurfaceTexture 的矩阵已经反映帧当前的方向你只需要在绘制时按需再做 90/180/270 旋转。3.3 把处理后的帧喂给编码器createInputSurface 与 EGL 渲染时序视频编码器可以接受一个输入 Surfaceencoder.createInputSurface()。OpenGL 直接把这个 Surface 当成 EGL window surface 的对象绘制到它上面就等于把数据交给编码器。Surface encodeSurface encoder.createInputSurface(); EGLSurface eglEncodeSurface EGL14.eglCreateWindowSurface(dpy, configs[0], encodeSurface, new int[]{ EGL14.EGL_NONE, EGL14.EGL_NONE }, 0); EGL14.eglMakeCurrent(dpy, eglEncodeSurface, eglEncodeSurface, eglContext); // 解码帧做 OES 采样后绘制到一个离屏 FBO再把 FBO blit 到 encode surface glBindFramebuffer(GL_FRAMEBUFFER, fbo[0]); glViewport(0, 0, width, height); drawOesFrame(texMatrix); // 这里包含 shader 和顶点绘制逻辑 glBindFramebuffer(GL_FRAMEBUFFER, 0); EGL14.eglSwapBuffers(dpy, eglEncodeSurface);每一帧必须调用eglSwapBuffers编码器才会认为一个 Buffer 被提交。不要在同一线程里同时渲染屏幕和编码器输出两个 EGLSurface 会互相抢当前上下文建议解码/处理跑一个线程屏幕预览用另一个 GLSurfaceView。FBO 在这里是可选环节如果只是录制同尺寸画面可以直接把 OES 纹理绘制到 eglEncodeSurface但如果要做滤镜、裁剪、旋转就得先画到 FBO 隔离再把 FBO 纹理画到编码器 surface。4. 编码与封装 mp4MediaCodec 编码器参数、音频轨与 MediaMuxer 写入解码和 OpenGL 都搭好后最后一根管道是把图像和声音封装成 mp4。这里最容易踩的两个坑是编码器输入 Surface 的 format 配置错误导致启动失败MediaMuxer 把 CODEC_CONFIG 当普通帧写进去导致播放器花屏。下面按视频、音频、复用三部分拆开。4.1 视频编码器输入 Surface 与参数表bitrate、帧率、I 帧间隔编码器必须使用 Surface 输入才能和 OpenGL 直接对接。KEY_COLOR_FORMAT在这种情况下必须写死为COLOR_FormatSurface系统会自己管理 YUV 转换。MediaFormat encFormat MediaFormat.createVideoFormat(video/avc, width, height); encFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); encFormat.setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000); encFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30); encFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5); encFormat.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR); MediaCodec encoder MediaCodec.createEncoderByType(video/avc); encoder.configure(encFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface inputSurface encoder.createInputSurface(); encoder.start();配置项里一定要区分 CBR 和 VBR大多数 app 的预览录制用BITRATE_MODE_CBR更稳码率波动小但同样的视频用 VBR 压缩率更高。如果你把setInteger(KEY_BITRATE_MODE, ...)放在 API 21 以下设备上会忽略该字段系统按默认 CBR 走。还需要检查CodecCapabilities是否支持video/avc否则在个别低端机上 configure 会抛异常。参数推荐值说明KEY_COLOR_FORMATCOLOR_FormatSurfaceSurface 输入专用KEY_BIT_RATE1~4 Mbps1080p 30fps按业务帧率动态调KEY_FRAME_RATE与解码源一致若需要变速在 PTS 处调整KEY_I_FRAME_INTERVAL2~5 秒太短体积大太长拖动进度慢KEY_PROFILE / LEVEL不设默认有些设备只支持 Baseline/High建议留给系统4.2 音频转码录制从 MediaExtractor 解 AAC 到编码器再入 Muxer音频如果不改格式可以原封不动地“直通”进 mp4只要保证两条轨道的采样时长对齐。但多数场景需要把背景音乐或麦克风声音重新编码路径是MediaExtractor 选音频轨 → MediaCodec 解码器输出 PCM → 编码器编码 AAC → 写入 Muxer。这里只给出编码器参数。MediaFormat audioFormat MediaFormat.createAudioFormat( audio/mp4a-latm, sampleRate, channelCount); audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, 128_000); audioFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 64 * 1024); MediaCodec audioEncoder MediaCodec.createEncoderByType(audio/mp4a-latm); audioEncoder.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); audioEncoder.start();sampleRate和channelCount应来自音频轨的MediaFormat不要自己写死。如果源文件是 5.1 surround sound test files 这类多声道素材而目标设备编码器只支持双声道你需要先检查CodecCapabilities.isChannelCountSupported()不支持就把采样率降到 44100、声道降到 2。否则 MediaCodec configure 直接返回负值logcat 里只会出现一条 initEncoder failed。音频编码输出的时间戳不能沿用解码器的presentationTimeUs因为在转码过程中输入输出 buffer 数量不是线性的。推荐做法是每提交一个音频输入 buffer 就累计sampleRate / bytesPerSample / channelCount对应的微秒用它作为输出 PTS。4.3 MediaMuxer 封装CODEC_CONFIG 过滤与 PTS 重对齐MediaMuxer 负责把编码器吐出来的 Annex-B 码流变成 mp4 的 avc1 格式同时写入音频轨和视频轨。写入顺序上可以先 addTrack 后 start再循环写两个轨道。int videoTrackIdx muxer.addTrack(encoder.getOutputFormat()); int audioTrackIdx muxer.addTrack(audioEncoder.getOutputFormat()); muxer.start(); void drainEncoder(MediaCodec codec, int trackIdx) { MediaCodec.BufferInfo info new MediaCodec.BufferInfo(); int outIdx; while ((outIdx codec.dequeueOutputBuffer(info, 0)) 0) { ByteBuffer buf codec.getOutputBuffer(outIdx); if ((info.flags MediaCodec.BUFFER_FLAG_CODEC_CONFIG) ! 0) { info.size 0; } if (info.size 0) { buf.position(info.offset); buf.limit(info.offset info.size); muxer.writeSampleData(trackIdx, buf, info); } codec.releaseOutputBuffer(outIdx, false); if ((info.flags MediaCodec.BUFFER_FLAG_END_OF_STREAM) ! 0) { break; } } }BUFFER_FLAG_CODEC_CONFIG的 buffer 里装的是 SPS/PPSMediaMuxer 内部会从getOutputFormat()里自动带上如果你再写一遍mp4 里会出现重复的 avcC播放器通常表现为第一秒花屏或直接不支持。presentationTimeUs在写入 Muxer 之前必须对齐如果视频和音频分别来自两个线程很可能音频 PTS 整体偏移 20ms但 MediaMuxer 不做跨轨校正需要手动记录第一条 audio sample 的 base offset 并减掉。5. 录完 mp4 后怎么验证mp4测试视频、PTS 检查和两个必查 log真正把整套链路跑通后你手里会有一个或多个“看起来正常”的 mp4但用第三方播放器看可能丢帧、音画错位。最后再给一套验证和排查的方法。先准备一个固定短素材比如用 mp4测试视频 这类 1280x720 源文件。素材时长控制 10 秒内别用太长的视频做冒烟测试否则每个参数改一轮都要等几十秒。录完后先在 Android Studio 里看 Logcat 的MediaCodec和MediaMuxer两个 tag如果没有异常再用MediaMetadataRetriever读生成的 mp4 时长与原视频对比偏差超过 100ms 就要检查 PTS 对齐。如果你用小米手机做真机调试别忽略 Android Studio 连不上时自动弹的端口占用提示顺带把“不锁定屏幕”打开录制期间息屏会直接卡死 EGL 线程。排查的第一步永远是打印两路编码器的 dequeueOutputBuffer 次数。假如视频轨道输出了 300 帧但音频轨道只输出了 900 个 AAC packet且每个 packet 的presentationTimeUs不是 21.333...ms 的整数倍基本就是音频缓冲填错。另外视频帧的bufferInfo.flags里的BUFFER_FLAG_KEY_FRAME一定要和writeSampleData一起提交缺少这个 flag 会导致 mp4 拖动进度后画面长时间黑屏修复方法是写一个小工具把每个输出 buffer 的 flags 按位打印出来对比。最后提一个容易漏掉的细节录制过程中muxer.stop()前必须等两个编码器都收到END_OF_STREAM并且把最后一个输出 buffer 都写完。不要在退出循环后立刻muxer.stop()否则最后几帧音频会被丢。我曾在一次老设备排查时发现问题就出在编码器 drain 循环用了 dequeueOutputBuffer(0) 超时主线程直接 break 掉了。改成INFO_TRY_AGAIN_LATER重试后再 break输出 mp4 的时长就正确了。你可以在自己的工程里把 drain 循环改成固定 3 次空跑计数再决定是否结束写入。本文还有配套的精品资源点击获取