FFmpeg与ANativeWindow:Android自研播放器渲染实践
2026/9/9 16:02:21 网站建设 项目流程

简介:一套面向安卓开发者的视频播放器项目源码,演示如何通过开源多媒体框架完成视频解码,并将解码后的画面帧渲染到安卓原生窗口,实现流畅的原生播放。项目采用C/C++混合编写,属于安卓原生开发的高级主题,适合具备一定音视频基础、希望深入掌握多媒体解码与图形渲染的开发者研究。资源压缩包共包含一百三十六个文件,以大量头文件为基础,配合多份C++核心源码、安卓工程配置文件、Java入口、构建脚本及动态库,整体仅六点七九兆字节,结构清晰,便于按模块查阅,目前已有三百八十三人学习下载。从源码中可以完整学习媒体容器解析、解码器调用与打开、逐帧读取、颜色空间转换、原生窗口缓冲区申请与提交、解码与渲染线程同步等关键环节,并涉及像素格式转换、时间戳对齐、错误处理与性能调优策略;项目还提供了构建动态库与工程目录组织方式,是一份深入理解安卓原生多媒体实现的实用参考资料。 老实说,我在自己做播放器模块之前,一直觉得 Android 上的视频播放只要交给 MediaPlayer 或者 ExoPlayer 就够了。直到有一次我需要对接一个特殊的视频封装格式,还要在解码后做逐帧的滤镜处理,Java 层的 MediaCodec 方案怎么都不顺手。MediaCodec 拿到的是压缩码流,硬件解码器的输出 buffer 走的是 Surface 或者 SurfaceTexture,你要想在帧级别干预像素数据,绕来绕去都得经历几次拷贝,而且很多国产设备对 MediaCodec 的兼容性表现一言难尽。这种情况就逼着我去看 FFmpeg + ANativeWindow 这条路。

这篇文章就是把我实际搭建 FFmpegANativeWindow 播放链路的完整过程写出来。适合那些不想依赖系统播放器、希望自己控制解码和渲染节奏的 Android 开发,也适合正在做 NDK 音视频方案的读者。FFmpeg 负责解封装和解码,ANativeWindow 负责把解码后的像素数据直接投递到 Surface 上显示,整个过程不经过 Java 层的 Bitmap 或 Canvas,是一条非常直接的 native 渲染路径。

1. 为什么绕开 Java 层:ANativeWindow 方案的定位与适用场景

1.1 什么时候必须自己用 FFmpeg 解码,而不是 MediaCodec

媒体播放这件事,系统提供的 MediaPlayer 和 ExoPlayer 在绝大多数场景下都够用,性能也好。但有几个场景是系统播放器很难覆盖的:第一,你需要使用 FFmpeg 的 filter_graph 对画面做自定义处理,比如水印、裁剪、像素效果,这些在硬件解码器上做非常痛苦,因为输出 buffer 是 YUV 格式的硬件 buffer,你很难直接复用;第二,项目里已经有大量基于 FFmpeg 的逻辑代码,比如自研协议、特殊容器解析、自定义流切片,这时候再引入一套 MediaCodec 来解码会破坏整体链路;第三,也是最重要的,学习价值。通过 FFmpeg 的完整解码链路,你能真正理解 DTS/PTS、codec_ctx、sws_scale 这些概念,而 MediaCodec 把这些都封装成了黑盒。

用 MediaCodec 时,解码器的输出要么是 Surface,要么是 ByteBuffer。Surface 路径高效但没法拿到帧数据做处理;ByteBuffer 路径能拿到数据但效率低,而且要处理格式协商,不同设备的 codec 输出格式还不一样。FFmpeg 全软件解码虽然 CPU 占用高一些,但所有数据和格式都在你手里,可控性是完全不同的。

1.2 ANativeWindow 与 Surface 的关系,以及它解决的问题

ANativeWindow 是 Android NDK 提供的原生窗口接口,本质上是 Java 层 Surface 对象在 native 层的对应体。你通过ANativeWindow_fromSurface(JNIEnv*, jobject surface)拿到一个ANativeWindow*,之后就可以用 ANativeWindow 系列 API 来向这个窗口提交图像数据。它底层直接操作的是 Surface 的 buffer queue,和 Java 层的 SurfaceHolder.lockCanvas() 相比,少了几层 JNI 转换,而且你提交的是原始像素数据,不需要经过 Canvas 绘制,也完全可以和 OpenGL ES 配合使用。

