FFmpeg开发笔记写到第一百篇了,翻翻前九十九篇,要么在讲命令行参数怎么抠,要么在剖析API怎么调,这一篇想换个角度,聊一个国产的Android开源视频压缩工具VideoSlimmer。做移动端视频处理的开发者应该都有同感:视频压缩这个需求看着不起眼,真要在Android上做得顺手、做得可控,并不容易。VideoSlimmer恰好可以作为一整份参考样本——它是面向Android平台的开源视频压缩方案,底层调用FFmpeg完成编解码,同时把"压缩"封装成了开箱即用的能力。这篇文章我想从源码结构、核心链路、参数调优到真机踩坑,完整复盘我对它做过的功课,给正准备把FFmpeg集成进Android做压缩功能的开发者一条能照着走的路线。
不管你是刚接触FFmpeg的Android开发新手,还是已经在产品里接了压缩功能但总觉得不太稳的老手,它都能给你不少启发。如果你是普通用户,也能从里面看到"为什么有的压缩工具压出来的视频还能看,有的就变成马赛克"这类问题背后的根源。下面进入正题。
1. 为什么"视频压缩"这个老需求,还需要再做一个新项目
1.1 视频压缩需求远比想象中高频
手机随便拍一分钟4K视频就是几百MB,微信传原图有大小限制,网盘空间总是不够用,剪辑软件导素材卡成幻灯片。这些场景落到用户手里,最终都变成一个朴素诉求:在尽量不损失画质的前提下,把视频变小。对开发者来说,这个诉求翻译过来就是一套固定动作:解码原视频,重新编码成更低码率、更小分辨率的文件。道理大家都懂,但落到Android上麻烦就没完没了。
Android碎片化的问题在视频处理领域尤其严重。同一个H.264文件,在这台手机上硬解正常,换一台可能花屏;同一个编码参数,在骁龙平台上没问题,到麒麟平台上可能初始化失败。这也是为什么很多做视频工具类的团队,宁可花大力气维护自己的压缩引擎,也不愿意把命脉交给系统组件。
1.2 三类现成方案各自的短板
先看看市面上实际存在的几条技术路线,各有各的问题。
第一类是系统相册自带的压缩功能。用户操作确实简单,但对开发者来说完全不可控。压缩比率是多少、输出分辨率多大、走硬编还是软编,全都改不了,而且不同厂商定制ROM的行为还不一样,测试都无从测起。
第二类是在线压缩网站。上传到云端压缩完再下载,客户端集成确实省事,但隐私风险很大,视频上传下载也慢,对商务、医疗、教育这些对数据敏感的行业基本劝退。单条视频几十MB还能忍,到了几百MB甚至上GB的素材,等待时间足以让用户放弃。
第三类是自己在项目里集成完整的FFmpeg方案。这个灵活度最高,但门槛也最高。你得自己处理so库编译、ABI适配、JNI封装、进度回调、内存安全一整套问题,很多人卡在第一步"编译FFmpeg"就放弃了。就算编译过了,后续还有无数个"为什么进度条不动了""为什么压出来的视频是横的"这类问题等着你。
1.3 为什么需要VideoSlimmer这样的项目
VideoSlimmer的英文直译是"视频瘦身器",从命名就能看出它做的是减法。它把FFmpeg藏起来的复杂度包起来,对外暴露一个尽量简单的流程:选视频、等进度、拿结果。同时因为是开源项目,内部实现完全透明,开发者拿过来既能直接用,又能照着源码学习。
它和大家更熟悉的RxFFmpeg这类通用框架定位不同。RxFFmpeg的思路是"什么都能干",把FFmpeg的命令行能力完整搬到Android上,但命令怎么拼、参数怎么配,还是要开发者自己心存敬畏;VideoSlimmer则专注"压缩"这一个动作,在参数调优和边界情况处理上做得更细。这种"小而美"的定位,反而为二次开发提供了更清晰的脉络。
2. 看源码之前先理清工程结构:FFmpeg在Android里的集成路子
2.1 拿到代码后先别急着跑,先把工程分层看明白
我拿到任何开源项目,第一步永远是先看工程目录,而不是急着编译运行,这个习惯帮我省了很多时间。一个典型的Android视频处理项目,绕不开这么几层:Java层做UI和业务逻辑,JNI层做Java和C/C++之间的桥接,CMakeLists负责把C/C++代码编进so库,底层才是真正干活的FFmpeg引擎。
VideoSlimmer的工程结构基本也是这个套路,但有几个点值得单独拿出来说。它的JNI目录把FFmpeg调用封装得很干净,Java层只面向Native方法的几个暴露接口,不需要关心底层是走软编还是硬编。这个设计对上层业务很友好,后面如果想把压缩引擎替换掉,Java层几乎不用动。
项目从功能模块上大致是这样划分的:
- UI层:负责视频选择、压缩进度展示、压缩前后画面对比
- 业务层:负责压缩参数计算、任务调度、结果回调
- JNI层:把C++侧的压缩函数暴露给Java调用
- 底层:FFmpeg库的裁剪产物和功能封装
2.2 Android集成FFmpeg的三种姿势,VideoSlimmer选了一条中庸的路
在Android里集成FFmpeg,各团队的做法大体能归成三类,这里对比一下各自的优劣势。
第一种是直接用JavaCV或者ffmpeg-kit这类现成封装库。这是很多项目快速上线时的选择,Gradle依赖一拉就能跑,省掉了极大量的JNI工作。但问题也很明显:依赖体积大,一个全量ffmpeg-kit带一堆你用不到的协议和解码器,apk直接膨胀几十MB;同时,你想改底层行为的时候会发现无路可走,只能围绕库作者给定的API打转。
第二种是下载FFmpeg源码,用NDK自己编译出so文件,再自己写JNI。这条路完全可控,但编译环境准备是个大坎。FFmpeg的configure脚本对NDK版本、工具链路径、target平台的配置有严格要求,稍微配错就编译失败。我见过不少同事第一次编译FFmpeg就卡了两三天,最后被劝退回头选方案一。
第三种是介于两者之间:自己维护编译产物,但只编译裁剪过的FFmpeg。VideoSlimmer走的就是这条路。它的好处很直观:最终apk体积可以压得非常小,该有的能力一样不少。坏处是,使用这种方案时你必须熟悉FFmpeg的编译开关,不然一不小心就编出一个异常臃肿的so包,反而得不偿失。
2.3 编译裁剪的关键思路:给so文件"减肥"
FFmpeg的configure脚本提供了几百个开关,全部开启编译出来的库体积非常可观。仅libavcodec一个库就能到几十MB,放进Android里完全不能接受。做移动端适配的第一件事,就是想清楚"我到底需要什么",然后把不需要的统统关掉。
裁剪的基本思路分几条:
- 只保留需要的封装格式和编码器,不需要的去configure里关掉
- 关闭所有用不到的网络协议,比如rtsp、rtmp,本地文件处理根本用不到
- 关闭滤镜组件,视频压缩不涉及复杂滤镜时完全没必要编进去
- 开启按需加载的编译优化选项,能省下不少空间
一个比较稳妥的编译策略是:先用--disable-everything把默认全开的东西全关掉,然后按需求逐个打开。比如只处理mp4文件,解码器只需要h264、hevc,封装格式只需要mov的demuxer和mp4的muxer,音频走aac,那么配置过程大概长这样:
./configure \ --disable-everything \ --enable-decoder=h264 \ --enable-decoder=hevc \ --enable-decoder=aac \ --enable-encoder=libx264 \ --enable-encoder=aac \ --enable-demuxer=mov \ --enable-muxer=mp4 \ --enable-parser=h264 \ --enable-parser=hevc \ --enable-parser=aac \ --disable-network \ --disable-avdevice \ --disable-postproc \ --disable-avfilter提示:--disable-everything要放在前面,后面用--enable开需要的东西。如果一开始全量编译通过了,后面再想裁剪又得整套重编,费时费力,不如一开始就穷举需求列表。
当然,配置完还要解决另一个问题:ABI架构。Android设备现在主流是arm64-v8a,armv7兼容性也不错,x86_64基本只在模拟器上出现。so库要针对每个ABI单独编译,这也是很多新手在集成FFmpeg时容易忽略的点。如果你的app只在真机上跑,优先打好arm64-v8a的so包就够了,没必要为了四个ABI各编一遍,白白增加打包时间和安装包体积。
3. 压缩主流程的FFmpeg API调用顺序,一步步拆给你看
3.1 选完视频之后,第一步是拿元数据
用户从相册选一个视频,拿到的通常不是一个普通路径,而是content://格式的Uri。在拿到这个Uri之后,怎么提取时长、分辨率、比特率这些元数据,就有两条技术路线。
一条是用Android自带的MediaMetadataRetriever,读个时长、宽高这些基础信息很轻松,但拿不到编码器级别的细节参数,比如原始视频的profile level、像素格式、编码帧率抖动这类信息。另一条是直接用FFmpeg的avformat_open_input打开文件,通过avformat_find_stream_info拿到stream的信息,一次性把后续压缩要用的元数据全部拿到。
VideoSlimmer在这个环节选择了走FFmpeg这边,理由很简单:反正后面压缩本来就要用FFmpeg打开这个文件,在准备阶段顺手把流信息读完,省一遍重复打开的成本,也让后续参数计算的判断依据来源更统一。这个选择在代码里看起来细微,但对整体流程的稳定性和可维护性有实际帮助。
从FFmpeg API的角度,这部分代码的核心逻辑大致是:
AVFormatContext *input_ctx = NULL; int ret = avformat_open_input(&input_ctx, filename, NULL, NULL); if (ret < 0) { // 打开失败,可能是文件损坏或格式不支持 } ret = avformat_find_stream_info(input_ctx, NULL); if (ret < 0) { // 读流信息失败 }拿到流信息之后,要从里面提取几个关键数值:视频流的索引、宽度、高度、帧率、比特率、时长,以及有没有音频流、音频流索引是多少。这些数值是后面计算压缩参数的原料,尤其比特率和时长的准确性直接关系到进度条的准不准。
3.2 核心循环:解码、转格式、再编码
这是整个压缩最核心的部分,理解了它,这个项目的主干逻辑就吃透了一半。整个流程可以分成四个阶段:
- 打开输入文件,定位视频流
- 初始化解码器和编码器
- 一边读包一边解码,再把解码出的画面喂给编码器
- 编码出来的数据写进输出文件
走到代码层面,API调用顺序大致是这样的。先打开输入文件,找到视频流索引,创建解码器上下文并打开:
int video_stream_index = av_find_best_stream(input_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); AVStream *in_stream = input_ctx->streams[video_stream_index]; const AVCodec *decoder = avcodec_find_decoder(in_stream->codecpar->codec_id); AVCodecContext *decoder_ctx = avcodec_alloc_context3(decoder); avcodec_parameters_to_context(decoder_ctx, in_stream->codecpar); avcodec_open2(decoder_ctx, decoder, NULL);然后创建输出上下文,添加一个新的视频流,配置编码器:
AVFormatContext *output_ctx = NULL; avformat_alloc_output_context2(&output_ctx, NULL, NULL, output_filename); AVStream *out_stream = avformat_new_stream(output_ctx, NULL); const AVCodec *encoder = avcodec_find_encoder(AV_CODEC_ID_H264); AVCodecContext *encoder_ctx = avcodec_alloc_context3(encoder); // 设置编码参数:宽高、帧率、比特率、像素格式等 encoder_ctx->width = target_width; encoder_ctx->height = target_height; encoder_ctx->time_base = in_stream->time_base; encoder_ctx->pix_fmt = AV_PIX_FMT_YUV420P; // ... avcodec_open2(encoder_ctx, encoder, NULL);循环阶段是整个压缩的发动机。每次调用av_read_frame从输入文件里读一个AVPacket,如果它属于视频流,就交给解码器处理,再从解码器里取出AVFrame,然后把这个frame塞给编码器,取出编码后的AVPacket写入输出文件。代码逻辑的核心骨架是:
while (av_read_frame(input_ctx, &packet) >= 0) { if (packet.stream_index == video_stream_index) { avcodec_send_packet(decoder_ctx, &packet); while (avcodec_receive_frame(decoder_ctx, frame) >= 0) { // 这里可以按需缩放分辨率 encode_frame(encoder_ctx, frame, output_ctx, out_stream); } } av_packet_unref(&packet); } // 收尾:冲洗编码器缓冲区 flush_encoder(encoder_ctx, output_ctx, out_stream); av_write_trailer(output_ctx);encode_frame内部做的事情,就是把frame塞进编码器,然后通过avcodec_receive_packet循环取出编码后的AVPacket,再用av_interleaved_write_frame写入输出文件。
这里有一个特别需要注意的点:avcodec_send_packet和avcodec_receive_frame的关系是"一次send,多次receive"。也就是说,一个AVPacket解出来之后,可能对应多帧画面,代码里必须用while循环把receive调到返回AVERROR(EAGAIN)或者AVERROR_EOF为止。如果只receive一帧就了事,等于把后面的画面全丢了,输出会缺帧。
3.3 进度回调与线程模型
压缩是耗时操作,理所当然不能在主线程跑。但在子线程里跑,进度条怎么回流到UI,是一个容易做丑的点。VideoSlimmer的做法是在拿到流信息时记录总时长,然后在读帧循环里,每读一个AVPacket就算一下当前时间戳和总时长的比例,得到0到1之间的进度值,通过Handler或者回调扔回UI线程更新进度条。
这里面隐藏着一个单位换算的坑。FFmpeg内部的时间戳单位一般不是秒,而是AV_TIME_BASE,即一百万分之一秒(微秒)。读取到的pts值必须除以AV_TIME_BASE才能换算成秒。很多新手直接拿pts原始值去算比例,算出来的进度条会乱跳,一会儿99%、一会儿30%、一会儿又回到50%。排查这类问题时要下意识想到时间基准的问题。
线程模型上还要注意一点:JNI层和Java层的回调不能直接在同一线程里互相操作UI。稳妥的做法是,native层把进度抛到Java层时只更新一个普通字段,或者通过Handler post到主线程再更新进度条控件。如果在native线程里直接操作UI线程的控件,在某些国产ROM上会触发crash或者卡顿,这个坑我踩过不止一次。
3.4 收尾阶段最容易忽略的细节:资源释放与文件落盘
压缩循环跑完之后,收尾不只是把文件扔在那里。要做的事情有这几件:先调用av_write_trailer写入文件尾部索引,然后按顺序释放编码器上下文、解码器上下文、关闭输入输出文件。释放顺序一旦乱了,轻则内存泄漏,重则直接SIGSEGV崩溃。
输出文件的落盘也要考虑不同Android版本的存储策略。Android 10以下直接写应用沙箱目录内的绝对路径就行,Android 10及以后强制分区存储,不能再随便往公共目录写文件了。VideoSlimmer的适配做法是,把压缩后的文件先写到应用专属目录,再通过MediaStore接口插入系统相册,这样用户能在相册里直接看到压缩结果,应用卸载时也不会制造一堆垃圾文件。
4. 码率、分辨率、编码器的取舍,压缩参数是门平衡术
4.1 目标码率怎么定才合理
码率直接决定文件体积和画质的最关键参数。码率设太高,压缩完体积还是大,意义就没了;设太低,画面糊成一团,用户看两眼就删了。合理的码率设置,跟原始视频的分辨率、帧率、内容类型都有关。
一个可以"抄作业"的简单公式是:目标码率 = 目标分辨率下的单像素码率参考值 × 总像素数 × 帧率。比如720p(1280×720)的画面,单像素码率参考值在0.1到0.2之间属于高画质档位,取0.15来算,1280×720×0.15×30等于约4.1Mbps,压出来的视频画质很好。如果目标是尽量小,把单像素码率降到0.05到0.08,算出来就是1.4Mbps到2.2Mbps,属于社交分享够用、但细节稍微粗糙的水平。
实际视频内容差异也很大。一个全是静态文字的录屏视频,0.03的单像素码率都能压得很清楚;一段运动强烈的体育比赛录像,给到0.2可能还是会看到瑕疵。所以开发者在做产品时,最好把内容场景也考虑进去。
还有一条更省心但不那么精确的路径:先拿到原视频码率,然后按目标体积往下折算。比如想把一个100MB的视频压到50MB左右,目标码率就按原码率的50%来设,再结合分辨率缩放的系数微调。这种做法的优点是思路简单,不会出现设定码率过高反而压缩后比原视频还大的乌龙情况。
4.2 分辨率缩放与宽高对齐
手机拍出来的原视频动辄4K,如果不缩放分辨率,单靠降码率压缩,效果有限。普通用户看视频,1080p已经足够清晰,甚至720p在很多小屏设备上完全够用。一个比较通用的缩略策略是:原视频超过1080p就缩到1080p,本来就低于1080p的则不放大。缩放要用sws_scale完成,目标尺寸保持原视频宽高比,否则画面会被拉伸变形。
这里藏着一个非常隐蔽的坑:H.264编码器要求分辨率的宽高必须是偶数。如果传入的是奇数宽高,有些编码器直接报错退出,有些编码器会输出偏色或者带绿边的画面。所以任何计算出来的目标宽高,在做缩放之前都要做一个向偶数取整的处理。下面这段逻辑是视频处理里的必备护身符:
target_width = (target_width / 2) * 2; target_height = (target_height / 2) * 2;一句话总结:视频处理里,把宽高都对齐成偶数,能帮你少踩一半的坑。
4.3 软编码和硬编码如何选择
FFmpeg在Android上的编码路径主要有两条。软编码走libx264/libx265,硬编码走平台专属的h264_mediacodec/hevc_mediacodec。两条路径的取舍非常典型,我整理过一个对比矩阵:
| 对比维度 | 软编码(libx264) | 硬编码(MediaCodec) |
|---|---|---|
| 兼容性 | 几乎所有设备都能跑 | 依赖厂商实现,参差不齐 |
| CPU占用 | 高,发热明显 | 低,省电 |
| 压缩速度 | 慢,高清视频要等 | 快,接近实时 |
| 画质控制 | 参数细,crf、preset都可调 | 参数粗,依赖编码器内部策略 |
| 典型问题 | 设备发热、压缩时间过长 | 初始化失败、分辨率支持不全 |
VideoSlimmer的默认策略是优先尝试硬编码,因为硬编码在速度和功耗上的优势太明显了,对用户体感好。但硬编码不是银弹,低端机上hevc_mediacodec初始化失败的案例我实测遇到过,所以必须做兜底:编码器初始化失败时自动回退到软编码。这个回退逻辑是整个压缩稳定性里最值得抄的一段代码。
4.4 音频轨道的处理策略
很多人做视频压缩只关心画面,把音频直接忽略了。实际上音频处理策略对压缩结果的影响也不小。
常见策略有两种。第一种是直接copy音频流,不重编码,好处是速度快、没有音质损失,坏处是完全压缩不了音频的体积。第二种是用aac编码器把音频重编码,码率控制在96kbps或128kbps,能进一步缩小整体文件体积。VideoSlimmer是根据场景二选一的:如果原视频音频码率已经不高,就copy;如果原音频码率高,就重编码成128kbps。
这里有个初学FFmpeg极易搞错的点:copy模式的音频流不需要打开新的解码器,直接拿着原来的编码参数写入输出文件就行。有些人在copy模式下还把音频单独解码了一遍,白白浪费CPU,逻辑还复杂了。只有重编码模式才需要先解码,再用aac编码器重新编码。
5. 我在真机上踩过的兼容性坑和排查思路
5.1 视频旋转信息丢失:压缩完画面横竖颠倒
这是手机视频压缩里最经典的一个坑。手机拍摄的视频,在mp4文件里通常会带一个旋转角度的元数据(rotation),而不是直接把像素旋转好存储。如果压缩时只按原位宽高编码,完全忽略rotation标记,输出视频在播放器里就会出现横竖屏颠倒。
处理方案是必须从AVStream的side_data里读取出旋转角度,当角度为90度或270度时,编码上下文里的目标宽高要相应交换。也就是说,如果原始视频在旋转标记下是竖屏,但存储的分辨率是横向的,压缩时你就要意识到它在播放时会被竖起来播放。
注意:rotation信息不能简单从文件扩展名或常规的宽高比推断,一定要从流信息的side_data里显式读取。我在项目里遇到过某机型拍摄的视频没有rotation标记但实际需要旋转的另类情况,这种只能靠真机测试覆盖。
5.2 硬编码在低端机上的初始化失败和回退策略
我在一台老设备上测试时,初始化hevc_mediacodec直接返回AVERROR_INVALIDDATA。如果代码里对编码器初始化失败没有做兜底,整个压缩任务会直接失败退出。正确的处理逻辑是:编码器初始化失败时不强行重试硬编码,而是走libx264软编码路径。
这个回退需要在Java层和Native层都做处理。Native层负责判断编码器open失败的错误码,Java层负责接收失败状态并重新把任务投递到软编码器上。回退逻辑修好之后,我在同一台老设备上重测,压缩虽然慢了一些,但至少能正常出结果,用户体感从"完全不可用"变成了"需要等一会儿"。
另一个和硬编码相关的坑是尺寸协商。有的设备MediaCodec编码器你传1920×1088,它实际支持的是1920×1080,如果不按编码器实际支持的尺寸输出,画面可能被裁剪或者花屏。正确的做法是,编码器打开之后,返回的编码器上下文里的宽高就是实际应该用的值,后续写入文件的参数也要同步更新。
5.3 分区存储适配:content://与文件描述符
Android 10之后的分区存储,让很多旧代码直接失效。以前可以直接传String路径给FFmpeg,现在相册选出来的视频是content://Uri,没有真实文件路径。FFmpeg的avformat_open_input只认文件路径或者URL,要打开content://Uri,标准做法是先通过ContentResolver拿到ParcelFileDescriptor,再通过dup得到新的文件描述符,拼接成特殊URL传给FFmpeg。
此处非常容易发生文件句柄泄漏。拿到ParcelFileDescriptor之后,用完了必须及时关闭,否则文件描述符会不断累积,最终可能导致进程打开文件失败。我在排查一个"压缩几次之后整个app打不开相册文件"的问题时,最后定位到就是fd泄漏。
5.4 Native崩溃怎么定位:SIGSEGV排查思路
FFmpeg是C库,一旦崩溃,Java层的try/catch根本接不到。日志里会出现带SIGSEGV字样的tombstone。很多没接触过Native开发的Android程序员走到这一步就懵了,觉得无从下手。其实排查路径是固定的:先在JNI代码里逐步加ALOGI日志,确定崩溃发生在哪个环节,再针对具体的API和释放逻辑排查。
以我的经验,释放阶段的崩溃占了Native崩溃里的很大比例。FFmpeg上下文释放,要遵守"先创建的后释放"的栈式原则。打开顺序是format->stream->codec,释放就得反过来codec->stream->format,顺序一交换就崩。遇到释放崩溃,先检查顺序,再检查有没有释放了一个已经被释放过的指针,绝大多数问题都能解决。
5.5 打日志输出到外部测试要注意设备差异
最后提一个和代码关系不大但很实际的点:JNI层打日志时,用ALOGI还是ALOGE要根据内容选。进度类信息用ALOGI,错误信息用ALOGE,并且日志里最好带上tag,比如"VideoSlimmer-jni",这样在logcat里过滤时能一眼看到自己项目的输出。在联调阶段,我在华为和三星设备上分别跑过测试日志,发现不同厂商ROM对native日志的显示策略有细微差异,有的默认会过滤debug级别日志,所以关键节点用info级别以上比较稳妥。
6. 看完源码后最值得动手的几个扩展方向
6.1 批量压缩需要串行队列
VideoSlimmer目前的交互是单个视频压缩。如果你要做批量压缩,最直接的做法是上个任务队列,逐个执行。批量压缩千万不要做并发,原因很简单:多路FFmpeg同时编解码会抢占CPU和内存,最终导致每个任务都变慢,还可能触发低端机的内存压力。串行队列加一个前台进度通知,体验反而最好。
6.2 自定义压缩档位
现在视频参数是自动计算的,但对很多垂直场景来说,用户需要更细粒度的控制。比如做电商视频的团队,希望输出固定960×540的分辨率;做在线教育的,希望保证音频码率不低于128kbps。这些需求可以通过在最终参数下发之前加一层配置覆盖逻辑来实现。VideoSlimmer的参数计算集中在业务层,改起来并不困难。
6.3 用PSNR和SSIM给压缩结果打分
功能做完之后,怎么证明压缩效果"好",不能靠拍脑袋,得靠数据。可以引入PSNR和SSIM这两个客观质量指标:压缩完成后,用FFmpeg的filter把原始帧和处理后的帧做一次对比,算出分数。这样后续调整参数时,就能看到"加大码率后PSNR提升了多少"这类客观数据,而不是感觉"好像清楚了一点"。
6.4 一点心得
一百篇FFmpeg开发笔记写下来,我最大的体会是:FFmpeg本身并不难,难的是把它放进一个真实的、充满怪设备的Android生态里。VideoSlimmer作为一个国产开源项目,价值不只在于"能用",更在于它把FFmpeg在Android上落地时要踩的坑都填了一遍,给后来者留了一条完整可参考的路线。如果你正打算做视频处理类App,或者一直在纠结怎么把FFmpeg正经集成进Android工程,从它入手,是个很划算的选择。