FFmpeg与Android视频压缩:VideoSlimmer项目实战解析 FFmpeg开发笔记这个系列能写到第一百篇我自己也没想到。走到今天选题越来越偏向实际动手的东西。这一篇聊一个我从开源社区淘来的Android视频压缩工具名字叫VideoSlimmer。它不算什么大牌项目但结构清楚、功能聚焦用FFmpeg把手机里动辄几百MB的视频压缩成几MB到几十MB同时保持肉眼基本可接受的画质。换句话说它把FFmpeg命令行里那些复杂的编码参数封装成了手机上几个按钮和滑条。如果你正在学FFmpeg又想知道它在Android端怎么落地VideoSlimmer是个很合适的参考样本如果你只是需要一个离线、不传云端、本地完成压缩的工具这个项目也值得拿来直接用。1. VideoSlimmer是什么一个用FFmpeg做视频压缩的Android开源项目1.1 项目背景与功能概览先说说我为什么会注意到它。做FFmpeg相关开发久了手机上经常要处理各种测试视频录屏、相机直出、从网上下载的样本。微信发视频会压一遍系统相册自带压缩只能选个档位具体压成什么码率、什么分辨率基本不可控。这时候就需要一个能自己指定参数的压缩工具VideoSlimmer刚好补上这个空缺。从功能上看它做的事情可以归纳成三步选一个视频设置压缩参数点开始。压缩完成后新文件会存到本地相册或者应用专属目录用户可以选择保留或删除原文件。如果往工程层面拆则至少包含这些模块视频文件选择与媒体信息读取获取时长、分辨率、旋转角度、码率压缩参数配置涉及分辨率缩放、编码器选择、CRF值、帧率、音频码率FFmpeg命令的组装与异步执行不能在主线程跑否则必然ANR执行进度回调让界面上能看到当前压缩到百分之多少压缩结果处理写入系统相册、显示新文件大小、对比压缩率这类工具的技术栈其实不复杂核心就两个FFmpeg的Android封装库以及围绕文件访问和界面交互做的一层壳。难的地方在于把这层壳做稳让用户从相册选完视频到拿到压缩结果全程不崩溃、不丢文件、进度还看得明白。1.2 为什么要自研而不是直接用现成App有人会问手机上压缩视频直接用系统相册不就行了吗这个问题我试过之后才有了更深的体会。系统相册的压缩模式属于“闭眼压”压完就是微信里能发的等级清晰度损失大而且没有参数可以调。微信虽然提供“标清/高清”选择但它本质上是在上传后端处理过的版本属于联网方案而且为了传输效率会把画质压得非常狠。自研一个FFmpeg封装工具的差异在哪里我给你列个对比表看着更直观。方案可控性压缩速度画质表现隐私性适用场景系统相册压缩低无法控制参数较快一般压得糊本地处理日常快速发送微信/网盘上传压缩低交给服务端受网速影响不可控压缩率高内容需上传服务器社交分享VideoSlimmer自研方案高编码参数全可控取决于软硬编组合可调能保持较好观感全本地离线压缩技术测试、素材预处理对普通用户来说自研工具的意义在于离线处理不泄露隐私尤其是拍到的门牌号、聊天记录这类不想传服务器的画面对开发者来说这个项目的价值在于演示了一条完整的FFmpeg集成路径从命令行工具变成手机App从PC端调参变成移动端调参整个过程逻辑链条非常清晰。这也是我推荐大家去读源码的原因。1.3 为什么FFmpeg是移动端视频压缩的优选做移动端视频处理绕不开一个选择用系统自带的MediaCodec还是用FFmpegMediaCodec作为Android官方硬件编解码接口优点是速度快、耗电低但缺点也很明显参数控制颗粒度粗CRF、preset、GOP大小这些在码率控制里很常用的概念MediaCodec要么不开放要么不同厂商实现差异巨大。你想让不同机型的压缩效果保持一致光适配硬件编码器的差异就能折腾掉大量时间。FFmpeg的方案则把编码器做成了插件式结构。今天你可以在上面跑软件编码器libx264明天也能切到硬件编码器h264_mediacodec上层业务代码甚至不用改太多只要把编码器名字换掉就行。这意味着你可以先做一个通用版本再针对具体机型做编码器优化改动成本低很多。VideoSlimmer能在各种Android设备上跑得相对稳定正是得益于FFmpeg这种横跨软硬编码器的统一接口设计。2. 核心原理FFmpeg视频压缩到底在压什么2.1 压缩的本质是重新编码不是裁剪内容在真正动手写代码之前得先把原理掰扯清楚。视频文件体积的估算公式很简单文件大小约等于码率乘时长码率单位是bps时长单位是秒除以8得到字节数。一个1080P、30fps、码率20Mbps的1分钟视频体积大概就是20乘60除以8等于150MB左右。所以压缩的思路非常直接降低码率。但码率不是想降到多低都行降太多画面就花了。那怎么办答案是转码。FFmpeg把原始视频解封装、解码成一张一张的图像然后通过编码器重新压缩这个过程叫重新编码。重新编码时可以把分辨率从1080P缩到720P可以把帧率从60fps降到30fps可以用更高效的编码器比如H.265替代H.264也可以在画质略微受损的前提下把码率压下来。我用一个生活化类比来解释原始视频像一本排版稀疏、插画很多的画册重新编码相当于用更紧凑的字体、更合理压缩比的图片格式把同样内容重新印了一本更薄的书。书里的信息没变阅读体验可能稍有变化但体积小了一大截。2.2 影响压缩效果的几个关键参数VideoSlimmer这类工具的参数配置普通用户看着就发懵但底层其实就是在调下面这几个变量参数作用推荐取值范围注意事项分辨率scale决定输出画面像素总量影响体积最明显保持原分辨率或降一档不宜超过原始分辨率否则反而增重CRF值恒定质量因子数值越小画质越好、文件越大18到28比较常用低于18文件巨大高于28画面劣化严重preset编码器预设影响压缩速度和压缩比ultrafast到slow越慢压缩率越高但耗电和耗时同步上升帧率fps每秒画面数普通视频30fps足够24/30体育、运动场景不要盲目降帧率音频码率声音部分的码率控制96k到192k语音视频可以用更低码率这里最容易踩的坑是CRF的理解。它不是码率上限而是质量目标。同一个CRF值画面复杂时码率会自动升高画面简单时码率会自动降低所以它最适合手机拍摄的这种内容变化很大的场景。如果你硬编码一个固定码率比如3000kbps拍静态画面时浪费码率拍动态画面时又不够用画质会忽好忽坏。2.3 为什么优选x264/x265软编而不是硬编VideoSlimmer核心处理链路上选用了软编码器这一点在实际使用中是会带来一点争议的。软编码浪费CPU、发热明显为什么不用硬编码我的经验是软编码换来的是可预期的结果以及跨设备的稳定性。硬件编码器h264_mediacodec在不同机型上的码控行为差异非常大。有的手机固件把码率控制做得很激进CRF概念被简化成“目标码率”压出来的文件不稳定有的手机对高分辨率输入支持不好压缩过程直接报错。而libx264是经过海量场景验证的软件编码器同样的CRF值在不同设备上压出来的效果基本一致方便排查问题。x265则是在追求更小体积时的选项。同样画质下x265通常能比x264再节省30%到50%的体积代价是编码时间更长。比如一段1080P的1分钟视频x264可能30秒压完x265可能要压3分钟。VideoSlimmer把编码器选择开放给用户是比较合理的策略想快用x264想省空间用x265不同场景自己做取舍。3. Android工程集成FFmpeg的方案选型3.1 几种常见的FFmpeg接入方式对比在Android工程里用FFmpeg方案大致有四类。第一类是打包一个编译好的FFmpeg可执行文件放在assets里运行时解压到应用私有目录再通过ProcessBuilder去执行。这种方式最简单但进程启动开销大开发者拿不到转码过程中的进度数据只适合应急。第二类是使用现成的开源封装库比如FFmpegKit它提供了Java层API可以异步执行命令、获取进度和日志是目前社区里最活跃的方案之一。第三类是引入国内开发者维护的库比如RxFFmpeg它对命令执行和任务管理做了更符合移动端习惯的封装。第四类是下载FFmpeg源码自己用NDK交叉编译出so库再手写JNI桥接层最灵活但工作量最大。我整理了对比表方便你选型时参考接入方式集成难度功能完整性体积影响维护状态适用项目自带可执行文件低低无法回传进度中等取决于FFmpeg版本简单工具、内部测试FFmpegKit中高支持进度/日志/取消较大活跃大多数生产项目RxFFmpeg中高任务队列管理完善较大更新放缓国内业务优先、视频编辑类自编译soJNI高完全可控可裁剪到最小自维护有定制需求的底层产品VideoSlimmer这类工具型项目我个人建议优先考虑FFmpegKit。因为它不需要你去写复杂的JNI代码却仍然保留了完整的FFmpeg能力。即使只看它内部实现也能学到一套标准的异步任务封装思路。3.2 编译FFmpeg for Android的脚本要点如果你非要走自己编译so这条路线我给你一个能跑通的思路。首先准备Android NDK推荐r25c或更新的版本。然后下载FFmpeg源码如果需要x264/x265软编码支持还要单独编译对应的编码器库。编译的核心是configure这一步决定了最终so里包含哪些功能。一个最基本、支持x264的编译脚本参数大致长这样#!/bin/bash NDK/path/to/android-ndk-r25c TOOLCHAIN$NDK/toolchains/llvm/prebuilt/linux-x86_64 API21 build_android() { ./configure \ --target-osandroid \ --arch$1 \ --cpu$2 \ --cc$TOOLCHAIN/bin/$3 \ --enable-cross-compile \ --enable-gpl \ --enable-libx264 \ --enable-mediacodec \ --disable-doc \ --disable-programs \ --disable-avdevice \ --disable-postproc \ --disable-network \ --disable-symver } # armeabi-v7a 为例 build_android armv7a armv7a-linux-androideabi armv7a-linux-androideabi21-clang这里有两个容易踩的坑。第一不要贪多把所有功能都编进去so库体积会膨胀到让你怀疑人生压缩视频用不到网络协议和复杂封装格式的话就把network、avdevice这些模块关掉。第二FFmpeg版本和NDK版本的匹配很关键新版本FFmpeg可能要求更高版本的NDK否则编译链会报错。如果你不想在这个环节耗太多精力直接用FFmpegKit预编译好的包更省事。3.3 初始化与执行命令的封装写法不管用哪个库最终执行命令的模式都差不多因为FFmpeg的本质就是一条命令行工具。以FFmpegKit为例压缩一条视频的核心代码可以写成这样FFmpegKit.executeAsync( -i $inputPath -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -y $outputPath ) { session - val returnCode session.returnCode if (ReturnCode.isSuccess(returnCode)) { // 压缩成功 } else { // 压缩失败读取日志定位问题 Log.e(FFmpeg, session.allLogs.toString()) } }注意这里用的是executeAsync而不是execute。因为视频压缩是耗时操作同步执行会卡死UI线程造成ANR。FFmpegKit内部会自动在独立线程里执行命令回调结果时切回主线程即可。异步执行的好处还有一个可以随时拿到执行的日志流为后续的进度展示做准备。4. VideoSlimmer压缩流程拆解与实操4.1 选视频与解析媒体信息用户从相册里选择一个视频拿到的不是本地绝对路径而是一个content:// URI。这个细节很多人第一次做会翻车。你不能直接把这个URI丢给MediaMetadataRetriever或者FFmpeg命令行因为底层C库不认content协议。正确做法是先用ContentResolver查一下OpenableColumns拿到文件名和大小必要时把文件复制到应用缓存目录再操作。媒体信息解析用MediaMetadataRetriever是Android官方推荐的方式。val retriever MediaMetadataRetriever() retriever.setDataSource(context, uri) val duration retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_DURATION )?.toLong() ?: 0L val width retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH )?.toInt() ?: 0 val height retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT )?.toInt() ?: 0 val rotation retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION )?.toInt() ?: 0rotation这个字段经常被忽略但它特别重要。手机拍摄的视频经常带着旋转角度元数据屏幕转90度、180度都常见。如果你在压缩时没有处理旋转压出来的视频要么是横躺的要么画面上下颠倒。这个数据到后面拼FFmpeg命令时要用到。4.2 压缩任务的核心代码实现把用户设置的选项翻译成FFmpeg命令是整个工具最核心的部分。我的做法是提供一个专门的方法负责把输入、输出、画质档位、是否降分辨率、是否压帧率这些信息拼装成一条完整的命令字符串。fun buildCompressCommand( inputPath: String, outputPath: String, scaleWidth: Int, scaleHeight: Int, crf: Int, encodeName: String, fps: Int, rotation: Int ): String { val filter mutableListOfString() if (rotation 90 || rotation 270) { filter.add(transpose${if (rotation 90) 1 else 2}) } if (scaleWidth 0 scaleHeight 0) { filter.add(scale$scaleWidth:$scaleHeight) } val filterPart if (filter.isEmpty()) else -vf ${filter.joinToString(,)} return -i $inputPath -c:v $encodeName -crf $crf -preset medium -r $fps filterPart -c:a aac -b:a 128k -y $outputPath }这个方法的思路很清楚先处理旋转再处理缩放最后统一加滤镜参数。这里有个容易出错的地方transpose滤镜的顺序一定要放在scale前面因为旋转会改变宽高如果你先缩放到720x1280再转置最后得到的尺寸可能变成960x1280之类的奇怪值。命令拼好之后调用FFmpegKit的异步接口执行即可。执行期间要给用户一个可取消的入口因为手机处理大视频动辄几分钟用户很可能中途想放弃或者改参数重压。4.3 进度回调与任务取消的实现进度是压缩工具面子工程里最重要的一环没有进度的压缩任务会让用户以为App卡死了。FFmpeg执行过程中会持续输出日志其中包含time00:00:10.00这样的时间戳格式是时分秒。只要解析出这个时间再除以总时长就能算出百分比。var totalSeconds duration / 1000.0 FFmpegKitConfig.ffmpegKitConfigSetLogDelegate { log - val message log.message val timeMatch Regex(time(\\d):(\\d):(\\d\\.\\d)).find(message) if (timeMatch ! null) { val hours timeMatch.groupValues[1].toDouble() val minutes timeMatch.groupValues[2].toDouble() val seconds timeMatch.groupValues[3].toDouble() val elapsed hours * 3600 minutes * 60 seconds val progress (elapsed / totalSeconds * 100).toInt().coerceIn(0, 100) runOnUiThread { updateProgress(progress) } } }这段逻辑在很多FFmpeg类App里都能看到属于通用套路。进度显示要处理两个边界情况第一视频有音频轨时日志里的time可能因为不同流的处理速度不一致而出现跳动可以不做平滑但不要让它回退到比以前更小的值第二压缩刚启动头一两秒可能没有time输出界面可以先显示百分之零加一个转圈避免用户误以为卡死。取消操作同样走FFmpegKit的API。在开始压缩时记录session id取消时调用FFmpegKit.cancel(sessionId)即可。但取消后必须清理临时文件避免残留的半个视频占用存储空间。5. 开发中踩过的坑与性能调优5.1 分区存储下的文件访问问题Android 10以后的分区存储限制是这类工具开发中最大的拦路虎。早期Android版本里我们可以自由读写外部存储路径但新版系统里访问相册文件、写入外部存储都被严格限制。直接new File(uri.getPath())这种做法现在基本上行不通得到的不是真实路径而是一个没有意义的内部标识。解决思路是彻底放弃路径思维全部改用ContentResolver。读从传入的content:// URI读取输入流复制到应用目录写通过MediaStore插入一条视频记录拿到输出流写入压缩后的数据。val values ContentValues().apply { put(MediaStore.Video.Media.DISPLAY_NAME, compressed_${System.currentTimeMillis()}.mp4) put(MediaStore.Video.Media.MIME_TYPE, video/mp4) put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES /VideoSlimmer) } val uri contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values) contentResolver.openOutputStream(uri)?.use { outputStream - outputStream.write(data) }这样既绕开了存储权限限制又能自动出现在系统相册里。Model在规划时直接把输出文件写到相册目录还能省去用户手动去找文件的麻烦。开发时注意插入MediaStore之后压缩完成后要关闭流并且可以发一个广播通知系统刷新相册。5.2 音画不同步与旋转信息丢失压缩后出现音画不同步是测试中很容易复现的问题。最常见的诱因是fps设置不当。比如原视频是29.97fps你强行压成30fps音频流保持原样这样音视频时间轴就对不上了长时间播放后偏差会越来越明显。解决方法是让视频帧率与音频时间基准对齐办法是不要盲目减帧率如果只是为缩小体积降分辨率和CRF基本就够了。旋转信息丢失的问题上一节已经提到在拼filter时要做处理。此外还有一个隐藏点旧版本FFmpeg默认会自动读取旋转元数据并应用旋转导致画面旋转两次。如果你用的FFmpeg库是较新版本但项目里对rotation做了手动处理就需要关掉自动旋转。我实际建议是让FFmpeg自己做自动旋转自己只读取最终输出的宽高不要在filter里再叠加transpose除非你确定库的版本行为。这个取舍在不同封装库上有差异最好用一段带旋转信息的测试视频多试两次。5.3 软硬编码器动态选择的兼容性同一个压缩工具在小米手机上跑得好好的换到某款海思芯片的平板上就压不了这不是奇怪事。原因是部分Android设备的MediaCodec只实现了H.264解码没实现编码或者只支持特定分辨率。如果用h264_mediacodec强行压缩FFmpeg初始化编码器时会直接失败。稳妥的做法是给用户提供编码器选择并默认走软编libx264软编的兼容性最保险。如果你确实想用硬编提速可以给硬编失败自动降级的逻辑先试h264_mediacodec初始化失败就改回libx264整个过程对用户透明。VideoSlimmer这类工具经常会提供“高速模式”和“画质优先”两个档位前者走硬编或者低质量软编后者走慢速高画质软编这样无论设备兼容性如何都能得到一个合理结果。5.4 内存与耗电优化压缩一个1080P视频会消耗大量内存和电量而且发热明显。开发时需要注意几点。压缩任务建议放到前台服务中执行并绑定一个通知告知用户“正在压缩视频”否则应用退到后台后系统可能在几分钟内杀掉进程导致压缩中断。如果不用前台服务至少也要用WorkManager它能利用系统空闲时间执行任务但实时性不如前台服务。分辨率参数要结合设备屏幕大小给默认值不要一上来就输出4K甚至8K。手机屏幕普遍是1080P级别压缩到1080P和720P在手机上看差距很小但文件体积差距巨大。把“默认压缩到1080P”作为起始档位对于大多数用户是合适的。另外压缩过程中FFmpeg会吃满多核CPU会让手机快速发热可以在App里检测电池温度超过一定阈值就自动暂停任务或者提示用户降低画质档位。5.5 API与权限的配合细节开发过程中很容易忽略的是运行时权限请求。如果只想访问系统相册并写入自己的应用目录新版Android用Photo Picker之类的系统选择器就够了不需要申请存储权限。但如果要往公共相册里写入MediaStore在某些版本上可能仍需要申请权限尤其是Android 13及以后新增的READ_MEDIA_VIDEO权限。我建议在进入压缩前先检查权限状态避免用户选完视频进入压缩流程后才发现没有写权限那个体验非常糟糕。FFmpeg库的体积也要留意。FFmpegKit的release包几十MB是常事如果对包体大小敏感可以用FFmpegKit提供的精简版配置只保留需要用的编解码器和封装格式。去掉不需要的网络协议和滤镜单so库可以控制在10MB左右这对工具类App来说很关键。6. 这个工具还能往哪个方向扩展6.1 从单文件压缩到批量任务VideoSlimmer目前处理单文件是够用的但它潜力可以做得更大。批量压缩的需求在短视频创作和素材处理场景里非常常见。比如你出差拍了十几段素材想统一压成720P发给同事预览一个个选文件、点按钮会发疯。批量场景要在工程上引入任务队列。把压缩任务抽象成一个data class包含输入URI、输出名、编码参数、状态然后用一个队列管理器挨个执行。每项任务执行完要记录结果、生成缩略图并且允许用户中途取消剩余任务。这个改动其实就是状态机和并发管理的叠加核心压缩逻辑不用动但整个工具的价值会一下子提升很多。6.2 从本地视频到CameraX录制的结合另一个有意思的方向是把压缩能力前置到录制阶段。用CameraX录制视频然后马上调用FFmpeg转码这样用户在拍摄结束后能立刻拿到一个压缩好的版本。配合FFmpeg的滤镜能力还能顺手实现加水印、加时间戳、画面裁切这些功能。这个方向比单纯压缩要复杂一些涉及相机权限、录制格式、底层Surface等一堆东西但效果也很直观一个录制、编辑、压缩闭环的产品原型就出来了。很多短视频工具内部就是这么干的录制时先用硬编做临时存储最终输出时再用FFmpeg做统一封装和压制。VideoSlimmer已经把压缩这个最硬核的环节打通了扩展起来逻辑上是顺的。6.3 结合Media3提升播放与编辑体验最后说一个我认为更现代的玩法Media3是Android官方现在的媒体处理框架它的Transformer类支持视频编辑但编码参数调节能力依然不如FFmpeg灵活。一个比较理想的架构是把两者结合Media3负责播放预览和基础剪辑FFmpeg负责最终的高质量导出压缩。在这种架构下VideoSlimmer的角色就不再只是一个压缩工具而是转码核心引擎。剪辑的结果通过Media3输出一个中间文件然后交给FFmpeg做最后一道压缩。用户能拥有像样的剪辑交互体验同时又能拿到FFmpeg级别的码率控制。这种组合方案在国外一些开源项目里已经有人尝试未来大概率会成为Android视频处理的主流形态之一。回到实战层面我个人的体会是FFmpeg在Android上真正的难点根本不在命令参数而在于文件访问、任务生命周期、编解码器兼容性这些工程边界条件。VideoSlimmer作为一套聚焦压缩功能的中小型项目把这些边界问题都处理得有模有样非常适合拿来当模板。你拿到源码后不要急着改界面先在PC上把FFmpeg命令调明白再把命令原封不动搬到Android里跑最后才去动参数封装那一层。这个顺序走下来你会少走很多弯路。