从我实际使用的体验来看,ANativeWindow 最大的价值在于:它让你在纯 native 代码里就可以控制画面输出,不需要 Java 层参与。解码线程在 C/C++ 里跑,渲染也在 C/C++ 里跑,整个播放循环是一个独立的 native 模块,这对工程上的模块化非常友好。

2. 环境准备:FFmpeg 库与 CMake 集成

2.1 FFmpeg 的获取方式选择

要在 Android 上使用 FFmpeg,通常有两种方式:自己用 NDK 交叉编译,或者直接引入预编译好的 so。

自编译的好处是可以裁剪出你需要的模块,体积更小。比如我的项目只用到 avformat、avcodec、avutil、swscale、swresample 这几个库,configure 的时候可以关掉不需要的封装格式和编解码器,减少 30% 甚至更多的体积。但自编译的坑比较多,主要是 NDK 版本和 FFmpeg 版本的选择,以及优化参数的设置。如果你只是要打通播放链路,推荐先引入现成的预编译库,把业务逻辑跑通之后再考虑裁剪。

我这里的项目用的是 FFmpeg 5.x 以上的版本,编译出一个 Android 用的 so 集合,然后通过 CMake 导入。一个关键点是 FFmpeg 日志默认输出到 stderr,在 Android 的 logcat 里看不到,需要设置一个自定义的日志回调,否则调试起来非常痛苦。

2.2 CMakeLists.txt 的最小配置

假设你的 FFmpeg 库被放在src/main/cpp/ffmpeg目录下,头文件在include,so 文件在libs/arm64-v8a,CMake 配置可以写成这样:

cmake_minimum_required(VERSION 3.22.1) project(ffmpeg_player) set(CMAKE_CXX_STANDARD 17) add_library(avformat SHARED IMPORTED) set_target_properties(avformat PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libavformat.so) add_library(avcodec SHARED IMPORTED) set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libavcodec.so) add_library(avutil SHARED IMPORTED) set_target_properties(avutil PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libavutil.so) add_library(swscale SHARED IMPORTED) set_target_properties(swscale PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/ffmpeg/libs/${ANDROID_ABI}/libswscale.so) add_library(native_player SHARED native_player.cpp) target_include_directories(native_player PRIVATE ${CMAKE_SOURCE_DIR}/ffmpeg/include) target_link_libraries(native_player android log avformat avcodec avutil swscale)

注意一定要把android库链接进来,因为 ANativeWindow 相关 API 在 libandroid.so 里,如果没有链接,编译可能不报错,但运行时会出现 undefined symbol 崩溃。

2.3 JNI 接口设计

Java 层我采用的是非常直观的接口设计,一个播放器类大概有这些 native 方法:

public class FFPlayer { private long nativeHandle; private Surface surface; public native void setSurface(Surface surface); public native void start(String videoPath); public native void pause(); public native void resume(); public native void stop(); public native void release(); }

这里有一个很容易踩坑的注意点:Surface 不能直接以普通对象的方式缓存,一定要在 Java 层持有引用,同时在 native 层通过NewGlobalRef持有 JNI 全局引用,防止 Surface 被 GC 回收。我在实现的时候是直接把 Surface 对象传进去,在 native 层创建全局引用,同时用 ANativeWindow_fromSurface 拿到窗口句柄。Java 层每次调用 start 之前都要确保已经传入一个有效的 Surface。

3. 解码到帧:FFmpeg 侧的数据链路

3.1 初始化:打开文件、定位视频流、打开解码器

FFmpeg 的解码链路其实非常套路化,只要你写过一次,后面就是重复。第一步是初始化所有需要的组件:

