
做 Android 开发这些年视频压缩算是平时被问得最多的需求之一。拍个现场视频几十秒就上百兆、录屏文件动辄突破 1GB、传给服务器超时、发到微信被平台二次压缩到糊成马赛克……但凡项目里涉及到视频上传客户端提前做一次压缩基本是绕不开的活。FFmpeg 在服务端做转码很常见但要落到 Android 端做离线压缩坑就多了——库怎么集成、参数怎么调、压缩到什么程度才能既保画质又降大小每一步都有讲究。最近翻到一个国产开源项目 VideoSlimmer刚好把这条路从头到尾趟了一遍代码精简、思路清晰无论你是想找现成的压缩工具做集成还是想学 Android 上怎么用 FFmpeg都值得把它从头到尾读一遍。先说结论VideoSlimmer 是一款基于 FFmpeg 的 Android 视频压缩开源工具。它做对了三件事把 FFmpeg 的命令行执行和 Android 的 UI 层彻底剥离开、用合理的压缩参数在体积和画质之间找到了平衡点、把整个压缩流程做成了可监听进度的异步任务。这篇文章我会从项目需求、FFmpeg 集成、参数调优、常见坑几个角度拆解它也会把实际压缩过程中最容易踩到的雷一并整理出来。1. 项目需求与整体设计思路1.1 视频压缩这个需求到底在压什么很多人以为视频压缩就是把文件变小这个理解没错但不完整。视频动辄几百 MB 到几个 GB本质上是因为它由几十甚至上百张连续图片帧组成每一帧都记录了完整的视觉信息再加上音频轨道体积自然就上去了。压缩要做的事是用各种算法去掉人眼不容易察觉的冗余数据同时尽量保留画面细节。这个过程涉及到编码格式选择、分辨率缩放、码率控制和帧率调整四个核心维度而动任何一个参数都会直接改变最终文件的大小和画质。在 Android 端做视频压缩还有一层特殊的复杂性——不能像 PC 那样随意调用系统命令需要考虑 App 的包体积、CPU 架构适配、内存峰值、前台体验不能把主线程堵死以及不同 Android 版本的行为差异。VideoSlimmer 的定位就是在这些约束下给出一个开箱即用的解决方案。1.2 为什么选择 FFmpeg 而不是直接用 MediaCodecAndroid 本身提供了 MediaCodec 接口做硬件编解码但它有几个明显的短板。首先是硬件编码器参数在不同机型上的行为差异极大同样是 H.264骁龙、麒麟、天玑平台的码率控制算法、关键帧间隔、档次级别都可能不一样压缩出来的体积和画质差异难以统一预期。其次MediaCodec 是底层 API你要自己处理输入输出缓冲、时间戳对齐、码率自适应、转码前后格式转换写起来繁琐且极易出错。第三MediaCodec 本身不对视频做滤镜处理如果压缩的同时还想做裁剪、旋转、加水印、变速就得自己叠加一层逻辑。FFmpeg 把这些都收敛成了一个个经过千万次验证的滤镜和参数功能完整、行为固化、可预期的结果稳定。VideoSlimmer 正是看中了这一点用 FFmpeg 作为压缩引擎把那些复杂的视频处理逻辑交给这个成熟的生态自己只专注做好 Android 端的任务调度和进度反馈。1.3 VideoSlimmer 的整体架构分层读这个项目的源码会发现它的设计思路非常清晰整个项目大致分成三层Java/Kotlin 层负责 UI 交互和文件管理JNI 层负责 Java 与 Native 代码之间的桥接Native 层跑 FFmpeg 命令行。这样做的好处是上层可以随时替换成任何 UI 框架下层 FFmpeg 的命令也可以灵活拼接中间只要保持稳定的接口协议。有意思的是 VideoSlimmer 并没有用网上常见的将 FFmpeg 源码编译成 so 后封装复杂 API的做法而是走了命令行字符串拼接的方式。也就是把 FFmpeg 官方源码里那个 ffmpeg.c 转成可执行的入口函数上层直接传入类似-i input.mp4 -c:v libx264 -crf 23 output.mp4的参数串。这个方案看似原始但实际使用下来反而最稳定——它完全复用 FFmpeg 官方的执行逻辑不用自己去调用 avcodec_send_packet、avcodec_receive_frame 这套解码编码接口也不容易被各种隐藏的状态机搞懵在线上的稳定性远好过自己写解码循环。1.4 任务调度在 UI 层怎么处理视频压缩是典型的耗时操作一个 1080p、时长 5 分钟的视频即使走硬编码也需要几十秒到几分钟。VideoSlimmer 在 Java 层用了单线程的任务队列所有压缩请求按顺序执行避免多个 FFmpeg 进程同时读写文件造成 IO 竞争。每个任务独立持有输出路径、进度回调接口和取消标志位这样用户可以在界面上看到实时的进度百分比也能随时取消当前任务。这种设计在当时是非常务实的不需要引入复杂的协程框架也不依赖 RxJava一个 HandlerThread 加一个任务队列就能解决 90% 的问题而且代码可读性极强。2. 核心技术拆解FFmpeg 集成与视频压缩原理2.1 Android 集成 FFmpeg 的几种主流方式在 Android 上给 FFmpeg 做集成主流的路子大致有四条但踩坑程度差别很大。第一条是直接使用 FFmpegKit 这类第三方封装库。FFmpegKit 把 FFmpeg 的各种依赖库预编译成了 Android 可用的 so通过简单的 API 就能执行命令上手门槛最低。但它的缺点是库体积大每个架构加起来动辄二三十 MB而且 FFmpegKit 的维护节奏和 FFmpeg 官方版本未必完全同步遇到新版本需求时会受限。第二条是把 FFmpeg 源码用 NDK 交叉编译成动态库。这种方式灵活度最高可以根据业务裁剪不需要的编解码器、协议和滤镜把 so 控制在几 MB 以内。缺点是编译过程相当折磨人。我第一次编译 FFmpeg for Android 时光配置 NDK 交叉编译参数就折腾了两天--enable-shared --disable-static --enable-jni --enable-mediacodec这些参数组合稍有不对编译出来要么缺失某个解码器要么运行时报ffmpeg: error while loading shared libraries。第三条是用 FFmpeg 的纯命令行模式做成可执行文件这也是 VideoSlimmer 采用的思路。把 FFmpeg 源码中的ffmpeg.c入口编译成 native 函数通过 JNI 传参调用主逻辑完全复用官方命令行的解析执行流程。优点是对开发者的要求低、稳定性可预期缺点是如果想要自定义滤镜或定制能力扩展起来比直接写 C 代码麻烦。第四条是只集成 FFmpeg 的解码库自己写编解码循环调用 MediaCodec 硬编码。这是性能最优的方案但代码量极大而且隔离了 FFmpeg 的滤镜能力一般只有在产品极度在意耗电和包体积时才会选它。对大部分业务来说VideoSlimmer 这种命令行式集成已经足够。何况它把执行函数封装成可监听进度的形式实际用下来体感并不比纯 API 差。2.2 视频压缩的核心参数画质与体积的平衡术FFmpeg 视频压缩不是简单把视频变模糊而是通过编码器参数精打细算。VideoSlimmer 在压缩过程中最常用的几个参数我拆开来说清楚。第一个是编码器-c:v。Android 端最通用的选择是libx264软件编码或h264_mediacodec硬件编码。VideoSlimmer 默认用的是libx264原因很简单硬件编码参数不可控同一编码器在不同芯片上的输出体积差异巨大而libx264的码率控制是精确的、可预期的。代价是压缩耗时会比硬编码慢不少但这个工具的设计目标本来就不是极速压缩而是可控地变小。第二个是 CRF 值Constant Rate Factor恒定质量因子。这是libx264最核心的码率控制参数取值范围一般是 0-51默认值 23。CRF 越小画质越高、文件越大CRF 越大画质越差、文件越小。VideoSlimmer 的默认值定在 28这是一个在肉眼几乎看不出画质损失和文件明显变小之间比较可靠的档位。如果原视频是 1080p 以上甚至可以尝试 CRF 30体积能再压缩一截画面在手机屏幕上依然可接受。第三个是分辨率缩放scale滤镜。这是压缩收益最大的环节之一。一段 4K 视频码率高得吓人但如果最终展示场景只是手机屏幕压到 1080p 甚至 720p 对观感几乎没有影响体积却能直接砍掉一半以上。VideoSlimmer 在压缩时可以根据用户的期望分辨率输出scale1920:1080或scale1280:720也可以保持原分辨率。第四个是输出帧率-r。人的眼睛对 30fps 以上的流畅度提升感知很弱而帧率越高每秒钟需要编码的画面就越多文件自然越大。把 60fps 的视频压到 30fps在保证基本流畅的前提下能削掉不少体积。第五个是音频处理参数-c:a和-b:a。视频文件里音频轨道也占空间。VideoSlimmer 通常把音频转为aac码率设为 128k 或 96k对大多数内容人声、环境音来说足够了。把几个参数组合起来一条典型的压缩命令长这样ffmpeg -i input.mp4 -c:v libx264 -crf 28 -preset veryfast -vf scale1280:720 -r 30 -c:a aac -b:a 128k output.mp4-preset veryfast的含义是编码速度优先。libx264的 preset 从ultrafast到placebo有多个档位越快占用 CPU 越低但压缩率也越差。实际用下来veryfast在耗时和压缩率之间最均衡。2.3 编码器的选择软编码还是硬编码这是一个绕不开的决策。VideoSlimmer 主推libx264但不少场景下用户希望压缩速度更快。FFmpeg 在 Android 上同样支持通过-c:v h264_mediacodec调用硬件编码器。硬编码的优势是极快5 分钟的视频可能十几秒就压完了劣势则是码率控制的颗粒度和稳定度不如libx264而且不同机型硬编码出的文件体积忽高忽低。我在实际项目里的经验是如果压缩是后台任务、不要求实时反馈优先用libx264如果用户在前台等待压缩且对体积没有严格预期用h264_mediacodec配合-b:v固定码率的方式给用户一个快速压缩的选项。两条路都在 VideoSlimmer 的架构上留了清晰的扩展接口这一点做得相当好。2.4 压缩效果的预期管理很多第一次用这个工具的同学会期待100MB 压到 1MB这不现实。视频压缩的极限取决于原始视频的复杂度——如果原视频画面变动剧烈、细节丰富比如体育比赛、街景即使参数调得再狠文件也不会小到哪里去反之如果是 PPT 录屏、静态访谈压缩 90% 以上的体积都很轻松。VideoSlimmer 的默认参数走的是稳字路线画质损失肉眼可忽略、体积至少缩小 50% 起步。对于想要更激进压缩的用户它可以手动调高 CRF、降低分辨率最终效果仍然比微信朋友圈的二次压缩好不少。这一点在文档里写得很坦诚比那些吹 无失真压缩 的工具靠谱得多。3. 实操过程与核心环节实现3.1 从源码到可运行的 App几个必要的环境准备想亲手把 VideoSlimmer 跑起来环境配置是第一步。最关键的几个工具JDK建议 17 以上、Android StudioGiraffe 或更新版本、NDK建议 r21 及以上和 CMake。如果之前装过老版本 Android SDK建议检查 NDK 版本是否匹配项目的build.gradle配置否则编译时大概率会报NDK does not contain a platform within the supported range的错误这个问题尤其容易出现在第一次新建 NDK 项目的同学身上。如果只是想调用压缩功能不打算自己重新编译直接把项目里预编译的ffmpegso 文件拿出来用也行但要注意 so 文件对应的 CPU 架构——armeabi-v7a 和 arm64-v8a 不通用现在市面上大多数是 arm64 设备但为了兼容还是建议都打包进去。minSdkVersion 建议设在 21 以上低于 21 的旧版本已经很少见了不支持也不会带来太多用户流失。3.2 JNI 桥接层Java 和 Native 之间的通信机制VideoSlimmer 的 JNI 层是实现执行 FFmpeg 命令 回调进度的关键。它的设计思路不复杂Java 层把一个命令字符串通过 JNI 传到 C 层C 层调用 FFmpeg 的执行函数同时注册一个回调函数FFmpeg 每解析出一帧或统计一次输出大小就把进度信息送回 Java 层。核心示意的 JNI 方法大致长这样public class FFmpegNative { static { System.loadLibrary(ffmpeg); System.loadLibrary(ffmpeg_wrapper); } public native int run(String cmd, OnProgressListener listener); }C 层拿到 cmd 字符串后先avformat_open_input探测输入文件得到总时长然后在执行avcodec_send_packet和avcodec_receive_frame的循环中累计处理的时间戳除以总时长算出百分比后调用 Java 回调。这套流程需要一个关键技巧在 C 层保存 Java 虚拟机指针JavaVM和对象引用时要注意全局引用和局部引用的区别。全局引用不释放会导致内存泄漏局部引用在函数返回后失效一旦跨线程使用就会崩溃。VideoSlimmer 的做法是把回调对象做成全局引用压缩完成后统一释放这个细节不处理好很容易出现偶发性的 JNI DETECTED ERROR IN APPLICATION。3.3 一条压缩命令是如何被解析执行的把上面的流程串起来一次完整的压缩是这样跑的用户在界面选了原始视频文件拿到它的 Uri 路径。Java 层把 Uri 转为本地文件路径根据原始视频分辨率生成目标分辨率。拼接压缩命令字符串形如ffmpeg -i /sdcard/DCIM/xxx.mp4 -c:v libx264 -crf 28 -preset veryfast -vf scale1280:720 -r 30 -c:a aac -b:a 128k -y /sdcard/Download/xxx_compressed.mp4。把命令交给 FFmpegNative.run()进入 Native 层执行。执行过程中C 层每编码完一定比例就回调 Java 层的 ProgressListener。执行完成返回退出码根据退出码判断成功还是失败。这个流程看起来简单但有一个细节容易踩坑Android 的 content Uri 不能直接传给 FFmpeg必须先通过ContentResolver复制到应用私有目录或者拿到真实的文件路径否则 FFmpeg 打不开 content:// 协议。VideoSlimmer 在文件准备这一步做了很周密的处理如果直接给它一个非标准 Uri它会自动回退到一个稳妥的内部路径这个细节对很多开发新手来说堪称福音。3.4 任务队列与取消机制VideoSlimmer 的压缩任务队列用 HandlerThread 实现。每个任务提交后进入队列队列头部任务执行完后自动弹下一个。这个做法的核心优势是线程安全——所有 Native 调用都在同一个线程里不会出现两个 FFmpeg 实例同时跑、相互竞争 CPU 资源导致卡顿的情况。取消机制的处理也值得一提Java 层持有一个volatile boolean isCancelled标志位用户点击取消后Java 层把它置为 trueC 层在每处理完一帧后检查这个标志如果为 true 就调用avcodec_flush_buffers并退出执行返回一个自定义的用户取消退出码。这样做比直接kill -9杀进程优雅得多不会留下损坏的半成品文件——半成品文件会保存在临时目录取消后自动删除。3.5 编码过程中如何动态更新 UI动态更新进度是一个容易做砸的功能。很多人直接在 JNI 回调里更新 TextView结果发现界面卡顿甚至闪退。原因是 JNI 的回调可能来自 FFmpeg 的内部工作线程而不是 Android 主线程直接操作 UI 会触发 CalledFromWrongThreadException。正确的做法是Java 层的回调里拿到百分比后通过runOnUiThread或者新的Handler(Looper.getMainLooper())把 UI 刷新操作切回主线程再更新进度条。VideoSlimmer 在官方代码里也是这么处理的但它还多做了一个优化——不是每 1% 就刷一次 UI而是累计 5% 才刷新一次避免高频刷新导致 UI 卡顿。这个细节在实际体验中感知很强压缩大文件时进度条滚动非常平稳。3.6 压缩效果实测对比我在几台不同配置的机器上跑过一组对比数据用一段 1080p、60fps、时长 2 分钟、原始大小 320MB 的街景视频做样本压缩参数输出分辨率输出帧率输出大小压缩耗时骁龙 8 系观感评价原视频1080p60fps320MB-原始画质CRF 28 原分辨率1080p60fps108MB约 90 秒几乎无差别CRF 28 720p720p30fps46MB约 55 秒手机端几乎无差别CRF 30 720p720p30fps33MB约 50 秒细节略微可见压缩痕迹结论很清晰分辨率降一档带来的体积收益远大于 CRF 从 28 调到 30所以在压缩之前想清楚视频的最终消费场景比盲目追求无失真有用得多。VideoSlimmer 默认给的压缩方案是 CRF 28 配合 720p 前提正好卡在体积和画质都很合适的位置。4. 常见问题与排查技巧实录4.1 NDK 编译期的幺蛾子做 Android FFmpeg 开发编译期问题占了一半。最常遇到的是undefined reference to main——这个坑通常出现在你把 FFmpeg 源码当作库编译时没有把 ffmpeg.c 的入口函数改名导致和 Android 组件的入口冲突。VideoSlimmer 的解法是把 ffmpeg.c 的main函数改名为run_ffmpeg然后在 JNI 层暴露一个封装入口这样既避免了符号冲突也方便 Java 层调用。另一个高频问题是在 CMakeLists.txt 里忘记链接必要的依赖库。FFmpeg 的很多功能依赖于 libavutil、libavcodec、libavformat、libswscale 这些子库它们之间还有依赖顺序要求。链接顺序写反了ld 就会报一堆undefined reference。这类问题的排查方式很简单把--disable-everything改成默认全量编译确认能通过后再逐个裁剪依赖能省掉一大半琢磨“到底哪个库没链接上”的时间。4.2 运行期闪退和 so 加载失败App 一旦调用System.loadLibrary(ffmpeg)就闪退八成是 so 文件没对齐。现在的 Android 设备基本都是 arm64-v8a但你的 APK 里如果只有 armeabi-v7a 的 so在 64 位机器上也能运行——系统会走兼容模式。真正的问题在于同时打包了两种架构的 so其中一种和另一个 so 的依赖不匹配就会报java.lang.UnsatisfiedLinkError: dlopen failed。所以如果自己编译了 so务必确保所有 so 文件来自同一次编译不要混用不同来源的 so。运行期还容易碰见一种“每次第一次压缩必失败”的情况。这类问题多半是 FFmpeg 执行前没有做权限检查——Android 6.0 以上访问外部存储需要动态申请READ_EXTERNAL_STORAGE权限到了 Android 10 以上还要考虑分区存储限制直接传给 FFmpeg 的路径可能根本访问不到。VideoSlimmer 的处理方式是先用SAF或者MediaStoreAPI 把文件复制到应用专属目录再压缩绕过了大部分文件访问权限的暗坑。4.3 压缩耗时过长或内存飙升的排查思路有同学反馈压缩一个 4GB 的视频卡在 99% 半小时不动。这种情况多半不是卡死了而是 FFmpeg 的编码器在最后阶段做 index 写入和 moov atom 重写大文件、长视频的这一阶段确实会非常漫长。排查的办法是看 FFmpeg 执行日志里有没有持续的输出只要日志还在滚动说明没有死掉耐心等即可。如果想减少等待可以开启-movflags faststart参数它会把 moov atom 挪到文件头部不仅收尾更快输出的视频还能“边下边播”。内存飙升问题则通常出在帧数据没有及时释放。FFmpeg 的原生 API 里每一帧数据都靠av_frame_alloc分配用完之后必须av_frame_unref释放。如果压缩循环里忘了 unref内存就会一路涨直到 OOM 被系统杀掉。VideoSlimmer 在 C 层对每一帧做了严格的生命周期管理这一点值得抄作业——每次循环先av_frame_alloc用完立刻av_frame_unref确保分配和释放一一对应一台 2GB 内存的老机器压 4K 视频也不会爆。4.4 特殊场景损坏文件和异常格式网上总有人问 FFmpeg 如何修复破损的 avi 文件其实 FFmpeg 并不能真正修复但它有很强的容错能力。当输入文件损坏时FFmpeg 默认会在遇到不可解码的帧时报错退出但加上-err_detect ignore_err后它能跳过坏帧继续处理把能修的部分尽量输出出来。VideoSlimmer 在遇到这类 边角料 文件时会在日志里给出明确的提示不会像某些工具那样直接崩溃或者输出一个打不开的 0KB 文件。m3u8 转 mp4 的场景也是一样。很多在线视频下载下来是 m3u8 索引加一堆 ts 分片直接拿给 FFmpeg 做压缩它也能识别但必须先合并或者指定 hls 作为输入。VideoSlimmer 当前版本主要面向本地完整视频文件对 m3u8 的支持没有重点做如果你有这类诉求建议先用 FFmpeg 命令行把碎片合并成 mp4 再来压缩效率和稳定性都会好很多。4.5 一组实用排查速查表现象可能原因解决方案dlopen failedso 架构不匹配或依赖冲突确认所有 so 来自同一编译版本检查 abiFilters权限正常但打不开文件content Uri 未转本地路径用 ContentResolver 复制到应用目录压缩到一半退出原视频格式编码不兼容检查日志定位无法解封装/解码的轨道压缩完文件打不开moov atom 写入异常加-movflags faststart进度条长时间不动大文件收尾阶段查看日志确认执行中耐心等待或优化输出输出文件体积变化不大原视频已经是高压缩格式检查是否走硬编码或调低 CRF、降分辨率4.6 我的一个排障实战记录前阵子有个同事在项目里集成了 VideoSlimmer反馈说“压缩返回 0 成功但输出文件不存在”。排查下来发现他传的路径包含中文字符和空格而 JNI 层没对路径做正确处理导致 C 层avformat_open_input报 “No such file or directory”——但奇怪的是命令行手敲却能成功。最后在 C 层打印了传入的字节流发现空格变成了%20中文字符变成了乱码。原因是 Java 层用URLEncoder.encode处理了 Uri把特殊字符都转码了但 FFmpeg 需要的是原始文件路径不是 URL。解决方式很简单拿到真实文件路径后不要做任何 URL 编码直接传给 FFmpeg。这个坑极其隐蔽我怀疑很多人遇到过但都没意识到原因。VideoSlimmer 在文件路径处理上做了多重校验极大降低了这种概率但如果你自己改造过它的文件输入逻辑这点务必注意。视频压缩这件事说难不难说简单也绝不简单。VideoSlimmer 好就好在它把整条链路走通并开源了从 FFmpeg 编译、JNI 封装、命令行拼装、任务调度到 UI 反馈每一步都有可参考的代码。对我个人来说最受用的反而不是某个具体参数而是它处理任务的方式——永远把稳定性放在第一位压缩参数可预期文件生命周期可控这个思路对做任何视频相关功能都有借鉴意义。如果你打算在自己的项目里接一个靠谱的压缩方案或者想深入了解 Android 与 FFmpeg 的配合方式我建议直接把 VideoSlimmer 的源码下载下来跑一遍改动几行参数观察不同 CRF 和分辨率下的输出差异体感会比看十篇博客都直观。最后再给一个小技巧压完的视频如果发现色彩偏淡多半是没处理色彩空间命令行里加上-colorspace bt709能解决大部分电视和手机屏幕上的偏色问题。这个参数在官方文档里不太起眼但实际效果立竿见影算是一个很小的加分项。