简介:一个基于FFmpeg的安卓视频播放器工程源码,面向想在安卓端深入掌握NDK多媒体开发的C、C++和Java工程师。项目完整演示了FFmpeg交叉编译、动态库集成、JNI接口设计以及播放器核心流程:先配置NDK环境并编写构建脚本,将编译好的so库放入jniLibs;随后在Java层通过JNI调用C/C++接口,使用avformat_open_input打开文件、avformat_find_stream_info获取流信息、avcodec_find_decoder查找解码器、avcodec_send_packet与avcodec_receive_frame完成解码、sws_getContext做像素格式转换,还能借助滤镜系统实现裁剪旋转等处理。同时覆盖SurfaceView/TextureView视频显示、播放状态控制、音量与速度调整、硬件加速以及按需裁剪FFmpeg和错误日志记录等优化思路。压缩包共34个文件,以编译脚本、C/C++源码、Java代码和XML资源为主,附带README、许可证、工程配置与项目说明,整体约130KB,结构紧凑便于阅读迁移。已有748人学习下载,适合需要处理非标准格式、从零搭建自定义播放器或系统学习FFmpeg移植原理的开发者参考。
1. 不止是一个播放器壳:ffmpeg 在安卓端的解码链路长什么样
如果把播放器做成一个安卓 App,核心工作其实不在界面上,而在「怎么把视频文件变成屏幕上的一帧帧画面」。android-ffmpeg-player 这个项目做的正是这件事:它用 C 语言写的 FFmpeg 在安卓 native 层完成解封装、解码、缩放,再通过 JNI 把图像数据送到 Java 层的 SurfaceView 上显示。这个项目不是拿来直接当正式播放器用的,而是给「想在安卓上跑 FFmpeg」的开发者做参考:看完整个代码,你就知道一个视频文件从字节流变成可交互画面的完整链路是什么样。
适合两类人看:一类是把 FFmpeg 当黑匣子调用、想搞清楚里面数据怎么流转的初学者;另一类是在做自定义播放器、需要修改解码或渲染逻辑的熟手。项目本身使用 C 语言实现核心逻辑,Java 层只做 UI 和生命周期管理,这种设计保证了核心代码可以被直接移植到其他安卓多媒体项目里复用。
2. 架构设计:两层三模块,播放器不是 Activity 里 new 一个对象
2.1 模块边界:Java 壳、JNI 桥、Native 核心
整个项目分成三个层次:Java 层、JNI 层和 Native 层。Java 层负责 Activity 生命周期、SurfaceView 管理、播放控制按钮的事件响应;JNI 层是 Java 和 C 之间唯一的通道,负责类型转换、方法注册和回调;Native 层则是 FFmpeg 和自定义播放逻辑的主战场。
这种分层的直接原因是 Java 和 C 的内存模型不一样。Java 层可以顺手 GC,但 Native 层分配的内存必须手动av_free或av_frame_free。如果把解码逻辑塞进 Java 层,每次调用 FFmpeg 都要做字节数组拷贝,性能损失会体现在帧率上——尤其是在 1080p 视频上,一次ByteArray拷贝就是几百 KB 的数据搬移。
所以在项目里 Java 层的播放器类只暴露少数几个方法:open(String path)、play()、pause()、seekTo(int position)、release()。所有的状态管理、缓冲、解码和渲染循环都发生在 native 层,Java 层只是状态的中转站。
2.2 播放器的数据流:从 mp4 文件到屏幕上的 YUV 帧
数据在项目里的流转路径可以概括为一条固定链路:
文件 → avformat_open_input → avformat_find_stream_info → avcodec_find_decoder → avcodec_open2 → av_read_frame → avcodec_send_packet → avcodec_receive_frame → sws_scale → Java Surfaceavformat_open_input只打开文件头,不读帧;avformat_find_stream_info则是把流信息扫描出来,包括视频流和音频流的索引、编码格式、宽高、帧率等。项目拿到这些信息后,用av_find_best_stream分别找到视频流和音频流,为每个流打开独立的解码器上下文。
解码环节用的是标准的 send/receive 模型。每个AVPacket从av_read_frame拿回来后,先判断它属于视频流还是音频流,再交给各自对应的解码器。解码器输出的AVFrame只是 YUV 像素数据,必须要经过sws_scale转换成 RGB 才能交给安卓的渲染管线。这里项目采用了在 native 层直接转换,而不是把 YUV 送到 Java 层再做颜色空间转换,减少了内存拷贝次数。
2.3 线程模型:解码线程、渲染线程、控制线程为什么不能合在一起
播放器项目最容易翻车的地方就是线程模型。把解码和渲染放在同一个线程里,会导致两个问题:网络视频或本地大视频的解码速度波动会直接让 UI 卡顿;而把控制逻辑放进去,快速点击暂停和播放会让线程状态变得一团糟。
项目里的做法是三个线程各管一段:
| 线程 | 职责 | 与外部通信方式 |
|---|---|---|
| 解码线程 | 循环av_read_frame+ 解码 | 解出的帧放入队列 |
| 渲染线程 | 从队列取帧,sws_scale后渲染到 Surface | 通过ANativeWindow直接写入 |
| 控制线程 | Java 层调用 native 方法,处理播放暂停 | 通过pthread_mutex和条件变量同步 |
队列是典型的生产者消费者模型,解码线程往一个有界队列里塞帧,渲染线程从队列里取帧。当队列满时解码线程阻塞,当队列空时渲染线程等待。项目里用条件变量而不是sleep来做等待,这样能保证暂停操作能在微秒级内生效。
3. 核心模块拆解:一个视频帧从 demux 到 render 的完整旅程
3.1 JNI 层:native 方法和 Java 回调怎么对接
JNI 层是项目里最容易让人看晕的部分,因为它不仅要完成 Java 和 native 的数据交换,还要处理异常和内存管理。看一下这个项目 JNI 层的核心注册方法:
static JNINativeMethod methods[] = { {"nativeOpen", "(Ljava/lang/String;)Z", (void*)native_open}, {"nativePlay", "()V", (void*)native_play}, {"nativePause", "()V", (void*)native_pause}, {"nativeSeekTo", "(I)V", (void*)native_seek_to}, {"nativeRelease", "()V", (void*)native_release}, }; jint JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env; if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } jclass clazz = (*env)->FindClass(env, "com/example/player/NativePlayer"); (*env)->RegisterNatives(env, clazz, methods, sizeof(methods) / sizeof(methods[0])); return JNI_VERSION_1_6; }JNI_OnLoad是 native 库加载时的入口,在System.loadLibrary("player")时被自动调用。这里用RegisterNatives而不是依赖 JNI 函数名约定,好处是函数的 C 命名可以随意,不用遵守Java_com_example_xxx那种又长又容易出错的规则。
注意nativeOpen接收String参数,对应的 C 函数需要通过GetStringUTFChars取出文件路径的 UTF-8 字符串。这里有个细节:拿完字符串后必须调用ReleaseStringUTFChars释放,否则 JVM 里那个字符串对象的内存不会被回收,连续打开播放几十次就会 OOM。
3.2 解码循环:av_read_frame 到 avcodec_send_packet 的固定套路
解码循环是整个项目最核心的部分,它在独立线程里运行,从文件里不断读取 AVPacket 并送入解码器。代码的套路基本固定:
static void* decode_thread(void* arg) { PlayerContext* ctx = (PlayerContext*)arg; AVPacket* packet = av_packet_alloc(); int ret; while (ctx->playing && !ctx->abort_request) { ret = av_read_frame(ctx->fmt_ctx, packet); if (ret < 0) { // 读到文件尾,送入 flush packet 让解码器输出剩余帧 avcodec_send_packet(ctx->video_dec_ctx, NULL); break; } if (packet->stream_index == ctx->video_stream_idx) { ret = avcodec_send_packet(ctx->video_dec_ctx, packet); if (ret == 0) { while (avcodec_receive_frame(ctx->video_dec_ctx, ctx->video_frame) == 0) { // 把解码后的帧加入渲染队列 frame_queue_push(ctx->video_queue, ctx->video_frame); } } } else if (packet->stream_index == ctx->audio_stream_idx) { // 音频走同样的 send/receive 流程 } av_packet_unref(packet); } av_packet_free(&packet); return NULL; }解码时一个 AVPacket 可能对应多个 AVFrame,尤其在处理 B 帧时,必须用内部循环调用avcodec_receive_frame直到返回AVERROR(EAGAIN)为止。EAGAIN 的含义是解码器当前输入不足,需要喂更多数据,这时才能回到外部循环继续av_read_frame。
av_packet_unref是另一个容易遗漏的调用。av_read_frame内部分配了 packet 的数据缓冲区,如果不 unref,下一次循环再往同一个 AVPacket 里写入时,上一次的缓冲区就泄漏了。这里的逻辑是「用完即清」——无论 packet 是否被送入解码器,都要在本次循环结束时 unref。
3.3 同步机制:音视频不同步时用哪条时钟当准绳
音视频同步是播放器项目里最麻烦的部分,android-ffmpeg-player 的思路是选用音频时钟作为主时钟,视频帧根据音频的播放进度来调整显示时间。选择音频做主时钟的原因很朴素:人耳对声音中断比人眼对画面跳跃更敏感,视频稍微等一下不会产生明显感知。
double get_audio_clock(PlayerContext* ctx) { double pts = ctx->audio_clock; if (ctx->audio_playing) { pts += av_gettime_relative() / 1000000.0 - ctx->audio_clock_updated_at; } return pts; } void video_refresh(PlayerContext* ctx) { double audio_time = get_audio_clock(ctx); AVFrame* frame = frame_queue_peek(ctx->video_queue); double frame_pts = frame->pts * av_q2d(ctx->video_stream->time_base); double delay = frame_pts - ctx->last_frame_pts; if (delay < 0) { delay = 0; // 帧间间隔不合理,直接显示 } double diff = frame_pts - audio_time; if (diff > 0.01) { // 视频快了,额外延迟 usleep((useconds_t)(diff * 1000000)); } else if (diff < -0.01) { // 视频慢了,直接丢弃当前帧,追上音频 frame_queue_pop(ctx->video_queue); } ctx->last_frame_pts = frame_pts; }这里frame_pts是解码器输出的显示时间戳,需要使用time_base换算成秒。delay是当前帧和上一帧的间隔,diff是视频帧和音频时钟的差值。差值超过 10ms 才做处理,小于这个阈值人眼基本感知不到。
视频慢时直接丢帧而不重排,因为重排意味着要维持一个待显示帧列表,这会消耗内存且让逻辑复杂。丢帧后用下一帧重新对齐,视觉上表现为轻微跳帧,但对同步性而言是合理的取舍。
4. 编译与运行:把 FFmpeg 交叉编译进 Android 工程
4.1 交叉编译 FFmpeg for Android 的配置
要在安卓上使用 FFmpeg,第一步是交叉编译出libavcodec.so、libavformat.so等动态库。这个项目的编译脚本基于 NDK 工具链,核心配置是用configure参数裁剪不需要的组件:
#!/bin/bash NDK=/path/to/android-ndk-r25b TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/linux-x86_64 API=21 build_ffmpeg() { ./configure \ --prefix=$PREFIX \ --target-os=android \ --arch=$ARCH \ --cpu=$CPU \ --cross-prefix=$TOOLCHAIN/bin/$CROSS_PREFIX \ --sysroot=$TOOLCHAIN/sysroot \ --enable-cross-compile \ --enable-shared \ --disable-static \ --disable-programs \ --disable-doc \ --disable-avdevice \ --disable-postproc \ --disable-network \ --enable-avcodec \ --enable-avformat \ --enable-avutil \ --enable-swscale \ --enable-decoder=h264,aac,hevc,mpeg4 \ --enable-parser=h264,aac,hevc \ --enable-demuxer=mov,mp4,mkv,flv \ --disable-encoders \ --disable-muxers make -j8 && make install }--disable-network和--disable-avdevice是这个项目的关键裁剪:它不是网络播放器,不需要这些协议簇。--enable-decoder=h264,aac,hevc,mpeg4限定了支持的编码格式,让最终 so 文件体积控制在几 MB 内。
API=21对应 Android 5.0,这里要留意如果声明minSdkVersion高于 21,native 库的版本号不会冲突。交叉编译时最容易出的问题是工具链变量名写错——ndk-r25 以后用的是aarch64-linux-android24-clang而不是老的arm-linux-androideabi-gcc,脚本里必须显式指定 clang 编译器。
4.2 在 Android Studio 里接入 native 库
编译产物是四个 so 文件,把它们放进src/main/jniLibs/arm64-v8a/目录后,还要让 CMake 找到 FFmpeg 的头文件和库文件。这里 CMakeLists.txt 的配置如下:
cmake_minimum_required(VERSION 3.22.1) project(player) set(FFMPEG_DIR ${CMAKE_SOURCE_DIR}/src/main/cpp/ffmpeg) add_library(player SHARED native_player.c frame_queue.c audio_clock.c ) target_include_directories(player PRIVATE ${FFMPEG_DIR}/include ) target_link_libraries(player avcodec avformat avutil swscale z android log ) set_target_properties(player PROPERTIES LINK_FLAGS "-L${CMAKE_SOURCE_DIR}/src/main/jniLibs/arm64-v8a" )LINK_FLAGS里通过-L指定了 so 库的路径,但前提是 CMake 能找到对应名字的库文件。这套配置在 armeabi-v7a 和 arm64-v8a 两种 ABI 下是同样的结构,只需要把编出来的 so 放对应目录即可。
4.3 播放一个视频的最小 Java 调用链
跑通整个项目的 Java 代码非常短,核心就几行:
public class PlayerActivity extends AppCompatActivity { static { System.loadLibrary("player"); } private native boolean nativeOpen(String path); private native void nativePlay(); private native void nativePause(); private native void nativeSeekTo(int position); private native void nativeRelease(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); SurfaceView surfaceView = new SurfaceView(this); setContentView(surfaceView); surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() { @Override public void surfaceCreated(SurfaceHolder holder) { nativeOpen("/sdcard/Movies/test.mp4"); nativePlay(); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { } @Override public void surfaceDestroyed(SurfaceHolder holder) { nativePause(); nativeRelease(); } }); } }这里有个必须注意的顺序:nativeOpen必须在surfaceCreated之后调用,因为 native 层需要拿到 Surface 的ANativeWindow才能渲染。如果 Surface 还没准备好就调用 Open,渲染时拿到的是一个空窗口,输出会直接丢失。
项目真正的入口是放在assets或外部存储目录的视频文件,本地播放路径下不需要申请网络权限,但 Android 11 及以上读取外部存储需要MANAGE_EXTERNAL_STORAGE权限,这是跑 Demo 时最容易漏掉的一步。
5. 避坑指南:NDK 环境下最容易翻车的几个点
写安卓 FFmpeg 播放器,踩坑是常态。下面几条都是实践中反复出现的问题,每条都值得记下来。
现象一:Java 层调用 native 方法,App 闪退且日志只有SIGSEGV,没有堆栈信息。原因通常是 JNI 方法签名不匹配或函数指针类型错误。项目里用RegisterNatives注册方法时,签名字符串(Ljava/lang/String;)Z表示「接收 String 返回 boolean」,如果 Java 端方法写成了void nativeOpen(String path),类型对应不上,JVM 会在调用时直接崩溃。解决:每次修改 JNI 方法签名后,用javap -s工具重新查看 Java 方法的描述符,对照检查。
现象二:播放到一半,画面卡住但声音还在,最后整个播放器无响应。原因是视频队列满了之后,解码线程在条件变量上等待,但渲染线程因为 Surface 被系统销毁而阻塞,两边互相等待造成死锁。解决:渲染线程在每帧渲染前检查ANativeWindow是否有效,无效时清空队列并唤醒解码线程,让解码线程走退出流程而不是继续等待。
现象三:Seek 之后画面恢复了,但音视频同步完全错乱。原因是 seek 操作没有刷新解码器内部状态。老版本的 FFmpeg 靠avcodec_flush_buffers清空解码器缓存,新版本在 seek 后必须同时调用avformat_seek_file和avcodec_flush_buffers,并且要把队列里残留的旧帧全部丢弃。如果只清一个,解码器会吐出 seek 之前缓存的帧,造成画面跳跃。解决:写一个flush_all函数,三件事一起做——seek 文件、flush 解码器、清空两个帧队列。
现象四:所有 so 文件都在 jniLibs 正确位置,但运行时UnsatisfiedLinkError: dlopen failed: library "libavcodec.so" not found。原因是 FFmpeg 库之间有依赖关系,libavformat.so依赖libavcodec.so,系统加载时如果只加载了libplayer.so,而它依赖的库没有被自动加载就会报错。解决:在 Java 静态代码块里按依赖顺序加载:
static { System.loadLibrary("avutil"); System.loadLibrary("swscale"); System.loadLibrary("avcodec"); System.loadLibrary("avformat"); System.loadLibrary("player"); }现象五:视频播放正常,但 App 退到后台一会儿再回来,画面黑屏。原因是 Surface 在 App 后台时被系统销毁,native 层持有的旧ANativeWindow已经指向无效内存。解决:监听surfaceDestroyed事件,在其中把 native 窗口指针置空;在surfaceCreated里重新获取新的 window,并重新初始化渲染上下文。播放器内部要区分「暂停」和「surface 丢失」两种状态,不要混为一谈。
6. 二次开发:把软解换成硬解的改造思路
这个项目的解码链路是软解,也就是纯 FFmpeg 用 CPU 解码。看代码结构能发现,解码的部分集中在decode_thread里,渲染的部分集中在video_refresh里,中间用帧队列隔离,这就是改造最好的切入点。
如果想让项目支持更多视频格式或者降低 CPU 占用,硬解是标准的下一步。安卓的 MediaCodec 能处理 H.264、H.265、VP9 等主流编码,很多设备还有专门的低功耗解码单元。参考这个项目的写法,改造思路是:从文件里用avformat读出AVPacket后,不再直接交给 FFmpeg 的软解器,而是把 packet 的data和size传入 MediaCodec 的输入队列。MediaCodec 输出的是Image对象,图像数据通常是 YUV 格式,可以直接用于渲染或转成Surface显示。
具体到代码层面,可以增加一个统一的解码接口:
typedef struct Decoder { int (*decode_frame)(struct Decoder*, AVPacket* packet, AVFrame** out_frame); int (*flush)(struct Decoder*); void (*destroy)(struct Decoder*); } Decoder;软解码器实现这个接口时,内部调用avcodec_send_packet和avcodec_receive_frame;硬解码器实现这个接口时,内部调用 MediaCodec 的 JNI 接口。这样解码线程代码基本不用动,只需要在初始化时根据设备能力选择一个具体的 Decoder 实现。
如果想进一步发挥这个项目的价值,还有两个值得做的小改造:一是把sws_scale的 RGB 转换放到渲染线程做,这样解码线程的耗时只包括编码层逻辑,能减少解码环节的堵点;二是给帧队列加上丢帧策略,顺序播放时队列超过一定帧数就丢掉旧帧,保证画面延迟不累积。
当时我给这个项目加硬解支持的时候,因为解码线程没有判空Decoder指针就调用了decode_frame回调,导致空指针崩溃连续出现了好几次。从那以后我每次改完播放器代码,都会强制走一遍「空指针检查 → 线程退出标志 → Surface 状态」三件套的 review 流程,稳当多了。希望这套改动路径对你有帮助。
本文还有配套的精品资源,点击获取