extern "C" { #include <libavformat/avformat.h> #include <libavcodec/avcodec.h> #include <libswscale/swscale.h> #include <libavutil/imgutils.h> } // 初始化 avformat_network_init(); AVFormatContext* format_ctx = avformat_alloc_context(); if (avformat_open_input(&format_ctx, file_path, nullptr, nullptr) != 0) { // 文件打开失败 } if (avformat_find_stream_info(format_ctx, nullptr) < 0) { // 无法读取流信息 } // 找到视频流索引 int video_stream_index = av_find_best_stream(format_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); AVStream* video_stream = format_ctx->streams[video_stream_index]; // 打开解码器 const AVCodec* codec = avcodec_find_decoder(video_stream->codecpar->codec_id); AVCodecContext* codec_ctx = avcodec_alloc_context3(codec); avcodec_parameters_to_context(codec_ctx, video_stream->codecpar); if (avcodec_open2(codec_ctx, codec, nullptr) != 0) { // 解码器打开失败 }

这中间有两点值得强调。第一,av_find_best_stream比手动遍历format_ctx->streamscodec_type == AVMEDIA_TYPE_VIDEO更可靠,因为它会综合考虑流的信息;第二,老版本里很多人会用stream->codec这个字段来获取 codec 参数,但从 FFmpeg 4.0 开始这个字段被移除了,正确做法是读取stream->codecpar,然后通过avcodec_parameters_to_context转给 codec_ctx。我第一次从旧代码迁移的时候,这里卡了挺久。

3.2 解码循环:从 av_read_frame 到 AVFrame

打开解码器之后,就可以进入主循环。主流程是:先av_read_frame读取一个 AVPacket,判断是不是视频流的数据,如果是就扔给解码器,解码出一个或多个 AVFrame,然后把 AVFrame 交给后续的格式转换和渲染。

AVPacket* packet = av_packet_alloc(); AVFrame* frame = av_frame_alloc(); while (av_read_frame(format_ctx, packet) >= 0) { if (packet->stream_index == video_stream_index) { int ret = avcodec_send_packet(codec_ctx, packet); if (ret < 0) { // 发送失败,通常不需要特殊处理 } while (ret >= 0) { ret = avcodec_receive_frame(codec_ctx, frame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { break; } else if (ret < 0) { break; } // 到这里拿到了一帧解码后的数据 render_frame(codec_ctx, frame); } } av_packet_unref(packet); } // 解码结束 avcodec_send_packet(codec_ctx, nullptr); while (avcodec_receive_frame(codec_ctx, frame) >= 0) { render_frame(codec_ctx, frame); }

这里render_frame里要做的事,是把 frame 转换成渲染所需的像素格式。FFmpeg 解码出来的原始帧一般是 YUV420P,而 ANativeWindow 最稳定通用的操作格式是 RGBA_8888,所以要用 swscale 做一次格式转换。关于什么格式最适合 ANativeWindow,我后面在性能章节会详细讲,这里先按最稳妥的方案来。

3.3 sws_scale 转换:YUV 到 RGBA 的桥

格式转换用到 libswscale:

void render_frame(AVCodecContext* codec_ctx, AVFrame* frame) { // 分配转换器 SwsContext* sws_ctx = sws_getContext( frame->width, frame->height, (AVPixelFormat)frame->format, frame->width, frame->height, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr ); uint8_t* rgba_data[4] = {nullptr}; int rgba_linesize[4] = {0}; av_image_alloc(rgba_data, rgba_linesize, frame->width, frame->height, AV_PIX_FMT_RGBA, 1); sws_scale(sws_ctx, frame->data, frame->linesize, 0, frame->height, rgba_data, rgba_linesize); // 现在 rgba_data[0] 里面就是 RGBA 格式的像素数据,linesize 是每行字节数 // 渲染到 ANativeWindow... av_freep(&rgba_data[0]); sws_freeContext(sws_ctx); }

这个函数每次创建 SwsContext 是比较低效的,实际工程里应该在解码之前就把这 28 个结构体分配好,这里写出来主要是为了让你看清整个流程。线条尺寸值rgba_linesize[0]非常关键,它可能和width * 4不同,因为有些平台为了对齐会在每行末尾填充额外字节,拷贝到 ANativeWindow 时必须以 linesize 为准。

4. 让画面出现在屏幕上:ANativeWindow 渲染

4.1 ANativeWindow 的完整操作流程

渲染部分,ANativeWindow 的核心 API 其实很少,就三个:lock、copy、unlock。

void render_to_anativewindow(ANativeWindow* native_window, uint8_t* rgba_data, int width, int height, int linesize) { ANativeWindow_acquire(native_window); ANativeWindow_Buffer buffer; // 设置窗口 buffer 的几何尺寸和像素格式 ANativeWindow_setBuffersGeometry(native_window, width, height, WINDOW_FORMAT_RGBA_8888); if (ANativeWindow_lock(native_window, &buffer, nullptr) < 0) { ANativeWindow_release(native_window); return; } // 逐行拷贝像素数据到窗口 buffer for (int y = 0; y < height; y++) { memcpy((uint8_t*)buffer.bits + y * buffer.stride * 4, rgba_data + y * linesize, width * 4); } ANativeWindow_unlockAndPost(native_window); ANativeWindow_release(native_window); }

这里最核心的知识点就是buffer.stride和视频宽度的关系。ANativeWindow 的 buffer 行字节数往往不等于width * 4,它通常会对齐到 8 或者 16 的整数倍,所以上面代码里拷贝到第 y 行时,目标地址要跳过buffer.stride * 4个字节,而不是width * 4。如果你把width * 4当作每行字节数,画面会出现一条一条的斜向偏移或色带,这是新手最容易踩的坑,而且看代码时很难发现逻辑错误。

4.2 关于 PixelCopy 的细节

有一些网上的代码直接写memcpy(buffer.bits, rgba_data, size),这是典型的错误写法。首先,ANativeWindow_Bufferstride是像素单位而不是字节单位,而且不同设备上 stride 可能不同。其次,RGBA 数据一帧可能有 108019204 个字节,一次性 memcpy 假设 src 和 dst 的内存布局完全一致,但实际上 dst 因为 stride 对齐的关系,每行末尾可能有填充字节,这就会导致从第二行开始所有数据都错位。正确做法永远是一行一行拷贝。

4.3 帧率控制:别让你的解码线程跑满 CPU

解码循环如果不控制节奏,会以最快的速度解码所有帧并往 ANativeWindow 上刷,这样播放速度远快于正常播放速率。最简单的做法是解码后对 PTS 做等待,但在开始阶段,我建议直接用usleep配合帧率值做一个粗糙的定时:

double frame_delay = 1.0 / fps; // 从 video_stream->avg_frame_rate 换算 ... render_frame(...); // 等待 usleep(frame_delay * 1000000);

这种做法只能保证大致速度。如果后续要做音视频同步,就得换成基于 PTS 的同步方案,但初版播放器先用这个方案把画面跑起来最直接。

5. 实际开发中的性能坑与细节处理

5.1 格式不匹配导致的黑屏与花屏

我在最初搭建这个播放器时,ANativeWindow_setBuffersGeometry 设置的格式用的是WINDOW_FORMAT_RGBA_8888,但 sws_scale 输出用的是AV_PIX_FMT_ABGR,结果在部分设备上出现了颜色通道对调、整体偏蓝的情况。这个问题的根源是字节序:AV_PIX_FMT_RGBA 在内存里的字节序是 R,G,B,A,而 ANativeWindow 的 WINDOW_FORMAT_RGBA_8888 期望的字节序取决于硬件,在某些 SoC 上实际期望的是 B,G,R,A。

一个可行的方法是,保持 sws_scale 输出为 AV_PIX_FMT_RGBA,然后在渲染时做一次通道交换。但这样会多一次像素遍历,对性能有影响。更推荐的做法是:在 FFmpeg 转换时分别测试AV_PIX_FMT_RGBAAV_PIX_FMT_BGRA,以你的目标设备实际显示效果为准。在我的主力机上,AV_PIX_FMT_RGBA是正常的,但在另一台平板上就偏色了。遇到偏色问题,优先检查这一层。

5.2 分辨率调整与旋转

ANativeWindow_setBuffersGeometry 的 width 和 height 决定了显示区域的大小,不一定非要和视频原始分辨率一致。如果你设置的和视频帧宽高比不同,画面会被拉伸。更关键的是,很多视频的显示宽高比不等于实际像素宽高比,比如 16:9 的视频可能像素是 1024x576,显示时拉伸到 1920x1080。建议在setBuffersGeometry时直接设置成显示目标分辨率,让系统的 buffer 用对应尺寸,然后用 sws_scale 直接把视频帧缩放到这个尺寸,一次性完成转换和缩放。

旋转信息也不是所有视频都存在的,但有些视频的stream->metadata里带有 rotate 标签,比如 90 度旋转。此时你在渲染前需要判断是否要交换宽高,否则画面会躺倒。我记得我那次处理一个手机竖屏拍摄的视频时,就因为这个旋转信息没处理,画面一直横着,折腾了半天才发现不是解码问题。

5.3 Surface 的内存生命周期管理

ANativeWindow 的使用需要注意一个生命周期的问题:ANativeWindow_fromSurface得到的窗口指针,在使用期间必须保持有效,否则调用ANativeWindow_lock时会直接崩溃或返回错误。在 Java 层,如果 SurfaceView 被销毁或者重新布局,Surface 也会失效。所以安全做法是:

  1. Java 层持有 Surface 引用,不要把它交给 GC 回收。
  2. native 层在每次渲染前检查 native_window 是否为空。
  3. 在 SurfaceHolder.Callback 的 surfaceDestroyed 回调里主动通知 native 层释放当前的 ANativeWindow,然后重新等待新的 Surface。

我在写播放器时,因为一开始没有处理 surfaceDestroyed,导致退出界面后偶发性崩溃,排查到最后发现是 dangling pointer 访问了已释放的 ANativeWindow,这个问题在 native 开发里属于常见的致命失误,值得提醒一下。

6. 线程模型与更进一步的优化方向

6.1 单线程如何稳定,双线程如何提升流畅度

初版我是在一个子线程里跑完整的解码 + 渲染循环,单线程的优点是简单可靠,不需要考虑线程同步和死锁,而且 FFmpeg 的解码和渲染天然是串相继的关系,不会出现画面撕裂。缺点是解码耗时波动(I帧 vs P帧)和渲染耗时叠加后,可能导致卡顿。

如果你的播放器处理的是高码率视频,我建议把解码和渲染拆成两个线程,中间用队列传递待显示的帧。解码线程持续从文件读取 packet 并解码成 frame,渲染线程从队列里取 frame 并调用 ANativeWindow 渲染。这样即使某一次解码耗时较长,渲染线程依然有缓冲帧可以继续显示,不会导致画面卡顿。队列可以用互斥锁 + 条件变量实现,设置最大长度来限制内存占用。诚实地讲,如果只是做一个基础播放器,单线程也够用,线程模型是为了后续复杂功能打的底子。

6.2 减少不必要的内存拷贝

目前的链路中,YUV 帧先由 sws_scale 转成 RGBA,再逐行拷贝到 ANativeWindow 的 buffer。转色是必须的,但第二次的 memcpy 实际上是可以优化的:有的平台支持在锁定 buffer 后直接获取到连续内存,而 sws_scale 可以直接输出到 ANativeWindow buffer 的地址上。这样你只要用rgba_data[0] = (uint8_t*)buffer.bits,然后再调 sws_scale,就省掉了逐行 memcpy。这需要你在打开解码器之前就拿到 buffer,我建议初版先不这么干,等逻辑稳定后再来优化拷贝。

另一个更大的优化方向是:让 FFmpeg 直接输出 YUV 并让 ANativeWindow 显示 YUV。但这里需要硬件和系统的配合,YUV buffer 在 ANativeWindow 上并不是每个设备都支持,而且像素格式协商极其敏感,我记得我调研过一圈,像AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420这类格式在部分设备上支持,但兼容性远不如 RGBA 稳。如果你追求性能,可以考虑 OpenGL ES 渲染 YUV 纹理,但这就偏离本文的标题了。

6.3 ANativeWindow 的局限性与实际选择

最后说点个人感受。ANativeWindow 是在纯 native 环境里最容易上手的渲染路径,不需要配置 EGL,不需要加载 GLES 库,也不涉及 shader,一个 lock 加一个 memcpy 就能上屏。对于刚开始在 Android 上接 FFmpeg 的开发者来说,ANativeWindow 是打通解码链路耗时最少的手段。

不过它也不是万能方案。ANativeWindow 能做的其实就是一个填充像素窗口,如果你后续要做复杂的滤镜、倍数缩放、字幕叠加,还是需要往 OpenGL ES 走。很多成熟的播放器方案都是 FFmpeg 负责解码输出 YUV 帧,然后用 GLES 渲染,这样可以最大限度利用硬件加速。从工程角度上看,先用 ANativeWindow 跑通整条链路,后面再迁移到 GLES 是成本最低的路径,因为解码和同步逻辑完全不用改动,只需要替换最终渲染层。

最后再分享一个小技巧:调试 FFmpeg 播放器时,建议在 JNI 层把 FFmpeg 的日志回调重定向到 logcat。FFmpeg 默认在 release 构建里会输出大量 debug 信息,通过av_log_set_callback把这些信息统一打上自己的 tag,排查视频流的分辨率、解码耗时和报错都方便很多。这一段我觉得是除了解码链路之外最值得先去写的代码。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询