ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android自研FFmpeg播放器:从NDK编译、JNI桥接到音画同步的全链路实践

Android自研FFmpeg播放器:从NDK编译、JNI桥接到音画同步的全链路实践 简介面向安卓开发者的 FFmpeg 视频播放器工程重点演示如何借助 NDK 将开源多媒体库集成到安卓应用实现自定义解码与渲染。工程包含完整的 JNI 桥接层、Java 层播放控制逻辑、FFmpeg 核心解码流程以及基于 SurfaceView 或 TextureView 的画面输出能够满足处理非常规视频格式或定制播放功能的需求。资源包共 34 个文件压缩后仅 130KB主要涵盖 Java 源代码、C 语言本地接口、Android.mk 构建脚本、XML 界面布局、PNG 图标以及项目配置文件目录结构清晰便于查阅。据统计已有 737 人学习浏览。通过该工程可以系统理解 FFmpeg 在安卓平台的交叉编译与链接方法掌握打开媒体流、解析流信息、查找解码器、发送数据包并接收解码帧等关键步骤同时了解滤镜裁剪、旋转、硬件加速优化以及按需裁剪库体积的实践思路适合希望深入学习多媒体底层开发的进阶开发者。 在Android平台上折腾视频播放器我前前后后做过不少版本要么直接套Vitamio、ExoPlayer这类现成轮子要么自己在FFmpeg的泥潭里摸爬滚打。这篇就是想聊聊我最近一次从头搭的android-ffmpeg-player一个完全基于FFmpeg的Android视频播放器从NDK编译、JNI桥接到解码渲染全链路自己控制。如果你准备做播放器、想弄懂FFmpeg在Android上到底怎么落地或者只是被网上碎片化教程坑得头大这篇应该能给你省点时间。先交代一下这个东西到底是什么定位。它不是一个完整的App更像一个播放器核心模块——负责把视频文件解码成可渲染的画面和声音交给你自己的UI层去展示和控制。换句话说你可以把它当做一个定制播放器的底层基座拿到手之后接上播放暂停按钮、进度条、倍速切换这些交互就能直接用。做这个项目的直接原因是我需要支持一些不常见的封装格式和编码格式ExoPlayer虽然也好但在定制编解码策略上还是绕不过底层能力FFmpeg这条路躲不开。1. 项目整体设计与思路拆解1.1 为什么自研播放器而不是直接用ExoPlayer或IJKPlayer不少朋友会问Google都出了ExoPlayerB站也开源了IJKPlayer为什么还要自己拿FFmpeg硬啃这个问题我得摊开说清楚。ExoPlayer在Android设备上的硬解能力很成熟对HLS、DASH这类流媒体协议支持也非常好但它的设计目标是覆盖常规场景。一旦你面对的是定制的私有封装格式、特殊编码参数或者需要精确控制解码buffer、要做到每一帧都自己过一遍算法ExoPlayer就有点不太顺手了。IJKPlayer本质上是把FFmpeg封装了一遍确实省事但它的定制灵活度存在问题——你只能按它暴露的接口来调整底层逻辑被固定住了真要动解码管线里的某个环节得改源码重新编译成本不比自研低。我当时的需求比较具体需要支持自定义的音频重采样策略要把视频帧转成RGBA再做目标检测处理还得在解码阶段就能拿到PTS信息做精确的逐帧定位。这些需求压下来直接决定了我必须自己控制FFmpeg的解码循环。自研这条路虽然有工作量但胜在每个环节都吃得透出了性能问题能立刻定位到是解码慢、渲染卡还是同步飘了不用在别人的封装里猜来猜去。1.2 技术选型的关键取舍软解为主、硬解为辅Android上的视频解码有两种路线MediaCodec硬解和FFmpeg软解。我最终定的是软解为主、硬解为辅的方案很多人觉得软解性能不行但放在这个项目里有它的合理性。硬解走MediaCodec确实对H.264、H.265这类主流编码效率极高功耗也低。但硬解有个让人头疼的问题——格式兼容性碎片化。AAC在某些芯片上有奇怪的delay行为1080P高码率在低端机上经常被厂商的驱动坑出花屏更不用说那些冷门的编码格式MediaCodec根本不认识。FFmpeg软解得通杀这些问题只要FFmpeg编译进去了对应的解码器格式就基本不用愁。当然纯软解播放1080P的H.265视频现在的中端芯片压力还好但4K就会很吃紧。所以我的架构里留了一个口子解码器工厂先检查编码类型如果是MediaCodec能处理的H.264/H.265且设备性能达标优先走硬解否则回退到FFmpeg软解。这个双轨设计工作量会增加不少但播放体验的兜底能力特别强。实际跑下来绝大多数视频都能稳定播放冷门格式靠软解兜着流畅度也有保障。2. 环境准备与核心工具链搭建2.1 FFmpeg的NDK交叉编译别让自己卡在第一步FFmpeg要在Android上跑第一步就是交叉编译出带Android ABI的.so库。这个环节能劝退不少人因为配置命令长、参数多稍有不慎就编出个没法用的库。我用的NDK版本是r21e开启的API级别是21覆盖Android 5.0及以上的设备。编译脚本的核心思路就是设置好交叉编译工具链的路径然后给FFmpeg的configure传参数。先把关键环境变量写出来export NDK/your/path/android-ndk-r21e export TOOLCHAIN$NDK/toolchains/llvm/prebuilt/linux-x86_64 export API21 export CC$TOOLCHAIN/bin/aarch64-linux-android$API-clang export CXX$TOOLCHAIN/bin/aarch64-linux-android$API-clang export AR$TOOLCHAIN/bin/aarch64-linux-android-ar export RANLIB$TOOLCHAIN/bin/aarch64-linux-android-ranlib export STRIP$TOOLCHAIN/bin/aarch64-linux-android-strip这些环境变量设置好之后configure阶段才是真正决定“要编什么”的地方。我的配置大致是./configure \ --prefix$PREFIX \ --target-osandroid \ --archarm64 \ --cpuarmv8-a \ --enable-cross-compile \ --enable-shared \ --disable-static \ --disable-programs \ --disable-doc \ --enable-small \ --enable-jni \ --enable-mediacodec \ --enable-decoderh264_mediacodec \ --enable-decoderhevc_mediacodec \ --enable-hwaccels \ --enable-avcodec --enable-avformat --enable-avutil \ --enable-swresample --enable-swscale \ --enable-decoderh264 --enable-decoderhevc \ --enable-decoderaac --enable-decodermp3 --enable-decoderac3这里有几个参数值得展开说明。--enable-jni和--enable-mediacodec是为了让FFmpeg能调用MediaCodec做硬件解码不编进去的话之前说的硬解辅助方案就无从谈起。--enable-small会裁剪一些性能优化但体积大的代码对移动端很友好。--disable-programs不生成ffmpeg命令行工具我们只需要库文件编那玩意纯属浪费时间。编译完一顿make -j8 make install之后在输出目录里会看到libavcodec.so、libavformat.so、libavutil.so、libswresample.so、libswscale.so这几个核心库。注意Android系统本身也带了一个libavcodec.so在系统分区打包App时一定要把自己的库放在与应用同目录下用android:extractNativeLibsfalse配合JNI加载避免加载到系统版本否则会踩到版本不匹配的坑。2.2 项目结构设计Native层与Java层的分工边界我见过不少人把解码逻辑和UI逻辑全塞在Java层结果频繁的JNI调用和跨线程同步能把人逼疯。我自己的结构是这样分的Native层只负责三件事——解封装、解码、格式转换对外暴露一个极简的C接口。Java层负责JavaSound的采集、视频画面的渲染、播放状态的维护以及上层业务逻辑。两者通过JNI以回调的方式通信Native层解码出新的视频帧就往Java层抛一帧Java层负责把它画到View上。Native层的核心抽象是三个类级别的概念Demuxer负责打开文件、找到音视频流、读取packetDecoder负责把packet解成frameRenderer负责把frame推到对应的输出端。Java层的控制器持有这三个Native层对象对应关系简单清晰public class FFmpegPlayer { private long nativeHandle; // 指向Native层的播放器实例 private Surface videoSurface; private AudioTrack audioTrack; public void setVideoSurface(Surface surface) { this.videoSurface surface; nativeSetSurface(nativeHandle, surface); } }用long句柄来引用Native对象是个很实用的技巧Java层不必知道Native内部的数据结构也不容易发生对象生命周期错乱。这样划分还有一个好处Native层被设计成无状态核心状态全由Java持有将来要对接测试代码、做自动化验证都会非常方便。3. 核心实现解码、渲染与时间同步3.1 JNI桥接层怎么设计才不摔跤JNI这层是Java与C/C的唯一通道设计不好容易产生内存泄漏和崩溃。这里最核心的问题有三个引用管理、数据拷贝、线程切换。引用管理上Java层传给Native的除了基本类型任何对象引用都要妥善处理。我的惯例是能用GetPrimitiveArrayCritical和ReleasePrimitiveArrayCritical处理byte[]数据就尽量用减少一次拷贝但要小心这两个函数包裹的区域内不能执行任何可能阻塞的操作否则容易死锁。数据拷贝方面解码出来的原始视频帧是YUV420P格式Android的SurfaceView渲染纹理更常用RGBA。纠结要不要在Native层转换我一开始很在意这点性能开销但后来实测发现用libswscale做色彩空间转换在720P分辨率下也就多花两三毫秒对整体播放影响不大。转换逻辑放在Native层的最大好处是Java层拿到的直接是可用数据不用再费心处理像素格式的坑。线程切换是个高频踩坑点。JNI回调Java方法时如果当前线程不是Java线程必须通过JavaVM和当前环境的关系来获取线程关联的JNIEnv——具体来说先AttachCurrentThread用完再DetachCurrentThread。我是在Native层维护了一个全局JavaVM引用初始化时存下来后续所有回调线程都能拿到JNIEnv。没做过这步的人基本都会遇到“JNI DETACHED CALL”的崩溃提示。3.2 解码主循环和音视频帧的管理策略解码主循环是整个播放器的心脏。我的实现思路是单独开一个DecodeThread不停从容器中拿packet、送进解码器、取出frame、分发到音频轨或者视频渲染端。核心代码看起来是这样while (running) { if (packetQueue.size() MAX_PACKET_QUEUE) { usleep(10 * 1000); continue; } AVPacket *pkt av_packet_alloc(); int ret av_read_frame(fmtCtx, pkt); if (ret 0) { if (ret AVERROR_EOF) { handleNoMoreInput(); } av_packet_free(pkt); break; } if (pkt-stream_index videoStreamIndex) { avcodec_send_packet(videoDecCtx, pkt); while (avcodec_receive_frame(videoDecCtx, videoFrame) 0) { videoQueue.push(av_frame_clone(videoFrame)); } } else if (pkt-stream_index audioStreamIndex) { avcodec_send_packet(audioDecCtx, pkt); while (avcodec_receive_frame(audioDecCtx, audioFrame) 0) { audioQueue.push(av_frame_clone(audioFrame)); } } av_packet_free(pkt); }这里有个细节avcodec_send_packet和avcodec_receive_frame是FFmpeg新式的解码接口老接口avcodec_decode_video2被废弃了千万别再用。新接口的语义更清晰按backpressure机制处理——如果解码器内部还有未取出来的帧send_packet会返回EAGAIN这时需要先把内部帧全部receive完再继续。对帧队列的容量管理我用了“上限阻塞”的组合视频帧队列最大3帧音频帧队列最大10帧。因为音频的消耗速度有确定性视频帧一旦没及时消费播放器暂停时队列会堆积所以设置上限可防止内存暴涨。解码线程一旦队列满了就睡眠几毫秒虽然简单粗暴但实测下来很稳。3.3 音画同步方案以音频时钟为基准音画不同步是播放器最容易被感知的问题。我的同步策略是业界标准的以音频为准、视频对齐因为人耳对声音的抖动量比对画面的抖动量更敏感。具体实现是在音频输出侧维护一个精确的时钟。每次往AudioTrack写入音频数据时根据采样率、写入了多少字节推算出当前应该播放到的时间位置记作audioClock。视频侧拿到一帧之后用帧的PTS减去audioClock得到的差值就用来控制是立即渲染还是延迟渲染还是丢掉。核心逻辑double diff videoFramePts - audioClock; if (diff 0.05) { // 视频快了等一会再渲染 usleep(diff * 900 * 1000); } else if (diff -0.05) { // 视频慢了直接丢帧别渲染了 return; }这里有个很有用的经验usleep等待的时候不要按照100%的时长等待要打个折比如90%因为解码和渲染本身也有耗时如果按实际偏差来等视频会一直慢半拍。另外阈值0.05秒是要根据实际播放效果调整的——阈值太大会感觉到音画错位太小又会导致频繁丢帧造成画面抖动。我调了一段时间觉得50ms在绝大多数情况下是舒适区。音频端还要处理一个采样率适配问题。Android的AudioTrack支持的采样率跟音频文件本身的采样率不一定一样我统一用libswresample把音频重采样到44100Hz或48000Hz声道数统一为双声道不然AudioTrack初始化时容易报参数错误。4. 渲染方案选择SurfaceView、TextureView与OpenGL4.1 三种方案对比和我的选择Android上显示视频画面的方案大概三类。SurfaceView的低延迟性能最好适合全屏播放纯视频界面TextureView可以配合动画和缩放适合嵌套在复杂的UI里SurfaceTexture OpenGL ES自由度最高能自己控制纹理和滤镜。我最终选了TextureView做为主渲染控件。原因很现实这个项目的上层UI有旋转、镜像、画中画的需求TextureView能比较自然地嵌进View系的布局和动画里。SurfaceView在Android 7.0之前不能做平移缩放旋转虽然现在新版本好多了但我这个项目要对一些老设备的兼容性保守一些TextureView是最稳妥的。不过TextureView的坑也得说。它本质上是每次帧到了都做一次SurfaceTexture的updateTexImage然后绘制到View上性能和SurfaceView相比确实有差距如果设备不行在播放1080P时容易掉帧。我的应对方案是解码线程把视频帧转换成一个Bitmap或直接使用SurfaceTexture.setDefaultBufferSize指定面元尺寸减少TextureView内部缩放的消耗。具体做法是在Native层用libswscale直接输出RGBA到一块buffer再把buffer传给Java层用Bitmap.createBitmap一次性生成位图这样虽然多了一次像素格式转换但视频尺寸不太大的时候成本完全可控。4.2 渲染线程的节奏控制和刷新策略视频渲染不能一直无脑刷新要由视频帧的节奏来驱动。我开了一个专门的RenderThread循环消费videoQueue里的帧每一帧都计算它的展示时间然后调用TextureView的绘制。实际渲染代码里帧的字体处理相对直接private void renderVideo(long ptsUs) { // 假设当前收到一帧视频帧 Bitmap bitmap nativeGetFrame(nativeHandle, width, height); if (bitmap ! null) { Canvas canvas textureView.lockCanvas(); canvas.drawBitmap(bitmap, null, dstRect, null); textureView.unlockCanvasAndPost(); } }锁画布绘制的方案在老设备上是最可控的新设备的HardwareLayer申请开销有点高但也没遇到严重的性能瓶颈。如果追求更极致的效率可以走OpenGL纹理上传但复杂度会上一个台阶非必需不折腾。收到帧后渲染前最好做一个差值判断。有些设备vsync信号不稳定渲染的帧动作可能不是均匀的弹到下一帧的时间可能比预期差得多。我做了个设定如果当前系统时间距离上一帧渲染时间超过了视频帧间隔的1.5倍就直接抛掉本帧不渲染宁可少一帧不要卡一个半帧。5. 常见问题与排查技巧实录5.1 那些让人崩溃的编译期错误编译这块我只说三个大家碰得最多的坑。第一个是NDK版本与FFmpeg版本不匹配有些FFmpeg版本太老、使用了一些已废弃的C语法用新版NDK的clang编译时会报一堆error。我的解决办法是固定NDK r21e配合FFmpeg 4.4.x版本这对组合验证过非常稳定。第二个是configure时提示Unable to create ...这类权限或路径问题多半是prefix目录不存在或没有写权限提前用mkdir -p建目录。第三个是link阶段找不到libc_shared.so需要在App的build.gradle里加上jniLibs配置把NDK里的对应库拷出来打包进去。这些坑都属于一次性问题真正难的是运行时崩溃。我在调试时一度疯狂崩溃每次报的都是SIGSEGV但崩溃栈信息完全没有后来发现是解码回调里直接操作了Java层的Bitmap对象——这个对象在Java层有可能会被GC回收在Native里用就变成了悬垂指针。解决办法是所有的Bitmap在Native层引用期间用NewGlobalRef把它变成一个全局引用用完之后再DeleteGlobalRef释放这样GC就不会回收它。5.2 播放体验优化的几个关键实测结论音画同步这关过了之后播放体验还剩下三个关键优化点。第一首屏渲染速度。视频打开后用户最反感的就是黑屏等待我的优化是预读关键帧。初始化时快速扫一遍容器格式找到第一个关键帧的偏移量用它把解码器预热一下比起从头解码到关键帧的时间能快不少。实现方案是在av_seek_frame时指定AVSEEK_FLAG_BACKWARD先seek到关键帧再正常播放实测首屏时间能压缩到300ms以内。第二内存占用的控制。FFmpeg的解码器初始化时要申请大量内存B帧缓冲也要占额外的空间。我在avcodec_open2之后手动调用avcodec_flush_buffers清空一次缓冲防止之前测试残留的帧数据影响状态。还开了AV_CODEC_FLAG_LOW_DELAY来减少解码延迟虽然会牺牲一点点压缩效率但对本地播放没有感知差异。第三掉帧策略。前面提过帧队列设置上限这其实还牵涉到播放卡顿和掉帧的平衡问题。如果设备能力不足解码跟不上与其让画面卡在那里不如适度丢帧。我的经验是如果持续解码帧间隔超过正常帧率的1.5倍就该考虑放弃B帧策略或主动丢帧而不是无条件把队列排满那样只会导致音频播完了视频还卡在半路。视频的倍速播放也是被经常使用的功能这个我单独处理。FFmpeg的解码速度跟不上音频的倍速消费速度时我会将播放速度速率直接应用在AudioTrack上配合setPlaybackParams做变速不变调视频这边则按音频时钟正常同步。这样的效果比直接在解码端加速要好很多省了一堆重采样和丢帧判断的逻辑。6. 后续还能往哪里扩展这个播放器内核目前能稳定播本地文件、RTSP流和部分自定义协议但要做产品级还差几块拼图。网络流层面的弱网协议栈现在FFmpeg内置的TCP连接遇到抖动很容易断要做基于FFmpeg的自定义IO回调来接入自有的网络库才能处理重连、缓存、码率自适应这些。字幕支持方面FFmpeg的avcodec_decode_subtitle可以解出字幕数据但渲染成带描边的样式需要自己在Canvas上绘制工作量不小。DRM加密内容也不是搭个架子就能搞定的需要对接商业CDM模块。小技巧你要是也想在Native层处理自定义滤镜可以考虑在libswscale转RGBA之后先把帧丢给GPU渲染管线做效果再上屏。Android平台的GLES绘制和surfaceTexture之间的协同有点绕但一旦通路搭好滤镜、裁剪、转场特效就都能无缝加上去了。踩了这么多坑我的一个核心心得体会是做播放器最难的不是把解码跑起来而是把时间线控制好让画面的节奏和耳朵的预期对得上。希望这篇能帮你少走点弯路。本文还有配套的精品资源点击获取
返回列表