简介:FFmpegANativeWindow 是一份面向 Android 原生音视频开发者的实战项目源码,聚焦于用 FFmpeg 解码视频帧并渲染到 ANativeWindow,解决原生层视频播放与硬件加速渲染的衔接问题,适合具备 C/C++ 与 Android NDK 基础、希望深入音视频处理的中高级开发者。资源包共 136 个文件,以 106 个 C 头文件、4 个 cpp 源文件、2 个 Java 文件及 xml、mk、so 等构建与配置资源为主,整体约 6.79MB,涵盖解码、渲染、工具封装等模块。项目完整呈现了 avformat_open_input、avcodec_decode_video2、sws_scale 等关键调用链路,以及 ANativeWindow 缓冲区申请与提交、解码线程与渲染线程同步、音视频时间戳对齐、错误处理与资源生命周期管理等核心思路,并涉及多线程与硬件加速等性能优化方向。目前已有 384 人学习,可作为理解 Android 原生视频播放管线的参考范例。
1. FFmpegANativeWindow:为什么绕开 Java 层直接渲染,是移动端播放器最划算的一步
如果你在 Android 上做过视频播放,大概率经历过这样的折腾:MediaPlayer 格式支持有限,ExoPlayer 定制解码链路又太重,想接自己的解码器就得一路从 native 层把 YUV 数据搬回 Java,再交给 SurfaceView 或 TextureView 显示。数据每过一次 JNI 边界就多一次拷贝,1080p 60 帧的场景下,CPU 占用和发热会直接教你做人。FFmpegANativeWindow 这个组合要解决的就是这件事:用 FFmpeg 完成解封装和解码,把解码后的图像帧通过 ANativeWindow 的原生窗口直接渲染出去,整条链路留在 native 层,Java 只负责创建 Surface 和生命周期管理。它适合已经能编译 FFmpeg、想自己掌控渲染节奏的移动端开发者,也适合做低延迟投屏、监控预览、自定义滤镜管线的团队。核心思路不复杂,但真正落地时,像素格式、缓冲区步长、窗口生命周期这三处最容易翻车。
2. 从 Surface 到 ANativeWindow:渲染链路的建立与像素格式对齐
2.1 Java 层把 Surface 交给 native 的正确姿势
Android 的 Surface 在 Java 层是一个对象,到了 native 层对应的是 ANativeWindow。两者之间的桥接靠ANativeWindow_fromSurface,它接收 JNIEnv 和 Java 的 Surface 对象,返回一个ANativeWindow*。这里第一个坑是引用计数:ANativeWindow_fromSurface会让窗口引用计数加一,用完之后必须调用ANativeWindow_release,否则 Surface 销毁时 native 侧还握着引用,表现为黑屏或者下次创建 Surface 失败。
// Java 层:把 Surface 传给 native,只传引用,不做任何绘制 public class NativePlayer { static { System.loadLibrary("ffplayer"); } // surface 由 SurfaceView.getHolder().getSurface() 获得 public native void setSurface(Surface surface); public native void start(String url); public native void stop(); }这段 Java 代码本身没有渲染逻辑,它只做一件事:把 Surface 对象的引用交给 native。真正的窗口获取在 native 侧完成。参数上要注意,setSurface必须在 Surface 有效之后调用,通常在surfaceCreated回调里执行,不要在构造函数里传一个还没准备好的 Surface。
2.2 native 侧获取窗口并配置缓冲区几何
拿到ANativeWindow*之后,不能直接往上画。ANativeWindow 是一个生产者接口,你需要先设置缓冲区的宽高和格式,再通过ANativeWindow_lock拿到一块可写的缓冲区,写入像素后再ANativeWindow_unlockAndPost提交。格式设置用ANativeWindow_setBuffersGeometry,宽高必须和解码输出的图像尺寸一致,格式通常选WINDOW_FORMAT_RGBA_8888或WINDOW_FORMAT_RGBX_8888。
// native 侧:从 Surface 获取 ANativeWindow 并配置几何 #include <android/native_window_jni.h> #include <android/native_window.h> static ANativeWindow *g_window = NULL; JNIEXPORT void JNICALL Java_com_example_ffplayer_NativePlayer_setSurface(JNIEnv *env, jobject thiz, jobject surface) { if (g_window) { ANativeWindow_release(g_window); g_window = NULL; } if (surface) { g_window = ANativeWindow_fromSurface(env, surface); // 先按解码尺寸设置,实际宽高在解码器初始化后才知道 ANativeWindow_setBuffersGeometry(g_window, 0, 0, WINDOW_FORMAT_RGBA_8888); } }ANativeWindow_setBuffersGeometry的宽高传 0 表示使用窗口默认尺寸,但更稳妥的做法是在解码器打开、拿到AVCodecContext的 width 和 height 之后再调用一次,传入真实分辨率。格式选 RGBA 是因为 FFmpeg 的sws_scale可以稳定输出这种打包格式,兼容性最好。如果你追求性能,可以改用WINDOW_FORMAT_RGBX_8888省掉 alpha 通道的写入,但要注意 FFmpeg 输出时 alpha 值会被忽略。
2.3 用 sws_scale 把解码帧转成窗口能吃的格式
FFmpeg 解码出来的AVFrame常见格式是 YUV420P,而 ANativeWindow 的缓冲区是 RGBA 打包格式,中间必须做一次色彩空间转换和重采样。这一步用sws_getContext创建转换上下文,再用sws_scale逐帧转换。转换上下文的参数包括源宽高、源格式、目标宽高、目标格式、缩放算法。缩放算法选SWS_BILINEAR在质量和速度之间比较平衡,如果源和目标尺寸一致,可以选SWS_POINT进一步省 CPU。
// 初始化转换上下文,src 和 dst 尺寸一致时缩放算法影响很小 struct SwsContext *sws_ctx = sws_getContext( codec_ctx->width, codec_ctx->height, codec_ctx->pix_fmt, codec_ctx->width, codec_ctx->height, AV_PIX_FMT_RGBA, SWS_BILINEAR, NULL, NULL, NULL); // 每解码一帧后执行转换 AVFrame *frame = av_frame_alloc(); uint8_t *dst_data[4] = {0}; int dst_linesize[4] = {0}; av_image_alloc(dst_data, dst_linesize, codec_ctx->width, codec_ctx->height, AV_PIX_FMT_RGBA, 1); sws_scale(sws_ctx, (const uint8_t * const *)frame->data, frame->linesize, 0, codec_ctx->height, dst_data, dst_linesize);av_image_alloc分配的目标缓冲区大小是linesize[0] * height,对于 RGBA 来说,linesize[0]通常等于 width 乘以 4,但可能因为对齐而略大。这个 linesize 在写入 ANativeWindow 缓冲区时非常关键,后面会专门讲。
3. 把帧写进 ANativeWindow:缓冲区锁定、步长处理与渲染循环
3.1 ANativeWindow_lock 的缓冲区结构与步长陷阱
ANativeWindow_lock会返回一个ANativeWindow_Buffer结构,里面有bits、width、height、stride、format几个字段。bits是缓冲区首地址,stride是一行的像素数,注意它不一定等于 width。很多人在这一步直接按 width 逐行拷贝,结果画面出现斜切或者右侧花屏,就是因为忽略了 stride。正确的做法是按行拷贝,每行拷贝的有效字节数是width * 4(RGBA),但目标地址的偏移要用stride * 4来算。
// 将转换后的 RGBA 数据写入 ANativeWindow 缓冲区 ANativeWindow_Buffer buffer; if (ANativeWindow_lock(g_window, &buffer, NULL) == 0) { uint8_t *dst = (uint8_t *)buffer.bits; int dst_stride = buffer.stride * 4; // 每行字节数 int src_stride = dst_linesize[0]; // sws_scale 输出的每行字节数 int row_bytes = codec_ctx->width * 4; // 有效像素字节数 for (int y = 0; y < codec_ctx->height; y++) { memcpy(dst + y * dst_stride, dst_data[0] + y * src_stride, row_bytes); } ANativeWindow_unlockAndPost(g_window); }这段代码里,buffer.stride是窗口缓冲区的行跨度,单位是像素,乘以 4 才是字节数。dst_linesize[0]是 sws_scale 输出缓冲区的行跨度,通常等于width * 4,但也可能因为 SIMD 对齐而更大。两个 stride 不一致时,必须逐行拷贝,不能一次性 memcpy 整个缓冲区。row_bytes只拷贝有效像素,避免把对齐填充的垃圾数据写进窗口。
3.2 渲染线程与解码线程的节奏控制
解码和渲染如果放在同一个线程里,遇到高码率视频时解码耗时波动会导致画面卡顿。常见做法是解码线程负责读包、解码、转换,把转换好的 RGBA 数据放进一个队列,渲染线程从队列取数据,锁定窗口、拷贝、提交。队列长度控制在 2 到 3 帧比较合适,太长会增加延迟,太短会丢帧。
// 简化的帧队列,用互斥锁和条件变量保护 #define MAX_QUEUE 3 typedef struct { uint8_t *data; int width; int height; int linesize; } FrameNode; FrameNode queue[MAX_QUEUE]; int head = 0, tail = 0, count = 0; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 解码线程入队 void enqueue_frame(uint8_t *data, int w, int h, int ls) { pthread_mutex_lock(&lock); while (count == MAX_QUEUE) { pthread_cond_wait(&cond, &lock); } queue[tail].data = data; queue[tail].width = w; queue[tail].height = h; queue[tail].linesize = ls; tail = (tail + 1) % MAX_QUEUE; count++; pthread_cond_signal(&cond); pthread_mutex_unlock(&lock); }队列满时解码线程阻塞等待,渲染线程消费一帧后唤醒。这里的内存管理要小心:入队的data必须是堆上分配的独立缓冲区,不能直接传dst_data[0],因为 sws_scale 下一帧会覆盖它。每次入队前用av_malloc或者malloc拷贝一份,渲染线程用完负责释放。
3.3 窗口尺寸变化时的重建逻辑
当 Surface 尺寸变化,比如横竖屏切换,ANativeWindow的缓冲区几何需要重新设置。如果只是调用ANativeWindow_setBuffersGeometry而不重新锁定,可能会拿到旧尺寸的缓冲区。稳妥的做法是在尺寸变化回调里释放旧窗口、重新从 Surface 获取、重新设置几何,并清空帧队列,避免旧尺寸的帧被写入新窗口。
// 尺寸变化时重建窗口 void on_surface_changed(JNIEnv *env, jobject surface, int new_width, int new_height) { if (g_window) { ANativeWindow_release(g_window); g_window = NULL; } g_window = ANativeWindow_fromSurface(env, surface); ANativeWindow_setBuffersGeometry(g_window, new_width, new_height, WINDOW_FORMAT_RGBA_8888); // 清空队列,丢弃旧尺寸的帧 flush_queue(); }flush_queue要遍历队列释放所有帧的内存,并把 count 置零。这一步不做的话,渲染线程可能拿旧尺寸的帧往新窗口里写,轻则画面拉伸,重则越界访问导致崩溃。
4. 避坑与排查:ANativeWindow 渲染最常见的五类翻车
4.1 画面全黑但解码日志正常
现象是 FFmpeg 的avcodec_send_packet和avcodec_receive_frame都返回成功,但屏幕就是黑的。原因通常是ANativeWindow_lock返回了非零值,而代码没有检查返回值,直接跳过了写入和提交。解决方法是每次 lock 之后判断返回值,非零时打印错误码,常见的是-EAGAIN表示窗口还没准备好,需要等 Surface 回调完成后再开始渲染。
4.2 画面颜色偏绿或偏紫
现象是渲染出来的图像整体色调不对,人脸发绿或者发紫。原因是像素格式不匹配,比如把 YUV420P 的数据当成 RGBA 写进了窗口,或者 sws_scale 的目标格式和ANativeWindow_setBuffersGeometry设置的格式不一致。解决方法是确认三处格式统一:sws_getContext的目标格式、av_image_alloc的格式、setBuffersGeometry的格式,三者必须都是AV_PIX_FMT_RGBA和WINDOW_FORMAT_RGBA_8888。
4.3 画面右侧出现斜切或花屏
现象是图像左边正常,右边有一条斜着的重复内容或者彩色条纹。这是典型的 stride 处理错误,逐行拷贝时用了 width 而不是 buffer.stride 作为目标偏移。解决方法是打印buffer.stride和dst_linesize[0]的值,确认两者是否相等,不相等时严格按行拷贝,目标偏移用buffer.stride * 4。
4.4 播放几秒后卡死或崩溃
现象是播放开始正常,几秒后画面冻结,日志里出现ANativeWindow_lock failed或者 native crash。原因通常是帧队列没有做容量限制,解码线程生产速度远快于渲染线程消费速度,内存耗尽。解决方法是给队列设上限,满时阻塞解码线程,同时检查每帧内存是否在消费后正确释放,避免泄漏。
4.5 横竖屏切换后画面拉伸或黑屏
现象是旋转屏幕后画面比例不对,或者直接黑屏。原因是窗口尺寸变化后没有重新设置缓冲区几何,或者旧尺寸的帧还在队列里。解决方法是在 Surface 变化回调里释放旧窗口、重新获取、重新设置几何,并清空帧队列。如果使用固定宽高比,还要在 native 侧计算 letterbox 的偏移,把图像居中写入。
5. 进阶技巧:用 AImage 和硬件缓冲区减少一次拷贝
5.1 从 ANativeWindow 到 AImageReader 的取舍
前面讲的链路里,sws_scale 输出的 RGBA 数据要先拷贝到 ANativeWindow 的缓冲区,这次拷贝在 1080p 下每帧大约 8MB,60 帧就是 480MB/s 的内存带宽。如果设备支持,可以用AImageReader配合AHardwareBuffer,让解码器直接输出到图形缓冲区,省掉 sws_scale 和 memcpy 两步。代价是代码复杂度上升,需要处理AImage的 planes 和 rowStride,而且不是所有解码器都支持硬件缓冲区输出。
5.2 用 AImage 的 rowStride 替代手动计算
AImage的AImage_getPlaneRowStride返回的行跨度和 ANativeWindow 的 stride 含义一致,但单位是字节。如果走 AImage 路线,渲染循环里不再需要 sws_scale,而是直接把 AImage 的 plane 数据按 rowStride 拷贝到窗口缓冲区。注意 AImage 的格式可能是AIMAGE_FORMAT_YUV_420_888,需要自己写 YUV 到 RGBA 的转换,或者用 GPU 着色器完成。
// 从 AImage 读取 Y 平面并写入窗口,仅示意 stride 处理 int32_t y_stride = 0; uint8_t *y_data = NULL; AImage_getPlaneRowStride(image, 0, &y_stride); AImage_getPlaneData(image, 0, &y_data, &y_len); ANativeWindow_Buffer buffer; ANativeWindow_lock(g_window, &buffer, NULL); uint8_t *dst = (uint8_t *)buffer.bits; for (int y = 0; y < height; y++) { memcpy(dst + y * buffer.stride * 4, y_data + y * y_stride, width); } ANativeWindow_unlockAndPost(g_window);这段代码只拷贝了 Y 平面,实际使用中还要处理 U 和 V 平面,并做色彩转换。它的价值在于展示了 stride 的处理方式和 ANativeWindow 写入的基本结构,和前面 RGBA 路径的思路一致,只是数据来源从 sws_scale 换成了 AImage。
5.3 一个可复用的验证方法
不管走哪条路径,验证渲染是否正确的最快方法是:准备一张纯色测试图,用 FFmpeg 编码成 H.264,然后在播放器里解码渲染。如果颜色正确、没有斜切、没有黑边,说明 stride 和格式都对。再换一张带文字和细线条的图,检查边缘是否清晰,如果文字发虚,说明缩放算法或者色彩转换有问题。最后用adb shell dumpsys SurfaceFlinger查看图层的合成方式,确认没有多余的拷贝。
我自己的习惯是,每次改完渲染逻辑,先用 640x360 的小分辨率跑通,确认画面正常后再上 1080p。小分辨率下 stride 对齐问题更容易暴露,而且编译调试周期短。这个习惯帮我省了很多次在 4K 电视上反复插拔设备的麻烦。希望帮到你。
本文还有配套的精品资源,点击获取