ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

安卓摄像头FFmpeg编码实战:NV21转YUV420P与H.264封装

安卓摄像头FFmpeg编码实战:NV21转YUV420P与H.264封装 1. 项目背景与整体设计思路1.1 这个示例到底在做什么安卓摄像头编码这个事说白了就是把手机摄像头的预览数据拿过来喂给编码器压成H.264或H.265码流再封装成MP4或者直接推流。听起来简单但真动手做的时候坑比想象的多得多。这个综合应用示例三的核心目标就是打通“摄像头采集→原始帧处理→编码→封装/输出”这条完整链路让做安卓音视频开发的同行能有一个可以直接参考的骨架。为什么选FFmpeg来做编码而不是用安卓自带的MediaCodec这是很多人第一个会问的问题。MediaCodec确实是安卓官方推荐的硬编方案性能好、功耗低但它有两个硬伤一是不同芯片平台的兼容性差异大同样的参数在高通和联发科上表现可能完全不同二是封装和推流环节还得另外找库。FFmpeg的优势在于软编方案统一、可控性强而且编码、封装、协议输出一条龙全包了。当然实际项目中更常见的做法是MediaCodec硬编FFmpeg封装但作为学习和理解编码流程的示例纯FFmpeg方案更容易把原理讲透。这个示例适合谁看如果你已经写过Java、了解安卓基本开发流程但对音视频编码还停留在“听说过”的阶段那这个内容就是给你准备的。如果你已经在做音视频项目但一直用的是封装好的SDK想搞清楚底层到底发生了什么同样可以对照着看。1.2 为什么不用纯Java方案有人可能会想既然是Java项目能不能纯Java搞定答案是理论上可以实际上不现实。安卓摄像头采集到的原始数据是NV21或YUV420格式数据量巨大——1080p30fps的NV21帧每帧大约3MB一秒就是90MB。纯Java做格式转换和编码性能根本扛不住帧率会掉到个位数。所以这个示例的架构是Java层负责摄像头控制和UI交互Native层C/C负责调用FFmpeg做编码和封装。两者之间通过JNI桥接。这个分层设计的好处是Java层开发者不需要深入理解FFmpeg的API细节只需要把摄像头数据通过回调传下去就行而Native层的编码逻辑可以独立测试和优化。1.3 整体数据流设计整个数据流是这样的Camera2 API或Camera1打开摄像头→设置预览回调→在回调中拿到NV21格式的原始帧→通过JNI把帧数据传到Native层→Native层做颜色空间转换NV21转YUV420P→送入FFmpeg编码器→编码后的AVPacket按时间戳写入封装器→最终输出MP4文件或推流。这里有个关键设计决策颜色空间转换放在Native层做而不是在Java层。原因是NV21转YUV420P涉及逐像素操作Java层做这个转换在1080p下每帧要耗时15-20ms直接吃掉一半的帧间隔预算。放到Native层用C代码做同样的转换只需要2-3ms。这个差距在30fps场景下就是能不能跑满的问题。注意如果你的项目只需要支持特定机型而且对功耗敏感建议优先考虑MediaCodec硬编方案。FFmpeg软编在低端机上跑1080p编码CPU占用率可能超过60%手机很快就会发烫降频。2. 核心细节解析与实操要点2.1 摄像头采集的关键参数选择摄像头采集这块参数选错了后面全白搭。先说分辨率不是越高越好。720p1280x720是软编方案最稳妥的选择1080p在部分中低端机上会掉帧。如果你的目标机型是旗舰机可以上1080p但要做好帧率波动的心理准备。帧率方面24fps是能接受的最低值30fps是标准值。设置的时候要注意Camera2 API的帧率控制是通过CONTROL_AE_TARGET_FPS_RANGE来设置的不是直接设一个数字就完事。你需要先查询设备支持的帧率范围然后从中选一个合适的。预览格式必须选NV21。虽然Camera2支持YUV_420_888但这个格式返回的是三个独立的Plane处理起来更麻烦。NV21是一个连续的byte数组Y分量在前VU交错在后处理逻辑简单直接。当然NV21的缺点是色彩精度略低但对于编码场景来说完全够用。// Camera2 预览请求配置的关键片段 CaptureRequest.Builder builder cameraDevice.createCaptureRequest( CameraDevice.TEMPLATE_RECORD); builder.addTarget(previewSurface); builder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, new Range(30, 30)); builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO);2.2 NV21到YUV420P的转换逻辑FFmpeg的编码器libx264默认接受YUV420P格式的输入。NV21和YUV420P的区别在于UV分量的排列顺序NV21是VU交错V在前U在后YUV420P是U平面在前、V平面在后各自连续存储。转换的核心操作是Y分量直接拷贝因为两者的Y排列完全一致UV分量需要从交错格式拆分成两个独立平面并且交换U和V的顺序。这个操作在C层用指针遍历实现效率最高。// NV21 转 YUV420P 的核心逻辑 void NV21ToYUV420P(uint8_t *nv21, uint8_t *yuv420p, int width, int height) { int ySize width * height; int uvSize ySize / 4; // Y 分量直接拷贝 memcpy(yuv420p, nv21, ySize); // UV 分量拆分并交换顺序 uint8_t *uPlane yuv420p ySize; uint8_t *vPlane uPlane uvSize; uint8_t *vuInterleaved nv21 ySize; for (int i 0; i uvSize; i) { vPlane[i] vuInterleaved[i * 2]; // V 在前 uPlane[i] vuInterleaved[i * 2 1]; // U 在后 } }这段代码看起来简单但有个容易踩的坑width和height必须是偶数。如果摄像头返回的预览尺寸是奇数某些前置摄像头会出现这种情况uvSize的计算会出错导致内存越界。所以采集端一定要强制设置偶数分辨率。2.3 FFmpeg编码器的参数配置编码器参数直接决定了输出画质、码率和CPU占用。这个示例用的是libx264软编关键参数有这几个参数推荐值说明presetveryfast编码速度与压缩率的平衡点tunezerolatency实时场景必选禁用B帧和lookaheadprofilebaseline兼容性最好不支持B帧bitrate2Mbps720p根据分辨率和帧率调整gop_size30关键帧间隔等于帧率时约1秒一个I帧pix_fmtYUV420P必须与输入格式一致preset选veryfast是有讲究的。ultrafast虽然更快但压缩率太差同样码率下画质明显下降medium或slow压缩率好但编码一帧要几十毫秒实时场景根本来不及。veryfast在移动端CPU上编码720p大约需要8-12ms每帧留足了余量。tunezerolatency这个参数必须加。不加的话x264默认会缓存若干帧做前瞻分析导致编码输出延迟好几帧。对于实时采集场景这个延迟是不可接受的。// 编码器初始化关键代码 AVCodec *codec avcodec_find_encoder(AV_CODEC_ID_H264); AVCodecContext *ctx avcodec_alloc_context3(codec); ctx-width width; ctx-height height; ctx-time_base (AVRational){1, fps}; ctx-framerate (AVRational){fps, 1}; ctx-pix_fmt AV_PIX_FMT_YUV420P; ctx-bit_rate 2000000; ctx-gop_size 30; ctx-max_b_frames 0; av_opt_set(ctx-priv_data, preset, veryfast, 0); av_opt_set(ctx-priv_data, tune, zerolatency, 0); avcodec_open2(ctx, codec, NULL);实操心得在调试阶段建议把编码后的裸H.264流先存成文件用播放器验证画面是否正常。确认编码没问题之后再接封装和推流。这样出问题的时候能快速定位是编码环节还是传输环节。3. 实操过程与核心环节实现3.1 环境搭建与依赖配置先搞定FFmpeg的交叉编译。安卓平台需要针对不同的ABI分别编译至少覆盖armeabi-v7a和arm64-v8a。编译脚本网上有很多模板核心是配置好NDK路径、指定--target-osandroid、--archarm/arm64、--enable-cross-compile然后disable掉不需要的模块来减小库体积。编译出来的产物是.so动态库和头文件。把.so放到jniLibs对应ABI目录下头文件放到cpp目录下然后在CMakeLists.txt里链接。# CMakeLists.txt 关键配置 add_library(avcodec SHARED IMPORTED) set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libavcodec.so) target_link_libraries(native-lib avcodec avformat avutil swscale android log)这里有个细节FFmpeg的库之间有依赖顺序avformat依赖avcodecavcodec依赖avutil链接的时候顺序不能乱。另外swscale虽然这个示例里没直接用到因为颜色转换是自己写的但封装MP4的时候可能会间接依赖建议一起链上。3.2 JNI接口设计与数据传递Java层和Native层的接口设计要尽量简单减少跨语言调用的开销。核心接口就三个初始化编码器、送入一帧数据、停止编码。public class VideoEncoder { static { System.loadLibrary(native-lib); } public native int init(int width, int height, int fps, String outputPath); public native int encodeFrame(byte[] nv21Data, long ptsUs); public native int stop(); }encodeFrame的参数里ptsUs是帧的显示时间戳单位微秒。这个时间戳必须单调递增否则封装出来的MP4播放会出问题。计算方式是第一帧的时间戳为0之后每帧加上1000000/fps。比如30fps的话每帧间隔33333微秒。注意不要用System.currentTimeMillis()来生成时间戳。这个值受系统时间调整影响可能回退。应该用SystemClock.elapsedRealtimeNanos()除以1000来得到单调递增的微秒值。数据传递这块byte[]从Java传到Native会触发一次内存拷贝。720p的NV21帧大约1.3MB拷贝一次大约1-2ms。如果嫌这个开销大可以用DirectByteBuffer来避免拷贝但管理起来更复杂示例里为了简洁还是用byte[]。3.3 编码循环与时间戳管理编码循环的逻辑是收到一帧就转格式、送编码、取码流、写封装。这里的关键是时间戳的传递。FFmpeg的AVFrame有个pts字段单位是编码器time_base。如果time_base设为{1, fps}那pts就是帧序号0, 1, 2, ...。// 编码一帧的完整流程 int encodeFrame(uint8_t *nv21Data, long ptsUs) { // 1. 转换颜色空间 NV21ToYUV420P(nv21Data, yuvBuffer, width, height); // 2. 填充 AVFrame frame-data[0] yuvBuffer; frame-data[1] yuvBuffer width * height; frame-data[2] frame-data[1] width * height / 4; frame-pts frameIndex; // 3. 送入编码器 avcodec_send_frame(codecCtx, frame); // 4. 取出编码后的包 while (avcodec_receive_packet(codecCtx, packet) 0) { packet-stream_index videoStreamIndex; av_packet_rescale_ts(packet, codecCtx-time_base, stream-time_base); av_interleaved_write_frame(formatCtx, packet); av_packet_unref(packet); } return 0; }av_packet_rescale_ts这一步不能省。编码器的time_base和封装器的time_base通常不一样不做转换的话写进去的时间戳就是错的播放器要么快放要么慢放。3.4 封装输出与文件收尾封装用avformat的API流程是avformat_alloc_output_context2创建上下文→avformat_new_stream添加视频流→设置stream的codecpar参数→avio_open打开输出→avformat_write_header写头→循环写包→av_write_trailer写尾→清理资源。最容易出问题的是av_write_trailer。这个函数负责把MP4的moov box写到文件末尾如果不调用或者调用前就关闭了文件输出的MP4是损坏的播放器打不开。所以stop()方法里必须确保先写trailer再释放资源。int stop() { // 冲刷编码器里剩余的帧 avcodec_send_frame(codecCtx, NULL); while (avcodec_receive_packet(codecCtx, packet) 0) { av_interleaved_write_frame(formatCtx, packet); av_packet_unref(packet); } // 写文件尾 av_write_trailer(formatCtx); // 释放资源 avio_closep(formatCtx-pb); avformat_free_context(formatCtx); avcodec_free_context(codecCtx); return 0; }实操心得调试阶段可以在stop()里加日志确认av_write_trailer的返回值是0。如果返回负值大概率是文件写入权限问题或者磁盘空间不足。另外编码器冲刷那一步很容易忘忘了的话最后几帧会丢失。4. 常见问题与排查技巧实录4.1 画面花屏或颜色异常花屏是最常见的问题表现是画面出现绿色或紫色的条纹、块状噪点。根本原因通常是颜色空间转换出错。排查步骤先确认摄像头输出的确实是NV21格式有些设备在特定分辨率下会返回其他格式然后检查NV21ToYUV420P函数的width和height参数是否与摄像头输出一致最后确认YUV420P缓冲区的分配大小是否正确应该是widthheight3/2字节。颜色异常比如人脸发蓝通常是U和V搞反了。NV21是V在前U在后转成YUV420P的时候U平面在前V平面在后这个交换逻辑写反了就会偏色。4.2 编码帧率不达标如果编码出来的视频明显卡顿先看日志里每帧的编码耗时。720p在veryfast preset下超过15ms就说明有问题。可能的原因CPU降频手机发烫导致、preset设得太慢、分辨率太高。解决办法降低分辨率到540p、把preset改成ultrafast试试、检查是否有其他后台线程抢占CPU。还有一个容易被忽略的点摄像头回调本身可能就不稳定。有些设备在预览回调里做耗时操作会导致掉帧。解决办法是把回调里的数据拷贝到一个队列里由独立线程去取数据做编码避免阻塞摄像头线程。4.3 输出的MP4无法播放这个问题通常有三个原因一是av_write_trailer没调用或调用失败moov box没写进去二是时间戳不单调播放器解析失败三是封装器的time_base设置不对导致时间戳溢出或为负。排查方法用ffprobe命令行工具检查文件信息。如果提示“moov atom not found”就是trailer的问题如果提示“non-monotonous DTS”就是时间戳问题。时间戳问题可以通过在写包之前加一层检查来解决确保每个包的pts严格大于前一个包的pts。问题现象可能原因排查方法解决方案画面花屏颜色转换错误检查NV21转换函数确认格式和参数颜色偏蓝/偏紫U/V顺序颠倒检查UV平面赋值交换U和V帧率不达标CPU降频或preset过慢打印每帧耗时降分辨率或改presetMP4无法播放trailer未写或时间戳异常ffprobe检查补trailer、修正时间戳编码器初始化失败参数不合法检查avcodec_open2返回值逐项核对参数4.4 内存泄漏与资源释放Native层的资源释放很容易漏。每次avcodec_receive_packet之后必须av_packet_unref否则packet内部的缓冲区不会释放。AVFrame用完之后要av_frame_freeAVCodecContext要avcodec_free_context。这些释放操作要放在JNI的stop方法里统一做而且要做好空指针判断避免重复释放导致崩溃。还有一个隐蔽的坑如果编码过程中发生异常提前返回已经分配的资源没有释放就会泄漏。建议用goto cleanup的模式来组织代码确保所有退出路径都经过资源释放逻辑。int encodeFrame(uint8_t *data, long pts) { int ret 0; AVFrame *frame NULL; AVPacket *pkt NULL; frame av_frame_alloc(); if (!frame) { ret -1; goto cleanup; } // ... 编码逻辑 ... cleanup: if (frame) av_frame_free(frame); if (pkt) av_packet_free(pkt); return ret; }这个模式在C语言项目里很常见能有效避免资源泄漏。虽然写起来啰嗦一点但比出了内存泄漏再去排查要省事得多。4.5 不同安卓版本的兼容性处理安卓10以后摄像头权限和文件存储权限的管理更严格了。摄像头权限需要在运行时动态申请文件输出路径不能用外部存储的绝对路径要用应用私有目录或者通过MediaStore API来创建文件。另外安卓11以上对后台启动摄像头有限制如果应用切到后台摄像头回调会停止编码也就断了。这个行为需要在业务逻辑上处理比如切后台时主动停止编码并保存文件。安卓版本碎片化还带来一个问题Camera2 API在不同厂商的ROM上行为不一致。有些设备不支持某些分辨率组合有些设备的预览回调格式与声明的不符。稳妥的做法是在初始化时枚举设备支持的配置选一个最接近目标参数的组合而不是硬编码参数。5. 性能优化与进阶方向5.1 减少内存拷贝次数当前方案里数据从摄像头到编码器经历了三次拷贝摄像头驱动到Java byte[]、Java到Native、NV21到YUV420P。每次拷贝都是开销。优化方向是用DirectByteBuffer让Java和Native共享内存省掉中间那次拷贝颜色转换可以用NEON指令集加速在arm64上能快3-5倍。NEON加速的思路是每次处理16个字节用vld1q_u8加载、vst1q_u8存储配合vrev16q_u8做字节交换。对于UV交错转平面的操作NEON的查表指令vqtbl1q_u8特别适合。不过NEON代码写起来比较复杂建议先用C版本跑通确认功能正确之后再考虑优化。5.2 硬编软编混合方案纯软编在高端机上跑720p没问题但要想上1080p或者60fps就必须用MediaCodec硬编。混合方案的架构是MediaCodec负责编码输出H.264裸流FFmpeg负责封装和推流。这样既利用了硬件编码的性能优势又保留了FFmpeg在封装和协议支持上的灵活性。MediaCodec的输出格式是Annex-B格式的H.264每个NALU前面有00 00 00 01起始码。FFmpeg封装MP4需要的是AVCC格式NALU前面是长度前缀。这两种格式的转换需要在Native层做逻辑不复杂但容易出错主要是起始码的识别和长度字段的字节序处理。5.3 推流场景的额外考量如果目标不是存文件而是推流那还需要考虑网络抖动和码率自适应。FFmpeg支持直接输出到RTMP或RTSP协议但网络不稳定的时候需要做丢帧或降码率处理。一个实用的策略是维护一个发送队列当队列积压超过阈值时主动丢弃非关键帧保证关键帧能及时发出去。推流场景下GOP设置也要调整。存文件的时候GOP可以设大一点比如60或120来提升压缩率但推流场景GOP要小30左右这样新加入的观众能更快看到画面。另外推流场景建议开启repeat_headers选项让每个关键帧前面都带上SPS/PPS参数避免播放端因为丢失参数集而无法解码。实操心得推流测试的时候不要只看发送端是否正常一定要用播放器实际拉流看效果。有些问题比如时间戳跳变、参数集丢失在发送端看不出来只有播放端才会暴露。建议用两个设备一个推一个拉模拟真实使用场景。5.4 音频同步的处理思路这个示例只做了视频编码但实际项目通常需要音视频同步。音频采集用AudioRecord采样率一般选44100Hz或48000Hz格式用16bit PCM。音频编码用AACFFmpeg的libfdk_aac或内置的aac编码器都可以。音视频同步的核心是时间戳对齐。视频帧的时间戳基于摄像头采集时刻音频帧的时间戳基于AudioRecord的录制时刻。两者都转换成微秒单位后以音频时间戳为基准视频帧根据时间戳差值决定是立即编码还是等待。如果视频超前太多就丢帧落后太多就重复上一帧。这个逻辑在封装层做FFmpeg的av_interleaved_write_frame会自动根据时间戳做交织但前提是时间戳本身要准确。6. 一些踩过的坑和实际体会摄像头采集这块最深的体会是不要相信任何设备的默认行为。同一个API调用在不同品牌不同型号的手机上返回的结果可能完全不一样。我遇到过某设备在720p下返回的NV21帧实际是640x480的也遇到过某设备预览回调的帧率标称30fps实际只有15fps。所以初始化的时候一定要做参数校验把实际拿到的参数打印出来确认。FFmpeg的版本选择也有讲究。太老的版本3.x以前API差异大网上的示例代码很多对不上太新的版本6.x以后有些API废弃了编译的时候一堆警告。建议用4.4或5.1这种长期支持版本API稳定资料也多。编码参数调优是个反复试验的过程。同样的preset和码率在室内光线充足和室外逆光场景下的画质表现可能差很多。如果项目对画质有要求建议做一套自适应码率逻辑根据画面复杂度动态调整编码参数。简单做法是统计每帧编码后的实际大小如果连续几帧都超过目标码率的1.5倍就适当降低码率或提高QP值。最后说一个调试技巧在Native层加一个统计模块记录每帧的采集时间、转换时间、编码时间、写入时间。这些数据输出到logcat用脚本抓下来分析能快速定位性能瓶颈在哪个环节。比盲目猜测高效得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进