简介:面向音视频开发者的安卓硬编码录制示例工程,解决摄像头预览数据经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 与 selectTrack
MediaExtractor 负责把 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 buffer |
2.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 只是释放 Buffer,OpenGL 那边永远看不到新帧。时间戳pts单位是微秒,MediaMuxer、MediaExtractor 都遵守微秒,别在中间某处换算成毫秒再传回去。喂完最后一个样本后,一定要显式送BUFFER_FLAG_END_OF_STREAM,然后在输出侧收到同样 flag 时退出循环,否则解码器会一直占着硬件节点不释放,第二次初始化可能失败。
3. OpenGL 把解码帧变成可回收的纹理:EGL 初始化、OES 采样与编码器输入 Surface
MediaCodec 解码器输出的 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_SIZE | 8 | 让配置匹配大多数解码 Surface |
| EGL_RENDERABLE_TYPE | EGL_OPENGL_ES2_BIT | 固定为 OpenGL ES 2.0 |
| EGL_CONTEXT_CLIENT_VERSION | 2 | 与 EGL_RENDERABLE_TYPE 对应 |
3.2 SurfaceTexture 与 OES 纹理:外部纹理采样与 TransformMatrix
SurfaceTexture 必须绑定到外部纹理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 渲染时序
视频编码器可以接受一个输入 Surface:encoder.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. 编码与封装 mp4:MediaCodec 编码器参数、音频轨与 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_FORMAT | COLOR_FormatSurface | Surface 输入专用 |
| KEY_BIT_RATE | 1~4 Mbps(1080p 30fps) | 按业务帧率动态调 |
| KEY_FRAME_RATE | 与解码源一致 | 若需要变速,在 PTS 处调整 |
| KEY_I_FRAME_INTERVAL | 2~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/PPS,MediaMuxer 内部会从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 次空跑计数,再决定是否结束写入。
本文还有配套的精品资源,点击获取