简介:这是一份基于 ffmpeg 的安卓视频播放器工程源码,面向希望通过 NDK/JNI 深入理解 Android 底层多媒体开发的 C/C++ 开发者。资源包内含完整项目文件,共 34 个文件,以 xml 配置、mk/makefile 构建脚本、java 与 c 源码为主,另有 png 图标和 md 说明文档,整体仅 130KB,结构简洁,适合快速阅读和二次修改。工程展示了 ffmpeg 在 Android 平台的完整集成流程:从 NDK 交叉编译出适配不同架构的动态库,到通过 JNI 封装底层解码逻辑,再到上层播放控制与画面渲染。代码中覆盖了打开输入、获取流信息、解码帧数据、像素格式转换等关键调用,也涉及音视频同步、滤镜处理和基于 SurfaceView/TextureView 的显示方案。对于希望自定义播放器、支持更多非标准格式,或想对 ffmpeg 做裁剪优化的开发者,这份小巧但完整的工程能提供比系统 MediaPlayer 更底层的实现参考;目前已有 748 人学习下载,是理解 Android 多媒体框架的良好切入点。
1. 一个基于 FFmpeg 的安卓播放器,值得你亲手编译一遍
如果你手头积压了一批监控录像、网课缓存或者老播放器解不了的 MKV,系统自带的 MediaPlayer 八成会直接甩脸色。这个基于 FFmpeg 的安卓播放器项目,就是把 FFmpeg 整个搬进 Android 端的完整样板:自带 native 层解码、JNI 桥接、Surface 渲染和播放控制,拿到手改一改就能接进自己的播放业务。它在方案选型上没什么花活,属于“最正统的软解硬渲”路线,适合正在做播放器、视频编辑或者格式转换类 App 的开发者拿来当轮子。我拆过不少播放器项目,这类东西最难的不是 FFmpeg 本身,而是编译参数、线程模型和帧同步这三座山,这篇就按实际调试顺序把路走一遍。
2. FFmpeg 在 Android 上的集成路线:编译脚本、ABI 与 JNI 桥
2.1 集成前先想清楚:so 怎么来、包怎么打
在动代码之前,你得先选 FFmpeg 的引入方式。社区里有两种主流做法,一是用现成的预编译 so,比如某些仓库打好的 4.0 或者 5.1 版本,优点是不用折腾编译环境,缺点是你拿不到想要的裁剪项,也没法确认编进去的 decoder 够不够用。二是自己用 NDK 编,这也是我推荐这个项目的原因——它自带完整的编译配置,你能同时拿到“能跑的 so”和“能改的脚本”,后续要加协议、加滤镜、加硬解,都不至于推倒重来。
关于 ABI 支持,arm64-v8a 必选,armeabi-v7a 如果要兼容老设备也留着,x86 那类模拟器架构在真机场景里基本用不上。实际打 Release 包时要特别注意 abiFilters 配置,很多新手在这里翻车:你不限制 ABI,Gradle 会把四五个 so 全塞进 APK,包体积瞬间增加大几十兆,还在 Play 商店的设备兼容页面上莫名多出一堆不支持的 CPU 架构。我一般会在 defaultConfig 里锁掉 x86 和 x86_64,只在 debug 包留它们给模拟器跑。
2.2 编译脚本里的关键 configure 参数:照着抄也要懂取舍
这个项目里带了一个 build_ffmpeg.sh 脚本,核心逻辑就是下载 FFmpeg 源码、设定工具链、逐个 ABI 编译。我摘一段最关键的 configure 片段来说明参数含义,这段决定了你的播放器到底能解什么、不能解什么。
./configure \ --target-os=android \ --arch=${ARCH} \ --cpu=${CPU} \ --enable-cross-compile \ --enable-shared \ --disable-static \ --disable-programs \ --disable-doc \ --cross-prefix=${TOOLCHAIN}/bin/${CROSS_PREFIX} \ --sysroot=${SYSROOT} \ --enable-avcodec \ --enable-avformat \ --enable-avutil \ --enable-swresample \ --enable-swscale \ --enable-avfilter \ --enable-decoder=h264,hevc,aac,pcm_mulaw,pcm_s16le \ --enable-demuxer=mov,flv,mkv,avi,mp3,aac \ --enable-protocol=file,http \ --enable-muxer=mp4,mov \ --enable-parser=h264,hevc,aac make -j$(nproc)这里有几个参数要单独拎出来讲。--disable-programs是必须的,Android 上不需要 FFmpeg 自带的命令行工具,不开反而省空间。--enable-shared决定输出 so 文件,Debug 时方便直接抓 log,如果追求极致包体积,可以编译成静态库再让一个总的 native-lib.so 去合并它,但新手不建议这么干,初始化上下文会多出一堆坑。Decoder 里我列了 h264 和 hevc,这意味着 H.265 的监控视频和网课视频能播放;如果还要解 VP9 甚至 AV1,就得按需补 decoder、parser 和 demuxer,编译时间会明显拉长。
最后执行./build_ffmpeg.sh会生成libavcodec.so、libavformat.so、libavutil.so等动态库,然后手动拷贝到src/main/jniLibs/${ABI}/目录,Gradle 会自动把它们打进 APK。注意脚本跑完要检查日志里有没有Unknown option之类的错误,NDK 版本和脚本里--toolchain指定的前缀不匹配时,第一行 configure 就会报错。
2.3 JNI 线程模型:解码器跑在哪个线程、渲染又在哪个线程
so 编好只是第一步,真正让播放器转起来的是 JNI 层封装的解码循环。这个项目没有把所有逻辑一股脑塞到 Java 侧,而是把核心解码和渲染放到 native,Java 只负责 Surface 的创建和生命周期回调。这算是一个比较稳的结构:Java 侧崩溃不会直接拖垮 native 解码线程,native 侧卡在 IO 上也不会让 UI 线程 ANR。
JNI 方法注册用的是RegisterNatives的方式,相比函数名静态映射,它的好处是导出符号少、安全合规扫描不容易误报,而且方法重命名不影响调用关系。典型的 native 函数签名长这样:
static void nativeInit(JNIEnv *env, jobject thiz, jobject surface); static void nativePlay(JNIEnv *env, jobject thiz, jstring url); static void nativePause(JNIEnv *env, jobject thiz); static void nativeSeekTo(JNIEnv *env, jobject thiz, jdouble positionSec); static void nativeRelease(JNIEnv *env, jobject thiz);对应的 Java 侧接口是init(SurfaceHolder holder)、play(String url)、pause()、seekTo(double pos)和release()。线程划分是理解整个项目的关键:init 只做 FFmpeg 全局初始化并保存 Surface 引用;play 里创建一个解码线程和一个渲染线程,两个线程通过一个内部的 FFrameQueue 传递解码后的 AVFrame;UI 线程只接收状态回调和 seek 请求,不碰任何 FFmpeg 对象。这个模型直接规避了 ANativeWindow_lock 在 UI 线程里卡死的风险。
提示:JNI 层拿到的 Surface 必须用
AVMediaCodecContext还是ANativeWindow之前,先想清楚一件事——你用软解还是硬解。这个项目默认走软解,ANativeWindow 拿到的是解码后的原始数据,不做二次转换;如果走硬解,你需要把 AVFrame 封装成 MediaCodec 的 BufferInfo,渲染流程会复杂不少。
3. 解码与渲染管线:从 native 层到 TextureView 的完整链路
3.1 解码循环的完整代码:av_read_frame 与 packet 队列的配合
播放器的核心是那句经典的 while 循环,但不能天真到直接拿着一个线程边读边解,因为网络 IO 慢、解码耗时、渲染节奏三者节奏完全不同。这个项目里用的是多人协作模式:读包线程把 AVPacket 丢进 packet 队列,解码线程从队列取包、解码成 AVFrame 再丢进 frame 队列,渲染线程负责消费 frame,控制显示时间戳。
while (av_read_frame(fmtCtx, packet) >= 0) { if (packet->stream_index == audioStreamIndex) { audioQueue.push(packet); } else if (packet->stream_index == videoStreamIndex) { videoQueue.push(packet); } else { av_packet_unref(packet); } checkAndWaitQueueSpace(); }这段代码在readThread里执行。audioQueue 和 videoQueue 是有界队列,默认各放 20 到 50 个包,放满了就wait,这能防止视频流一直读、音频追不上导致的内存膨胀。checkAndWaitQueueSpace()每次入队都检查两个队列的大小,超过阈值就让读线程睡一下,等渲染线程消费。这里有一个常见误用:拿到 packet 就直接丢给解码器去解,不检查stream_index,这在只有单路视频的测试文件里跑不出问题,一旦遇到双音轨视频,解码器直接报错甚至 abort。
如果还要做音视频预加载缓冲,可以在 UI 层发一个prepare()请求,让读线程先跑一会儿再通知 Java 层进入 playing 状态,这样可以消除首帧慢的问题,但注意不要预读太久,网络流场景下 5 秒左右的缓冲已经足够,再多用户就直接退出了。
3.2 帧渲染:ANativeWindow 与显示时间戳的配合
渲染线程的代码是这个项目里最值得逐行读的部分,它直接操作ANativeWindow的 Buffer,把解码出来的 AVFrame 拷贝到屏幕。
ANativeWindow_Buffer windowBuffer; if (ANativeWindow_lock(window, &windowBuffer, nullptr) == 0) { uint8_t *dst = static_cast<uint8_t *>(windowBuffer.bits); int dstStride = windowBuffer.stride * 4; // RGBA_8888 // 逐行拷贝 for (int y = 0; y < height; y++) { memcpy(dst + y * dstStride, frame->data[0] + y * frame->linesize[0], width * 4); } ANativeWindow_unlockAndPost(window); }这段代码依赖一个前提:解码出来的 AVFrame 已经通过sws_scale转成了 RGBA_8888,因为 ANativeWindow 默认窗口格式就是 RGBA_8888。dstStride必须取windowBuffer.stride而不是 width,很多 Surface 在屏幕宽高不是 16 的倍数时会多出 padding,写死 width 会导致右侧出现条纹或顺序错位。
frame->linesize[0]是 FFmpeg 内部的行字节对齐数,不同解码器和分辨率下它不一定等于 width*4,直接用 memcpy 时拷贝行数必须按 linesize 走。这里最容易翻车的是:硬件解码出来的 frame 格式可能是 NV12 或 YUV420P,直接按 RGBA 拷贝出来的画面绿油油一片,必须在渲染前用sws_getContext建好转换上下文。
从性能角度看,软解模式一帧 1080p 的 RGBA8888 拷贝大约要 8 到 10 毫秒,如果算上 sws_scale 转换时间,在低端手机上会接近 20 毫秒。所以实际的渲染线程要做两个优化:一是区分 window 支持裁剪区和不支持的情况,优先用ANativeWindow_setBuffersGeometry把 Buffer 尺寸固定到视频宽高,减少底层缩放开销;二是监控ANativeWindow_lock返回时间,如果连续几帧超过 30 毫秒,丢帧标志位打开,跳过积压的 frame。
3.3 音频输出与线程优先级:不被盯上的杂音问题
音频部分相对简单但容易被忽略,项目用AudioTrack播放 PCM 数据,线程优先级直接设置为Process.THREAD_PRIORITY_URGENT_AUDIO,否则低端机一卡顿就会爆音。解码线程优先级设置成THREAD_PRIORITY_DEFAULT以上,这样锁竞争导致的时间抖动不会直接落在 AudioTrack 上。
播放 PCM 时要注意AudioTrack.getMinBufferSize()返回值,不少设备上这个值比一帧音频的 PCM 大小大好几倍,如果你每次只 write 一帧数据,系统会因为缓冲不足而丢音。常见做法是维护一个 Java 层的 PCM 缓冲队列,一边从 native 拿解码结果,一边按固定间隔写 AudioTrack。如果需要耳放或者蓝牙设备支持,还得在 AudioTrack 构建参数里把 sampleRate 从设备实际拥有的值里取出来,而不是直接写死 44100。
4. 播放控制与同步:速度切换、seek 精度与音视频基准选择
4.1 关键参数表:rate、sync_type、clock 的初始值
很多自研播放器能解码但难用,核心原因是播放控制的细节没做透。这个项目里比较有价值的是它的控制参数设计,全部集中在 native 侧的结构体里:
| 参数名 | 默认值 | 作用 | 边界与坑 |
|---|---|---|---|
| videoClock | 0.0 | 视频帧时间戳基准 | 必须在首个视频 frame 解码后初始化 |
| audioClock | 0.0 | 音频播放进度 | 依赖 AudioTrack 的 getPlaybackHeadPosition |
| syncType | 1 | 0=视频同步到音频,1=音频同步到视频,2=外部时钟 | 默认音频为基准 |
| frameDropThreshold | 0.1 秒 | 视频帧累积延迟超过该值则丢帧 | 动画场景建议调大 |
| seekTargetSec | -1.0 | seek 目标时间,-1 表示无 | seek 后需要清除 packet 队列 |
| speed | 1.0 | 倍速播放 | 靠改 audio 播放步长 + 跳帧实现 |
这套参数表对照下来,最容易理解的是:为什么默认以音频为基准。因为人耳对声音抖动的敏感度远超视觉,只要一帧音频晚了几毫秒,立刻就能听到卡顿;而视频帧晚几十毫秒,肉眼基本无感。反过来让视频做主导,视频卡顿时音频会一起卡,听感很差。所以sync_type=1,视频每显示一帧都要去比对 audioClock,如果视频慢了就丢帧,视频快了就 sleep 等待。如果想实现“视频为基准”,多用于纯视频或音轨抽掉的文件,需要在无音轨时自动切换到外部时钟模式,否则全部帧都会被判定为迟到然后丢光。
4.2 倍速播放不改变音调的实现路径
倍速播放是这个项目里另一个值得借鉴的设计。普通的倍速就是加快采样率拉到 2 倍,但声音会变尖锐刺耳。要保调换速,常见做法有两个:一是 Sonic 算法,二是 SoundTouch 算法,它们都是基于时域压扩的流式处理,把音频按时间尺度压缩而不改变频率特征。实现层面,需要先启用 FFmpeg 的aresample和swresample,然后在 native 解码循环里把音频 frame 丢给一个重采样器,按speed参数处理完后写入 AudioTrack。
速度切换时要注意别把所有状态全清掉,比如用户从 1.0 切到 1.5,你不能把 audioClock 重置回 0,否则视频帧会剧烈抖动。正确的做法是让 audioClock 继续按原时间增长,只修改每次av_resample处理的输入采样数。视频这边更简单,把speed作为丢帧判断的一部分:1.5 倍时视频帧延迟只允许 0.05 秒,而不是 0.1 秒。
seek 的精度是另一个大坑。FFmpeg 的av_seek_frame默认按关键帧跳,所以 seek 到 87.0 秒,可能实际跳到 85.4 秒的视频关键帧。如果产品要求精确到 100 毫秒以内,需要开启AVSEEK_FLAG_BACKWARD去寻找最近的上一关键帧,然后解码并丢弃前面几帧,直到显示帧的时间戳达到目标时间。这个项目里有flush_queue和clearFrameQueue的逻辑,seek 后旧队列的包不清理干净,会出现几秒的花屏片段,这一块在避坑章里单独说。
4.3 音视频同步的基准切换:从默认无解到自定义实现
同步基准切换是播放器高级功能里比较实用的一个点。很多人以为设置一个 sync_type 就完事,但实际网络流场景里,音频解码经常跟不上视频解码,导致音频 clock 停顿在最老的位置,视频帧则被判定为超前,画面一直等声音。这种场景下自动切换成外部 clock 才能保证画面趋势不中断。
外部时钟建议用steady_clock,它是单调时钟,不受系统时间校准影响。用gettimeofday那种墙上时钟,在手机自动校时或者跨时区时会突然跳变,播放器会瞬间丢一坨帧。具体实现上,每一帧显示完时,记录系统时间戳和当前媒体时间戳的差值,这个差值就是 clockDrift,连续统计 20 帧,如果 drift 波动超过 ±0.2 秒,就强制刷新基准。
5. 避坑 / 常见问题:编译失败、花屏、无解与崩溃的排障清单
5.1 编译脚本报错:C compiler test failed的诡异现象
现象:执行 build_ffmpeg.sh 编译某 ABI,前几分钟还正常,配置检查到一半突然报C compiler test failed,日志里夹着一段乱码头文件。
原因排查:最常见是 NDK 版本与脚本里的工具链前缀不匹配。假设你装了新版 NDK r27 而脚本按 r17 的老路径去取arm-linux-androideabi-clang,就会找不到编译器。另一种低频情况是 make 并行度过高,-j数字给得太大导致资源竞争,configure 阶段偶发出错。
解决:先检查脚本头部TOOLCHAIN变量指向的路径是否真实存在,用ls看一下 clang 和 llvm-ar 是否在预期位置。其次把make -j$(nproc)改成make -j8,避免并行度太高。最后,如果还挂着,加入--enable-cross-compile后顺手把--cc=${TOOLCHAIN}/bin/clang显式指出来,让 configure 不要自作聪明去 bin 目录里猜编译器。
5.2 播放视频绿屏或花屏:sws_scale 像素格式对不上
现象:H.265 视频解码正常,声音正常,画面是绿色或者上下撕裂的条纹,旋转屏幕时花屏区域变大。
原因:解码器输出的 AVFrame 是AV_PIX_FMT_YUV420P,而 ANativeWindow 的 Buffer 格式固定是 RGBA_8888,没有经过 sws_scale 就直接 memcpy,每帧只有亮度分量有意义,色度全丢了。
解决:在渲染前加一次格式转换,代码如下:
if (swsContext == nullptr) { swsContext = sws_getContext(width, height, inputPixFmt, width, height, AV_PIX_FMT_RGBA, SWS_FAST_BILINEAR, nullptr, nullptr, nullptr); } // 转换到临时 RGBA buffer uint8_t *dstData[4] = {rgbaBuffer, nullptr, nullptr, nullptr}; int dstLinesize[4] = {width * 4, 0, 0, 0}; sws_scale(swsContext, frame->data, frame->linesize, 0, height, dstData, dstLinesize);注意SWS_FAST_BILINEAR是速度优先的算法,缩放画质会有下降。如果是转 GIF 或者做缩略图,建议改成SWS_BILINEAR或更高阶。rgbaBuffer的大小必须按width * height * 4分配,但注意 linesize 在 sws_scale 输出时是按width*4对齐,还是需要av_image_fill_linesizes去算,否则边缘像素会错位。
5.3 快进后画面花 or 声音不同步:packet 队列的水位与旧包清理
现象:seek 到新位置后,播放器继续播放,但画面前几秒出现块状马赛克,接着声音突然快了几拍。
原因:seek 前读线程已经往队列里塞了几十个包,seek 后旧队列里的包时间戳全乱了。解码器上下文内部还有没消耗完的参考帧——H.264 里这类 B 帧参考关系一旦断裂,就会出现花屏。
解决:seekTo触发时,native 侧先做flush_queue,清掉所有待处理的 AVPacket,然后调用avcodec_flush_buffers重置解码器,最后avformat_seek_file。注意顺序不能反,先清队列再 flush 解码器,否则队列里的旧包又会被喂给已经重置的解码器。下面这段逻辑是稳定的三连:
clearPacketQueue(); clearFrameQueue(); avcodec_flush_buffers(audioCodecCtx); avcodec_flush_buffers(videoCodecCtx); avformat_seek_file(fmtCtx, videoStreamIndex, INT64_MIN, newTimestamp, newTimestamp, 0);新 timestamp 的单位是 AV_TIME_BASE 微秒,不是秒也不是毫秒。很多人在这一步直接拿秒数去算,一 seek 就跳到错误位置。想省事就直接用av_seek_frame带AVSEEK_FLAG_BACKWARD,精度略差但能定位到最近关键帧。
5.4 音画不同步越来越严重:audioClock 的校准选错了参考源
现象:播放 5 分钟后画面比声音慢 0.3 秒,播放 20 分钟后领先 1.2 秒,没有固定规律,但越长越明显。
原因:audioClock 用的是音频帧自身的 PTS,而不是 AudioTrack 真实播放进度。如果你用usleep或者播放线程休眠来等,音频实际播放的时间和系统时间会产生累积漂移。手机音频输出频率本身不是绝对的 44100 Hz,不同设备的 DAC 晶振偏差都在千分之几。
解决:改成用AudioTrack.getPlaybackHeadPosition()来驱动 audioClock。Java 层周期性地通过 JNI 把当前播放的帧位置传回 native,native 层拿帧数除以采样率算出已播放秒数,作为同步基准。代码模板:
long playedFrames = audioTrack.getPlaybackHeadPosition(); int sampleRate = audioTrack.getSampleRate(); double audioTimeSec = (double) playedFrames / sampleRate; nativeSyncAudioClock(audioTimeSec);注意getPlaybackHeadPosition在暂停和停止时返回 0,需要在 resume 后重置基准,否则同步会突然跳变。
5.5 Release 包裁剪后看不了视频:so 的裁剪面太窄的代价
现象:Debug 包能播放 MP4、MKV 等一切文件,Release 包一播放就提示fmt not found或decoder not found。
原因:FFmpeg 编译时用--enable-decoder=h264,hevc,aac做了严格裁剪,但项目后续给 Release 加了混淆和资源压缩,混淆把 Java 侧的avcodec类名加乱了,或者 JNI 链接时静态注册的函数名被 R8 去掉,导致 native 方法找不到。
解决:proguard-rules.pro里必须加入-keep class com.xx.ffmpeg.player.** { *; },同时确保libffmpeg.so的导出函数没有被 strip 掉。在 release 构建前先跑nm -D libffmpeg.so检查关键函数是否在列表里;如果不在,在 CMake 里补一句set(CMAKE_SHARED_LINKER_FLAGS "-Wl,--export-dynamic")。
6. 让首屏快三倍:预解码、fast start 与缓冲水位调整
播放器“能不能用”和“体验好不好”的分界线,基本就在首帧时间和首次缓冲上。这个项目默认的 play 是拿到 URL 就远行 read 线程,首帧渲染平均在 800 毫秒到 1.2 秒之间,但这还远远不够。短视频 App 的快感阈值是 300 毫秒以内,长视频播放器也至少应该保证冷启动不在 2 秒以上。这里我从这个项目的代码里提炼三条可以直接套用的优化路径。
第一条是预解码初始化,把avformat_open_input、avformat_find_stream_info这类重操作移到 JNI 的prepare()阶段,而不是 play 阶段。prepare 可以放在 ActivityonCreate之后立即触发,趁用户点击按钮的几百毫秒内提前跑完。很多人的实现是点击事件回调里才调 open input,这一下就浪费了 300 到 600 毫秒。正确顺序是:获取 URL 后马上在 Native 线程执行 open and probe,只提供AVFormatContext的读取能力,先不创建解码线程。用户点击播放时,解码线程已经拿到全部流信息和音频参数,直接开跑。
第二条是针对网络流的快开优化,在prepare阶段设置AVFormatContext的probesize和analyzeduration。默认probesize是 5MB,对于拉流速度慢的场景,探测阶段会卡很久,裁低到 1MB 到 2MB,analyzeduration设成 500 万微秒(即 5 秒)足够识别绝大多数流的参数。代价是某些编码会被误判,比如极低码率的音频流探测不全,解出来没有声道数,需要用代码兜底:
if (audioCodecCtx->sample_rate <= 0 || audioCodecCtx->channels <= 0) { avformat_find_stream_info(fmtCtx, nullptr); // 探测一次兜底 }第三条是缓冲水位。这个项目里audioQueue和videoQueue的容量默认值偏保守,网络抖动时容易产生首次缓冲。在 Wi-Fi 环境下,把两个队列容量都翻一倍,player 从一开始就调节到一个比较舒服的节奏。我一般会在网络流场景下把 packet 队列上限设到 200 个包或 2MB 字节大小,超过就暂停读线程,低于一半就恢复。这样做以后首帧率提升明显。
从整体感受来说,把这三条做完,首屏渲染时间能压到 350 毫秒左右。从那以后,我养成了一个固定习惯:每次改完播放层代码,强制走一遍首帧时间、seek 后恢复时间、连续 seek 三次的缓冲次数这三项测试,数值基准钉在文档里。这份项目代码虽然不是现成商业播放器那么完善,但作为一座 FFmpeg 落地的桥,它该有的骨架都在,希望帮到你。
本文还有配套的精品资源,点击获取