
简介面向 Android 等平台音视频集成的 ijkplayer 0.8.8 完整编译产物适合不愿从零搭建 NDK 编译环境、希望直接取用可运行库文件的开发者也便于中高级工程师核对底层依赖与构建配置。压缩包共 1581 个文件以 532 个 d 依赖描述、532 个 o 中间目标文件、24 个 so 动态库、20 个 a 静态库为主另有 76 个 java 源码、39 个 xml 配置及 gradle、mk 构建脚本整体约 49.08MB多架构 so 与 libyuv、soundtouch 等静态库并列存放便于按 ABI 取用。已有 1748 人学习下载。借助现成的编译产物与脚本读者可省去 FFmpeg 交叉编译环节直接对照工程结构核对依赖、替换库文件并快速验证 RTMP、HLS 等流媒体播放链路。1. 三个 .so 的分工为什么我愿意直接用 0.8.8 的编译产物上周有个做安防客户端的朋友找我说要在老项目里加一路 RTSP 预览从零编译 ijkplayer 折腾了一下午NDK 版本、FFmpeg 交叉编译脚本、openssl 路径轮着报错。我让他别编了直接换 0.8.8 的预编译 .so半小时就跑出画面了。ijkplayer 0.8.8 的编译文件不是一个库而是三个libijkffmpeg.so、libijksdl.so、libijkplayer.so按 ABI 分目录放好。它解决的是「我只要播放能力不想维护一套音视频工具链」这件事适合做点播/直播/监控预览的 Android 工程也适合当学习 native 播放器分层的样本。不适合的也明说要加私有协议、要裁 FFmpeg 体积、要改渲染管线的老老实实自己编。2. 装载顺序与目录落位三个库谁先谁后拿到压缩包第一件事不是往工程里拷是先搞清楚这三个文件之间的引用关系。很多「dlopen failed」的报错根子就在这一步没做。2.1 依赖链谁 NEEDED 谁这三个库不是并列关系是分层关系。libijkplayer.so 是 Java 层 IjkMediaPlayer 的 JNI 实现它向上暴露 native 方法向下调用 libijksdl.so 做视频渲染、音频输出OpenSL ES / AudioTrack和消息队列同时调用 libijkffmpeg.so 做解封装和解码。也就是说 libijkplayer.so 在 ELF 的 DT_NEEDED 段里同时依赖另外两个。这意味着两件事。第一只拷 libijkplayer.so 一定崩因为动态链接器找不到它声明的依赖。第二加载顺序虽然理论上由 linker 按 NEEDED 自动解析但 Android 各版本对dlopen的查找路径处理不完全一致尤其是把 so 放在非标准目录、或者用System.load传绝对路径的时候手动按「ffmpeg → sdl → player」的顺序加载是最省心的做法。我一般会在 Application 或播放页的 static 块里显式写死这个顺序System.loadLibrary是幂等的重复调用不会重复加载只是把引用计数加一不用担心和 IjkMediaPlayer 内部的加载逻辑打架。提示三个库必须来自同一次编译。混用不同版本的 libijksdl.so 和 libijkffmpeg.so编译期不报错运行期在 native 层随机 SIGSEGV堆栈还指不到你写的代码行排查成本极高。2.2 jniLibs 目录结构与 Gradle 配置预编译产物的目录结构是有约定的每个 ABI 一个文件夹文件夹名必须是arm64-v8a、armeabi-v7a、x86、x86_64这种标准写法三个 .so 平铺在里面。放错层级、或者把三个 so 直接堆在 jniLibs 根目录Gradle 不会报错但打包进 APK 后路径是错的运行时照样找不到。// app/build.gradle android { defaultConfig { ndk { // 只保留业务真正需要的 ABI // 少勾一个架构包体就少三份 .so 的体积 abiFilters arm64-v8a, armeabi-v7a } } sourceSets { main { // 自定义 jniLibs 路径避免和别的 SDK 的 so 混在一个目录里 jniLibs.srcDirs [libs/jniLibs] } } // AGP 8.x 里这个块叫 packaging老版本叫 packagingOptions packagingOptions { jniLibs { // false so 不压缩安装时直接从 APK 里 mmap // 0.8.8 这类较新的产物建议保持 false useLegacyPackaging false } } }abiFilters是这里最值得花时间的一项。全量四个架构三个 so 加起来轻松几十兆只留 arm64-v8a 和 armeabi-v7a覆盖面基本不受影响包体直接砍掉三分之一以上。注意别为了省体积只留 armeabi-v7a——现在很多应用商店和系统对纯 32 位包越来越不友好而且 32 位进程的虚拟地址空间有限播高分辨率视频更容易 OOM。useLegacyPackaging false决定了 so 是否以未压缩形式打进 APK。设为 false 时安装阶段不做二次解压运行时通过 mmap 直接加载启动更快、占用存储更少代价是对 so 的段对齐有要求这一点在 2.3 节会撞上。2.3 用 readelf 做一轮体检在把 so 塞进工程之前先花两分钟跑一遍检查。这三条命令我在每份陌生 so 上都会走一遍能提前挡掉后面 80% 的玄学崩溃。# 1. 确认架构和位数输出里应该有 ARM aarch64 或 ARM, EABI5 file libijkplayer.so # 2. 看它到底依赖了谁正常应能看到 libijksdl.so / libijkffmpeg.so / liblog.so / libc.so readelf -d libijkplayer.so | grep NEEDED # 3. 看 LOAD 段对齐0x4000 表示按 16 KB 页对齐0x1000 是 4 KB readelf -l libijkplayer.so | grep LOAD第 2 条命令的输出是判断「这份 so 是不是完整一套」的直接证据。如果 NEEDED 里出现了libijkffmpeg.so却在你目录里找不到对应文件装载必然失败报错文案是dlopen failed: library libijkffmpeg.so not found很多人第一反应是去改loadLibrary的名字其实文件根本没拷进去。第 3 条命令对应的是新系统上的一个硬性约束。近期 Android 在 64 位设备上逐步要求原生库支持 16 KB 页大小如果你的 so 是按 4 KB 对齐编译的readelf -l会看到 LOAD 段对齐值是 0x1000在某些新机型上可能出现加载失败或运行异常。这类问题没法靠改 Gradle 绕过只能换一份对齐正确的产物或者自己重新链接。拿到 so 先看一眼对齐值比事后在真机上抓瞎强得多。顺手还可以确认一下这份 FFmpeg 编进去了哪些能力避免「以为支持 RTSP 结果不支持」的尴尬# 从字符串表里捞协议名和解码器名 # 找得到说明大概率编进去了找不到也不绝对可能被合并优化 strings libijkffmpeg.so | grep -x -E rtsp|rtmp|hls|dash|hevc|mjpeg | sort -u这段不是精确探测但足够做一次粗筛。如果业务要拉 RTSP 摄像头流而输出里连rtsp都没有那就别在这个包上耗时间了直接去找带完整协议的编译配置。3. 接进工程跑出第一帧配置、Surface 绑定与关键 option文件放对位置只是准备动作真正决定能不能出画面的是初始化和参数。这一章按「建实例 → 绑 Surface → 设参数 → 释放」的顺序走一遍。3.1 初始化与 Surface 绑定IjkMediaPlayer 的用法和系统 MediaPlayer 很像但有两个地方必须自己兜住库加载顺序以及 Surface 的生命周期。class PlayerActivity : Activity() { private var player: IjkMediaPlayer? null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 顺序固定ffmpeg 提供解封装/解码sdl 提供渲染与音频输出 // player 依赖前两者放最后 System.loadLibrary(ijkffmpeg) System.loadLibrary(ijksdl) System.loadLibrary(ijkplayer) // 调试期打开日志release 包记得改回 IJK_LOG_SILENT IjkMediaPlayer.native_setLogLevel(IjkMediaPlayer.IJK_LOG_DEBUG) player IjkMediaPlayer().apply { setDisplay(surfaceView.holder) // 视频输出目标 setScreenOnWhilePlaying(true) // 播放期间保持亮屏 setWakeMode(thisPlayerActivity, PowerManager.SCREEN_BRIGHT_WAKE_LOCK) setOnPreparedListener { mp - mp.start() } setOnErrorListener { _, what, extra - Log.e(IJK, error what$what extra$extra) true } } } }setDisplay(SurfaceHolder)和setSurface(Surface)二选一。前者适合传统 SurfaceView后者适合自己管理 Surface 的场景比如配合 SurfaceTexture、或者外部传入的 Surface。用 SurfaceView 时务必在surfaceDestroyed里把 display 置空否则 native 层还攥着一个已经销毁的 ANativeWindow切后台再回来就是一片黑日志里只有一行不起眼的ANativeWindow_lock failed。setWakeMode加setScreenOnWhilePlaying这一对在监控类应用里几乎是必需的播放中锁屏会导致解码器被系统挂起恢复时音视频不同步。3.2 九个值得调的 optionsetOption是 ijkplayer 相对系统 MediaPlayer 最大的优势几乎所有 FFmpeg 层和播放器层的参数都能透传。参数按 category 分组常用的三类是OPT_CATEGORY_PLAYER、OPT_CATEGORY_FORMAT、OPT_CATEGORY_CODEC。player?.apply { // ---- 播放器层 ---- // 开启 MediaCodec 硬解0 关闭 setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, mediacodec, 1) // 硬解路径下跟随视频旋转信息自动转屏 setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, mediacodec-auto-rotate, 1) // 硬解时分辨率切换不重建 codec直播切码率场景必开 setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, mediacodec-handle-resolution-change, 1) // 丢帧策略弱网下优先保音画同步而不是画面完整 setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, framedrop, 1) // 起播前先缓存一定数据减少首帧后的卡顿 setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, packet-buffering, 1) // ---- 格式层 ---- // 打开流探测的时长上限单位微秒 // 直播流调小能加快起播点播调大能提高识别准确率 setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, analyzeduration, 500_000) // 探测阶段读取的最大字节数 setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, probesize, 1_000_000) // RTSP 走 TCPUDP 在跨网段/公网环境下丢包严重 setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, rtsp_transport, tcp) // 协议白名单防止播放器被恶意流引导去访问本地文件 setOption( IjkMediaPlayer.OPT_CATEGORY_FORMAT, protocol_whitelist, file,http,https,tcp,tls,rtp,rtsp,udp ) // ---- 编解码层 ---- // 跳过环路滤波硬解不支持的机型上能降一点 CPU画质略降 setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, skip_loop_filter, 0) }参数值有几个容易搞错的地方。analyzeduration和probesize是 FFmpeg 的原始参数前者的单位是微秒不是毫秒写 500 会被当成 0.5 毫秒等于没设后者单位是字节调到几十 MB 会让起播明显变慢。protocol_whitelist不写的话某些版本的 FFmpeg 对嵌套播放列表m3u8 里再引 m3u8会直接拒绝表现是「能拿到流但一直不 prepare 完成」。framedrop打开后画面会掉帧但音画同步更稳做监控预览建议开做点播播放器建议关——用户宁可卡一下也不愿意看到画面跳跃。3.3 生命周期与释放native 播放器最常见的资源泄漏不是内存是 Surface 和解码器实例。release()没调MediaCodec 会一直占着硬件解码单元多进几次播放页就会出现「最后一个能播前面的全黑」。override fun onPause() { super.onPause() // 只是暂停不释放回来继续播 player?.pause() } override fun onDestroy() { player?.let { it.stop() // 先停让 native 线程退出解码循环 it.release() // 再释放回收 Surface 引用与 codec 实例 } player null super.onDestroy() }顺序不能换。先release()后stop()在某些版本上会命中 native 层的空指针因为 stop 需要访问已经被释放的播放上下文。reset()和release()的区别也要分清reset()保留 native 对象、清空数据源适合播放列表场景复用同一个实例release()彻底销毁之后再调用任何方法都是未定义行为。4. 硬解还是软解mediacodec 打开时机与降级路径mediacodec 1一开CPU 占用能掉一半但坑也跟着来。这一章讲清楚它在什么条件下真的生效以及出问题时怎么退回软解。4.1 硬解打开后实际发生了什么设置mediacodec 1不等于一定走硬解。实际路径是播放器拿到视频流的 codec id 和分辨率去查系统 MediaCodecList 里有没有支持该格式的解码器并且这个解码器要满足 profile/level、分辨率、色彩格式的要求。任何一项不满足静默回落到 FFmpeg 软解日志里只有一行ijkplayer: fallback to soft decode之类不主动看日志根本发现不了。所以「我开了硬解怎么 CPU 还是满的」这个问题第一步永远是抓 logcat 确认实际走了哪条路而不是怀疑参数写错了。判断依据可以看解码器名字里有没有OMX.或c2.前缀。另一个容易被忽略的是mediacodec-handle-resolution-change。直播场景里服务端切码率会导致分辨率变化不开这个参数硬解路径会销毁并重建 codec中间有几百毫秒黑屏开了之后走动态分辨率调整代价是部分老旧机型的 codec 实现有 bug切换瞬间直接崩。4.2 花屏、绿屏、黑屏的三种典型成因这三种现象长得像根子完全不同混在一起排查会浪费大量时间。花屏马赛克块、局部撕裂大多出在硬解上原因是解码器输出的 buffer 和渲染端的 stride/色彩格式没对齐。libijksdl.so在做 YUV 到 RGB 转换时对某些私有 tile 格式支持不全尤其是国产芯片上的一些非常规输出格式。表现是同一路流在 A 机型正常、B 机型花屏。这类问题的解法不是改参数是加机型黑名单。绿屏基本可以锁定在色彩空间上。硬解输出 NV12 但渲染端按 I420 解析或者反过来色度平面错位就会整片发绿。少数情况是 Surface 格式不匹配setDisplay传的 SurfaceHolder 类型和 codec 输出不兼容。黑屏分两种。有声音没画面通常是 Surface 生命周期问题或者渲染线程没起来连声音都没有那是根本没解码成功回去看onError的 what 值。前者在surfaceCreated之前就调了start()的场景里特别常见因为 native 层拿不到有效的 ANativeWindow视频帧被直接丢弃。4.3 一套可落地的降级策略我的做法是「先硬后软超时兜底」。起播时打开 mediacodec同时挂一个定时器如果在设定时间内没收到渲染开始回调就关掉硬解重来一次。private var renderStarted false private val fallbackHandler Handler(Looper.getMainLooper()) private fun playWithFallback(url: String) { renderStarted false player?.reset() player?.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, mediacodec, 1) player?.setOnInfoListener { _, what, _ - // MEDIA_INFO_VIDEO_RENDERING_START第一帧真正上屏 if (what IjkMediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) { renderStarted true fallbackHandler.removeCallbacksAndMessages(null) } true } player?.setDataSource(url) player?.prepareAsync() // 3 秒没出画面判定硬解失败降级重试 fallbackHandler.postDelayed({ if (!renderStarted) { Log.w(IJK, hardware decode timeout, fallback to ffmpeg) player?.reset() player?.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, mediacodec, 0) player?.setDataSource(url) player?.prepareAsync() } }, 3000) }MEDIA_INFO_VIDEO_RENDERING_START是判断「真的出画」最可靠的信号比onPrepared准得多——onPrepared只说明流解析完成解码器可能还没吐出第一帧。3 秒这个阈值按业务调直播可以缩到 2 秒点播大码率文件可以放宽到 5 秒。更彻底一点的做法是维护一份机型黑名单把已知硬解有问题的型号在初始化阶段就关掉硬解省掉一次失败重试的等待。黑名单从线上崩溃日志和用户反馈里攒比任何通用规则都准。5. 避坑与排查集成阶段五个翻车现场5.1 只拷了 libijkplayer.so现象应用启动就崩java.lang.UnsatisfiedLinkError: dlopen failed: library libijkffmpeg.so not found。原因libijkplayer.so 的 DT_NEEDED 里声明了另外两个库但目录里只有它自己。动态链接器按依赖链查找失败。解决回 2.1 节三个 .so 必须成套拷贝且来自同一次编译。用readelf -d逐个体检确认每个库声明的依赖都能在同一个 ABI 目录里找到。5.2 混用不同来源的 so现象播放几秒后 native 层 SIGSEGV堆栈落在libijksdl.so里符号解析不出来或者偶发Fatal signal 11复现率大概三成。原因libijkffmpeg.so 的 ABI 接口和 libijksdl.so 期望的不一致。预编译包经常被人东拼西凑从不同版本里各拿一个文件凑成「一套」看起来文件齐了实际二进制接口对不上。解决整包替换不做单文件替换。如果必须更换其中一个先确认两份产物的编译时间戳和 FFmpeg 版本一致strings libijkffmpeg.so | grep -i ffmpeg version能看到版本串。5.3 64 位设备上装不上或直接闪退现象只在部分新机型上出问题APK 安装成功但打开就退logcat 里是couldnt find libijkplayer.so。原因APK 里只打了 armeabi-v7a但设备是纯 64 位环境系统不会回落到 32 位库目录或者反过来的情况只给了 arm64-v8a老设备找不到。解决abiFilters至少要覆盖 arm64-v8a 和 armeabi-v7a 两个。用unzip -l app.apk | grep lib/直接看打包结果里的目录结构比看 Gradle 配置靠谱。5.4 切后台回来画面全黑现象按 Home 键再回来音频正常视频区域一片黑onPrepared也不会再触发。原因SurfaceView 销毁时surfaceDestroyed被调用Surface 已经无效但播放器还持有旧的 ANativeWindow 引用继续往上面送帧。部分场景下 native 渲染线程会卡在 lock 上不返回。解决surfaceDestroyed里立刻setDisplay(null)并pause()surfaceCreated里重新setDisplay(holder)再start()。如果业务允许改用 TextureView 能绕开大部分 Surface 生命周期问题。5.5 新系统上加载失败现象同一份 APK在一部分较新系统版本的设备上dlopen失败报错提到页大小或对齐。原因so 的 LOAD 段按 4 KB 对齐而系统要求原生库支持更大的页粒度。这类产物在老系统上完全正常只有新系统才暴露。解决用 2.3 节的readelf -l确认对齐值0x1000 就是有风险的那一批。短期可以临时把useLegacyPackaging设为 true走解压路径验证是否是这个问题长期方案是换一份对齐正确的编译产物。6. 上线前的全链路验证一个最小闭环集成完别急着接业务代码先造一个只有两个按钮的空壳 Activity把验证跑完整。这个习惯是从一次事故里攒出来的当时把播放器塞进一个复杂页面出问题后花了整整一天才排除掉是别的 SDK 抢了音频焦点。验证顺序按「本地 → 远端 → 弱网 → 前后台」四步走。第一步播一个本地 MP4文件放 assets 里路径写file:///android_asset/test.mp4。这一步能出画说明三个 .so 加载、Surface 绑定、软解链路全通后面再出问题就只可能是网络或流本身。第二步换远端 HTTP 点播观察onPrepared到MEDIA_INFO_VIDEO_RENDERING_START的间隔正常在几百毫秒内。第三步挂弱网工具限速到 200 KB/s看framedrop和缓冲策略是否按预期工作。第四步反复切前后台十次检查onDestroy后 native 层是否有残留线程——用adb shell ps -T pid看线程数是否回到初始水平涨了就说明release()没生效。日志过滤这一环有讲究。ijkplayer 的日志 tag 比较散用一条命令全捞adb logcat -c adb logcat | grep -iE ijk|ffmpeg|mediacodecffmpeg那一行会打出流的编码格式、分辨率、码率mediacodec那一行能确认实际走的是硬解还是软解ijk开头的多半是播放器状态机。三者对照着看起播慢到底是网络探测久还是解码器初始化久一眼就能分清。最后一步是版本锁定。把整包 so 连同它的 SHA256 一起写进工程的 README下次有人想「顺手升个小版本」先看哈希变没变。我吃过一次亏某次只替换了 libijkffmpeg.so测试环境一切正常灰度到 5% 时开始出现零星崩溃回滚加定位花了两天。从那以后我每次动 native 库都强制走一遍「哈希记录 → 单机型验证 → 灰度」这三步再没让同类问题溜到线上。这份 0.8.8 的编译产物本身没什么花哨的价值在于省掉了工具链那一摊事。拿到手先跑第 2 章那三条 readelf 体检确认依赖完整、对齐正确再按第 3 章接进工程第 4 章的降级逻辑照抄一份基本就能覆盖绝大多数播放场景。希望帮到你。本文还有配套的精品资源点击获取