简介:面向 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 交给 OpenGL
MediaCodec 在 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 的关系是:解码器把图像放进 BufferQueue,new 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_FormatSurface |
| KEY_BIT_RATE | 视频码率 | 1080p 用 8-12 Mbps,720p 用 4-6 Mbps |
| KEY_FRAME_RATE | 编码帧率 | 与源视频一致,通常 30 或 60 |
| KEY_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_4:
MediaMuxer 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里置为 true,GL 线程里检查并提交。这样录制的 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 packet=pts_time,duration_time -of csv=p=0 output.mp4正常的 PTS 序列应该是连续递增的。如果出现负值或大段回退,说明写入 muxer 的时间戳有问题。再用下面这条命令对比音视频时长:
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 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。录制链路跑通后,还要用系统播放器和第三方播放器各播一遍,检查拖动、方向、音画同步三项都正常,才算真正交付。
本文还有配套的精品资源,点击获取