1. 项目背景与整体设计思路
1.1 这个示例到底在做什么
安卓摄像头编码这个事,说白了就是把手机摄像头的预览数据拿过来,喂给编码器压成H.264或H.265码流,再封装成MP4或者直接推流。听起来简单,但真动手做的时候,坑比想象的多得多。这个综合应用示例(三)的核心目标,就是打通“摄像头采集→原始帧处理→编码→封装/输出”这条完整链路,让做安卓音视频开发的同行能有一个可以直接参考的骨架。
为什么选FFmpeg来做编码而不是用安卓自带的MediaCodec?这是很多人第一个会问的问题。MediaCodec确实是安卓官方推荐的硬编方案,性能好、功耗低,但它有两个硬伤:一是不同芯片平台的兼容性差异大,同样的参数在高通和联发科上表现可能完全不同;二是封装和推流环节还得另外找库。FFmpeg的优势在于软编方案统一、可控性强,而且编码、封装、协议输出一条龙全包了。当然,实际项目中更常见的做法是MediaCodec硬编+FFmpeg封装,但作为学习和理解编码流程的示例,纯FFmpeg方案更容易把原理讲透。
这个示例适合谁看?如果你已经写过Java、了解安卓基本开发流程,但对音视频编码还停留在“听说过”的阶段,那这个内容就是给你准备的。如果你已经在做音视频项目,但一直用的是封装好的SDK,想搞清楚底层到底发生了什么,同样可以对照着看。
1.2 为什么不用纯Java方案
有人可能会想,既然是Java项目,能不能纯Java搞定?答案是:理论上可以,实际上不现实。安卓摄像头采集到的原始数据是NV21或YUV420格式,数据量巨大——1080p@30fps的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 摄像头采集的关键参数选择
摄像头采集这块,参数选错了后面全白搭。先说分辨率:不是越高越好。720p(1280x720)是软编方案最稳妥的选择,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软编,关键参数有这几个:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| preset | veryfast | 编码速度与压缩率的平衡点 |
| tune | zerolatency | 实时场景必选,禁用B帧和lookahead |
| profile | baseline | 兼容性最好,不支持B帧 |
| bitrate | 2Mbps(720p) | 根据分辨率和帧率调整 |
| gop_size | 30 | 关键帧间隔,等于帧率时约1秒一个I帧 |
| pix_fmt | YUV420P | 必须与输入格式一致 |
preset选veryfast是有讲究的。ultrafast虽然更快,但压缩率太差,同样码率下画质明显下降;medium或slow压缩率好,但编码一帧要几十毫秒,实时场景根本来不及。veryfast在移动端CPU上编码720p大约需要8-12ms每帧,留足了余量。
tune=zerolatency这个参数必须加。不加的话,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-os=android、--arch=arm/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依赖avcodec,avcodec依赖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过慢 | 打印每帧耗时 | 降分辨率或改preset |
| MP4无法播放 | trailer未写或时间戳异常 | ffprobe检查 | 补trailer、修正时间戳 |
| 编码器初始化失败 | 参数不合法 | 检查avcodec_open2返回值 | 逐项核对参数 |
4.4 内存泄漏与资源释放
Native层的资源释放很容易漏。每次avcodec_receive_packet之后必须av_packet_unref,否则packet内部的缓冲区不会释放。AVFrame用完之后要av_frame_free,AVCodecContext要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。音频编码用AAC,FFmpeg的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,用脚本抓下来分析,能快速定位性能瓶颈在哪个环节。比盲目猜测高效得多。