简介:FFmpegANativeWindow 是一份面向 Android 原生音视频开发者的实战项目源码,聚焦于用 FFmpeg 解码视频帧并渲染到 ANativeWindow,解决原生层视频播放与硬件加速渲染的落地问题,适合具备 C/C++ 与 JNI 基础、希望深入音视频处理的中高级开发者。资源包共 136 个文件,以 106 个 C 头文件、4 个 cpp 源文件、2 个 Java 文件及若干 xml、mk、so 等为主,涵盖解码器接口、渲染逻辑与 Android 工程配置,压缩包约 6.79MB。项目完整呈现了从 avformat_open_input 打开流、avcodec 解码,到 YUV 色彩空间转换、ANativeWindow 缓冲区申请与提交的链路,并涉及解码与渲染线程同步、音视频时间戳对齐、错误处理及资源生命周期管理等关键细节。已有 384 人学习,读者可借此掌握 FFmpeg 集成、原生窗口渲染与性能优化的完整思路,是进阶 Android 音视频开发的实用参考。
1. 从解码到上屏:FFmpeg 配 ANativeWindow 到底解决了什么
如果你在 Android 上写过视频播放,大概率经历过这个阶段:FFmpeg 解码出来的 AVFrame 拿到了,YUV 数据在内存里躺着,但屏幕上什么都没有。软解方案里最常见的做法是 sws_scale 转成 RGBA,再塞给 ImageView 或者 SurfaceView 的 Canvas 去画——帧率一高,CPU 直接拉满,1080p 就开始掉帧。这个资源要解决的就是这段「最后一公里」:把 FFmpeg 解出来的原始帧,通过 NDK 原生层直接渲染到 ANativeWindow,绕开 Java 层的 Bitmap 和 Canvas 拷贝。
它适合两类人:一是已经能用 FFmpeg 解出帧、但卡在渲染环节的 Android NDK 开发者;二是想搞清楚「零拷贝上屏」这条链路到底怎么走通的音视频工程师。核心链路是AVFrame → ANativeWindow_Buffer → 像素格式匹配 → 提交,中间涉及像素格式选择、stride 对齐、缓冲区锁定这几件事,每一件都有翻车的余地。下面按「资源是什么 → 怎么用 → 坑在哪」的顺序拆开讲。
2. 渲染链路拆解:ANativeWindow 的 Buffer 机制与像素格式选型
2.1 为什么不用 Surface + Canvas,而要走 ANativeWindow
Java 层的 Surface 最终也是映射到 native 的 ANativeWindow,但中间隔了 JNI 调用和 Bitmap 拷贝。用 Canvas 画一帧的典型开销是:解码帧 → sws_scale 转 RGBA → 创建/复用 Bitmap → Canvas.drawBitmap → Surface 合成。这里面 sws_scale 的 RGBA 转换本身就是一次全帧遍历,Bitmap 的像素拷贝又是一次,1080p 一帧 8MB 左右的数据来回搬,60fps 下内存带宽直接吃紧。
ANativeWindow 的路子是把这块内存直接暴露给 native 层:ANativeWindow_lock拿到一个可写的 buffer 指针,你把解码后的像素按行写进去,ANativeWindow_unlockAndPost提交。省掉了 Java 对象创建和一次拷贝,CPU 占用能降一大截。代价是你得自己处理像素格式和 stride,不能再指望 Canvas 帮你兜底。
2.2 像素格式:RGBA8888 还是 YUV420,这是个取舍
ANativeWindow 支持的格式由ANativeWindow_setBuffersGeometry决定,常见的有WINDOW_FORMAT_RGBA_8888和WINDOW_FORMAT_RGBX_8888。注意,标准 ANativeWindow 接口并不直接接受 YUV420 的 planar 数据——这是很多人第一个误解,以为设个 YUV 格式就能把 AVFrame 的 data[0]/data[1]/data[2] 直接怼进去。
实际做法通常是:FFmpeg 解码输出多为 YUV420P,你需要用 sws_scale 转成 RGBA8888,再写入 ANativeWindow 的 buffer。这看起来和 Canvas 方案一样都要转 RGBA,但区别在于省掉了 Bitmap 那一层拷贝,而且转换和写入都在 native 层完成,没有 JNI 往返。
// 设置窗口缓冲区几何:宽、高、像素格式 // 注意宽高要和视频解码尺寸一致,否则会拉伸 ANativeWindow_setBuffersGeometry(window, width, height, WINDOW_FORMAT_RGBA_8888); // 准备 sws 上下文:YUV420P -> RGBA8888 struct SwsContext *sws_ctx = sws_getContext( width, height, AV_PIX_FMT_YUV420P, // 输入格式 width, height, AV_PIX_FMT_RGBA, // 输出格式 SWS_BILINEAR, NULL, NULL, NULL);参数说明:SWS_BILINEAR是缩放算法,如果解码尺寸和窗口尺寸一致,其实用SWS_POINT更快,因为没有实际缩放。sws_getContext的输入输出宽高要分别填,如果要做等比缩放,这里就是缩放发生的地方。转换目标用AV_PIX_FMT_RGBA而不是AV_PIX_FMT_RGB0,是因为 ANativeWindow 的 RGBA_8888 期望的字节序就是 R、G、B、A。
2.3 stride 对齐:最容易被忽略的一行
ANativeWindow_lock 拿到的ANativeWindow_Buffer结构里有个stride字段,它表示每行像素实际占用的像素数,可能大于窗口宽度。这是硬件对齐要求导致的,比如宽度 1080,stride 可能是 1088。如果你按 width 去写,画面会斜切。
ANativeWindow_Buffer buffer; if (ANativeWindow_lock(window, &buffer, NULL) == 0) { // 目标指针:注意用 stride 而不是 width 计算行偏移 uint8_t *dst = (uint8_t *)buffer.bits; // sws_scale 的输出直接写到 buffer,但行跨度要用 stride*4 uint8_t *dst_data[4] = { dst, NULL, NULL, NULL }; int dst_linesize[4] = { buffer.stride * 4, 0, 0, 0 }; sws_scale(sws_ctx, frame->data, frame->linesize, 0, height, dst_data, dst_linesize); ANativeWindow_unlockAndPost(window); }逻辑说明:buffer.stride * 4是因为 RGBA 每像素 4 字节,stride 的单位是像素。sws_scale的srcSliceH传 height,表示整帧转换。转换完直接 unlockAndPost,硬件合成器会把这帧显示出去。这里没有额外的 memcpy,sws_scale 的输出就是最终上屏的数据。
3. 从零搭起:Native 层初始化、解码循环与上屏代码
3.1 拿到 ANativeWindow:从 Java Surface 到 native 指针
ANativeWindow 不能凭空创建,它来自 Java 层的 Surface。典型做法是在 Java 侧把 Surface 传给 native,或者用ANativeWindow_fromSurface转换。
#include <android/native_window_jni.h> // env 和 surface 从 JNI 调用传入 ANativeWindow *window = ANativeWindow_fromSurface(env, surface); if (window == NULL) { // Surface 无效,常见于 Surface 还没创建好就调用 return; } // 设置格式和尺寸 ANativeWindow_setBuffersGeometry(window, width, height, WINDOW_FORMAT_RGBA_8888);参数说明:ANativeWindow_fromSurface会增加引用计数,用完要ANativeWindow_release。setBuffersGeometry的宽高如果和视频不一致,会触发硬件缩放,画质和性能都受影响,建议保持一致。常见做法是在 SurfaceHolder 的surfaceCreated回调里触发 native 初始化,避免拿到空 Surface。
3.2 解码循环:av_read_frame 到 avcodec_send_packet
FFmpeg 新版 API 用 send/receive 模式,不再用avcodec_decode_video2。一个最小解码循环长这样:
AVPacket *pkt = av_packet_alloc(); AVFrame *frame = av_frame_alloc(); while (av_read_frame(fmt_ctx, pkt) >= 0) { if (pkt->stream_index == video_stream_idx) { // 发送压缩包给解码器 int ret = avcodec_send_packet(codec_ctx, pkt); if (ret < 0 && ret != AVERROR(EAGAIN)) { // 真正的错误,不是缓冲区满 break; } // 接收解码后的帧,可能一次 send 对应多帧输出 while (avcodec_receive_frame(codec_ctx, frame) == 0) { render_frame(window, frame, sws_ctx); av_frame_unref(frame); } } av_packet_unref(pkt); }逻辑说明:avcodec_send_packet返回EAGAIN表示解码器内部缓冲区满,需要先 receive 再继续 send,不是错误。avcodec_receive_frame返回 0 表示拿到一帧,返回EAGAIN表示当前没有更多输出,需要继续送包。这个循环结构是 FFmpeg 4.x 之后的标准写法,老教程里的avcodec_decode_video2已经废弃。
3.3 渲染函数:把 AVFrame 写进 ANativeWindow
把前面几段拼起来,渲染函数的核心就是 lock、sws_scale、unlock:
void render_frame(ANativeWindow *window, AVFrame *frame, struct SwsContext *sws_ctx) { ANativeWindow_Buffer buffer; // 尝试锁定缓冲区,失败可能是窗口正在销毁 if (ANativeWindow_lock(window, &buffer, NULL) != 0) { return; } uint8_t *dst_data[4] = { (uint8_t *)buffer.bits, NULL, NULL, NULL }; int dst_linesize[4] = { buffer.stride * 4, 0, 0, 0 }; sws_scale(sws_ctx, (const uint8_t * const *)frame->data, frame->linesize, 0, frame->height, dst_data, dst_linesize); ANativeWindow_unlockAndPost(window); }参数说明:frame->linesize是源数据的行跨度,可能包含对齐填充,sws_scale 会正确处理。dst_linesize必须用buffer.stride * 4,不能用frame->width * 4,这是 stride 对齐的坑。ANativeWindow_lock的第三个参数是 dirty region,传 NULL 表示整帧更新。
4. 避坑与排查:五个让画面黑屏或花屏的典型问题
4.1 现象:画面全黑,日志无报错
原因:最常见的是ANativeWindow_lock返回非 0 被静默忽略,或者 Surface 还没就绪就调用了渲染。另一个可能是setBuffersGeometry的格式和 sws_scale 输出格式不匹配,比如设了 RGBX 却写 RGBA,alpha 通道被当成了保留位。
解决:在 lock 失败时打日志,确认 Surface 生命周期。格式上,RGBA_8888 对应AV_PIX_FMT_RGBA,RGBX_8888 对应AV_PIX_FMT_RGB0,两者不能混。如果画面全黑但 stride 正常,检查 sws_scale 的返回值是否等于 height。
4.2 现象:画面斜切或右侧有绿边
原因:stride 对齐问题。窗口宽度 1080,stride 可能是 1088,你按 1080 写,每行少写 8 个像素,累积下来画面就斜了。绿边通常是 YUV 转 RGBA 时 UV 分量没对齐,或者 buffer 尾部残留数据。
解决:所有行偏移计算统一用buffer.stride,不要用 width。如果必须按 width 写,那就要逐行 memcpy 并跳过 padding,但这样多一次拷贝,不推荐。绿边问题检查 sws_scale 的输入 linesize 是否来自 frame 本身,不要手动改。
4.3 现象:播放几秒后卡死或崩溃
原因:ANativeWindow_fromSurface的引用计数没释放,或者解码循环里av_frame_unref漏了导致内存涨。另一个高频原因是把渲染放在了解码线程里同步做,解码和上屏互相等,缓冲区耗尽。
解决:ANativeWindow_fromSurface配对的ANativeWindow_release要在 Surface 销毁时调用。解码和渲染建议分线程,用队列传递 AVFrame,渲染线程负责 lock/unlock。如果不想分线程,至少确保avcodec_receive_frame的循环能及时消费。
4.4 现象:画面颜色发灰或偏色
原因:色彩空间转换矩阵不对。YUV 到 RGB 的转换有 BT.601 和 BT.709 两套系数,标清和高清用的不一样。sws_scale 默认可能选了不匹配的矩阵。
解决:在 sws_getContext 之后用sws_setColorspaceDetails显式指定系数,或者根据视频的color_space字段动态设置。常见做法是 720p 以下用 BT.601,以上用 BT.709。如果懒得判断,至少确认解码器输出的 color_range 是 limited 还是 full,full range 下转换系数不同。
4.5 现象:高分辨率下帧率上不去
原因:sws_scale 的缩放算法太重,或者每帧都在重新创建 SwsContext。另一个可能是ANativeWindow_lock阻塞,因为前一帧还没被合成器消费。
解决:SwsContext 只创建一次,复用。缩放算法如果尺寸一致,用SWS_POINT或SWS_FAST_BILINEAR。如果窗口尺寸和视频尺寸不同,考虑在解码端用avcodec的硬件缩放,或者接受一次缩放但用更快的算法。lock 阻塞的话,检查是否在 UI 线程做了渲染,以及 Surface 的 buffer 数量是否够。
5. 进阶技巧:用时间戳做音画同步与渲染节流
5.1 用 AVFrame 的 pts 控制上屏节奏
前面的循环是「解一帧上一帧」,没有节奏控制,视频会以解码速度狂飙。正确做法是用frame->pts和av_q2d(stream->time_base)算出显示时间,和系统时钟对齐。
// 计算帧的显示时间(秒) double frame_time = frame->pts * av_q2d(video_stream->time_base); // 获取当前时钟 double now = av_gettime_relative() / 1000000.0; // 如果还没到显示时间,睡一会儿 if (frame_time > now) { av_usleep((frame_time - now) * 1000000); } render_frame(window, frame, sws_ctx);参数说明:av_gettime_relative返回微秒,除以 1000000 得秒。av_usleep的参数是微秒。这个简单节流能让播放速度接近实时,但音画同步还需要以音频时钟为基准,视频向音频对齐。如果音频还没做,至少这个节流能防止视频跑飞。
5.2 双缓冲与丢帧策略
当解码快于渲染时,队列会涨。常见做法是设一个最大队列长度,超过就丢旧帧。丢帧时注意要av_frame_unref,否则内存泄漏。
| 策略 | 适用场景 | 代价 |
|---|---|---|
| 丢旧帧 | 实时流、直播 | 画面跳跃 |
| 丢新帧 | 本地文件、要求完整 | 延迟累积 |
| 动态调解码速度 | 可控场景 | 实现复杂 |
我一般会在渲染线程里维护一个std::deque<AVFrame*>,长度超过 3 就 pop 最旧的并释放。这样即使解码瞬间爆发,上屏也不会延迟太多。
5.3 验证渲染是否真的零拷贝
一个简单的验证方法:在render_frame里打时间戳,统计 lock 到 unlock 的耗时。如果耗时和分辨率成正比且数值很小(1080p 在 1ms 以内),说明没有额外拷贝。如果超过 5ms,检查是不是在 sws_scale 之后又做了一次 memcpy,或者 stride 处理引入了逐行拷贝。
从那以后我每次接 ANativeWindow 渲染,都会先跑一个纯色填充的测试:不接解码器,直接往 buffer 里写固定颜色,确认上屏正常再接入 FFmpeg。这一步能排掉大半的格式和 stride 问题。希望帮到你。
本文还有配套的精品资源,点击获取