FFmpeg 在 iOS 上的裁剪、集成与包体控制
一、你只用了一个功能,包体涨了几十 MB
把 FFmpeg 引进 iOS 工程的人,大多走过这条路:
需求是把用户导入的 MKV 转成 MP4,或者把一段 RTMP 流录下来,AVFoundation 干不了。你找到一个预编译好的 FFmpeg 静态库,拖进工程,链接通过,功能跑通,一天搞定。
然后打包。App Store Connect 上的安装大小涨了几十 MB。产品问你为什么,你说"FFmpeg 就这么大"。产品说"我们只用它转个格式"。你没法反驳,因为你确实只用了它百分之一的能力。
于是你开始裁剪。--disable-everything,只开你要的。编译通过,包体降了。上线。三天后线上炸了:
- 某些用户的 MKV 转不了,日志里只有一句
Invalid data found when processing input。 - 一个用户的 MP4 转出来没声音,本地复现不了。
- 法务转来一封邮件,问你们的 FFmpeg 是不是 GPL 的。
- 换了 Apple Silicon 的同事说模拟器跑不起来,
lipo报have the same architectures (arm64) and can't be in the same fat output file。
这四件事没有一件是 FFmpeg 的 bug,全是集成层面的问题。而集成层面的知识在 FFmpeg 自己的文档里几乎没有——它的文档默认你在 Linux 上、动态链接、全量编译。iOS 三个条件全不满足。
这篇按踩坑顺序把这些讲清楚。先说一个总的判断:在 iOS 上集成 FFmpeg,最难的部分不是编译,是决定"哪些不用 FFmpeg 做"。 包体、许可证、稳定性,三个问题的根都在这里。
二、先把账算清:包体大在哪里
不要凭感觉裁剪,先看数据。FFmpeg 编出来是几个静态库:
libavcodec.a 编解码器 —— 最大,通常占一半以上
libavformat.a 容器与协议(demuxer / muxer / protocol)
libavfilter.a 滤镜
libswscale.a 像素格式转换与缩放
libswresample.a 音频重采样
libavutil.a 基础工具
libavdevice.a 设备采集 —— iOS 上直接 --disable-avdevice
全量编译、arm64、不做任何裁剪,这几个库加起来是几十 MB 的量级(具体数字随版本和 configure 选项浮动,自己量:size -m 或者 du -sh *.a)。链接进 App 后 dead strip 会去掉一部分,但 FFmpeg 的组件是通过注册表被引用的——codec_list.c / demuxer_list.c 里的静态数组指向每一个组件——只要注册表活着,组件就都活着,linker 剥不掉。所以 FFmpeg 的包体只能在 configure 阶段砍,链接阶段基本无能为力。
看大头在哪,用 linker map:
OTHER_LDFLAGS = -Xlinker -map -Xlinker $(BUILT_PRODUCTS_DIR)/link.map
然后按符号大小排序(或者直接对静态库 nm --size-sort -r libavcodec.a | head -50)。你会看到一个反直觉的分布:大头不是"编解码器的数量",是少数几个编解码器的"表"。 H.264 / HEVC 解码器的 CABAC 上下文表、DCT 系数表,AAC 编码器的心理声学模型,MPEG-4 的各种 VLC 表——这些静态数据动辄几百 KB 一个。再加上 libswscale 里所有像素格式两两之间的转换路径,libavfilter 里两百多个滤镜。
这个分布决定了裁剪策略:砍掉一百个你不用的小组件,不如砍掉三个你不该用的大组件。 而在 iOS 上,最大的那几个恰好都不该用——下一节说。
三、第一刀:编解码不用 FFmpeg
iOS 有硬件编解码器,通过 VideoToolbox 暴露。H.264 / HEVC 的编码和解码、AAC 的编码和解码(AudioToolbox),系统都有,硬件加速、省电、不占包体。
FFmpeg 里对应的软件实现——h264 解码器、libx264 编码器、hevc 解码器、aac 编解码器——是整个 libavcodec 里最大的几块。把它们全部禁掉,换成 VideoToolbox / AudioToolbox,是包体上最大的一步,也是许可证上最关键的一步(第六节)。
FFmpeg 自己就支持这条路:
--enable-videotoolbox
--enable-hwaccel=h264_videotoolbox,hevc_videotoolbox
--enable-encoder=h264_videotoolbox,hevc_videotoolbox
--enable-audiotoolbox
--enable-encoder=aac_at
--enable-decoder=aac_at
--disable-decoder=h264,hevc,aac
--disable-encoder=libx264,libx265,aac
这样 FFmpeg 在你的 App 里退化成它真正擅长的三件事:容器(demux / mux)、协议(rtmp / hls / file)、滤镜图。 编解码交给系统。
一个常见的反对意见:"VideoToolbox 不支持某些 profile / 某些奇怪的流。"这是真的,但正确的应对是在你的 App 里明确不支持它们,而不是为了覆盖百分之一的输入把包体翻倍。用户导入一个 10-bit 4:4:4 的 H.264,弹一个"不支持此格式"比给所有用户多下载 20MB 划算得多。这个取舍要在产品层面显式做,不能由"FFmpeg 什么都能解"这个默认值替你做。
做完这一刀,剩下的裁剪才有意义。
四、坑一:--disable-everything 之后,错误信息的信息量归零
现在开始正式裁剪。正确的姿势是先全禁,再按清单开:
./configure \
--disable-everything \
--disable-programs --disable-doc --disable-debug \
--disable-avdevice --disable-postproc \
--enable-protocol=file,rtmp,http,https,tcp,tls \
--enable-demuxer=mov,matroska,flv,hls,mpegts \
--enable-muxer=mp4,mov,flv \
--enable-parser=h264,hevc,aac \
--enable-bsf=h264_mp4toannexb,hevc_mp4toannexb,aac_adtstoasc \
--enable-decoder=pcm_s16le \
--enable-filter=scale,aformat,aresample,anull,null,format \
--enable-videotoolbox --enable-audiotoolbox \
...
这份清单是我踩了好几轮才凑齐的,每一轮的症状都一样:功能不工作,错误信息是 Invalid data found when processing input 或者 Protocol not found 或者干脆返回 AVERROR_INVALIDDATA,没有一个字告诉你缺了哪个组件。
原因是 FFmpeg 的组件之间有隐式依赖,configure 只解析一部分:
- 开了
demuxer=mov但没开parser=h264:能 demux 出 packet,但某些流的 packet 边界不对,送给编码器时AVERROR_INVALIDDATA。 - 开了
muxer=mp4但没开protocol=file:avio_open直接Protocol not found。这条 configure 不会帮你连。 - 开了
demuxer=hls但没开demuxer=mpegts:HLS 的分片是 TS,解析器要另开。 - 开了
bsf=aac_adtstoasc但没开parser=aac:从 TS 里转出来的 AAC 塞进 MP4 后没声音——就是开头那个"本地复现不了"的 bug,因为本地测试文件是 MP4 源,线上用户的是 TS 源。 - 音频滤镜
aresample依赖swresample,scale依赖swscale——这两个 configure 会帮你连,但如果你--disable-swscale了它就静默地把scale也禁了。

反直觉的地方在于:你砍得越多,错误信息越没用。 全量编译时,FFmpeg 遇到不认识的东西会 fallback 到另一个组件再试,日志里会留下线索;砍到只剩骨架,fallback 全没了,只剩一个泛化的错误码。
解法:把"编进去了什么"变成一个测试
不要靠人脑维护依赖清单。启动时把实际注册的组件 dump 出来,写成测试:
func ffmpegManifest() -> Set<String> {
var out = Set<String>()
var opaque: UnsafeMutableRawPointer? = nil
while let c = av_codec_iterate(&opaque) { out.insert("codec:\(String(cString: c.pointee.name))") }
opaque = nil
while let d = av_demuxer_iterate(&opaque) { out.insert("demuxer:\(String(cString: d.pointee.name))") }
opaque = nil
while let m = av_muxer_iterate(&opaque) { out.insert("muxer:\(String(cString: m.pointee.name))") }
opaque = nil
while let b = av_bsf_iterate(&opaque) { out.insert("bsf:\(String(cString: b.pointee.name))") }
opaque = nil
while let p = avio_enum_protocols(&opaque, 0) { out.insert("protocol:\(String(cString: p))") }
return out
}
func testManifest() {
let required: Set<String> = [
"demuxer:mov", "demuxer:matroska", "demuxer:hls", "demuxer:mpegts",
"muxer:mp4", "protocol:file", "protocol:https",
"bsf:aac_adtstoasc", "bsf:h264_mp4toannexb",
"codec:h264_videotoolbox", "codec:aac_at",
]
XCTAssertTrue(required.isSubset(of: ffmpegManifest()),
"missing: \(required.subtracting(ffmpegManifest()))")
}
这个测试锁定的是"这次编译包含了我需要的东西",每次换 FFmpeg 版本、改 configure 都会先在 CI 上炸,而不是在用户手机上炸。它把一个"依赖图不可见"的问题变成了"清单可校验"的问题。 再配一组端到端测试——每种你声称支持的输入格式放一个最小样本文件进仓库,跑一遍完整转换——两层加起来,裁剪就不再是玄学。
parser 那种"能跑但结果不对"的依赖,清单测试抓不到,只有端到端能抓到。所以样本文件要覆盖每一种来源(MP4 源的 AAC、TS 源的 AAC、MKV 源的 AAC),而不是每一种格式。
五、坑二:arm64 ≠ arm64,lipo 合不了模拟器
Apple Silicon 之前,iOS 交叉编译的流程是:编一个 arm64(真机)、一个 x86_64(模拟器),lipo -create 合成一个 fat 静态库,拖进工程完事。
Apple Silicon 的模拟器是 arm64 的。同一个架构名,不同的 SDK,不同的 ABI 约定,不同的 -target 三元组(arm64-apple-ios vs arm64-apple-ios-simulator)。lipo 按架构名区分 slice,看到两个 arm64 直接拒绝:
fatal error: lipo: ... have the same architectures (arm64) and can't be in the same fat output file
正确的产物是 XCFramework,它按"平台 + 架构"而不是只按架构组织:
# 三次编译,三个 SDK
build() { # $1=sdk $2=arch $3=target-triple
./configure --target-os=darwin --arch=$2 --enable-cross-compile \
--cc="xcrun -sdk $1 clang" \
--extra-cflags="-target $3 -mios-version-min=14.0 -fembed-bitcode-marker" \
--extra-ldflags="-target $3" \
--prefix=out/$1-$2 ...你的裁剪清单...
make -j && make install
}
build iphoneos arm64 arm64-apple-ios14.0
build iphonesimulator arm64 arm64-apple-ios14.0-simulator
build iphonesimulator x86_64 x86_64-apple-ios14.0-simulator
# 模拟器的两个架构可以 lipo(架构名不同)
lipo -create out/iphonesimulator-arm64/lib/libavcodec.a \
out/iphonesimulator-x86_64/lib/libavcodec.a \
-output out/sim/libavcodec.a
# 真机和模拟器用 xcframework 区分
xcodebuild -create-xcframework \
-library out/iphoneos-arm64/lib/libavcodec.a -headers out/iphoneos-arm64/include \
-library out/sim/libavcodec.a -headers out/sim/include \
-output FFmpegAVCodec.xcframework

几个细节:
-target三元组必须带-simulator后缀,否则编出来的模拟器库在链接时报building for iOS Simulator, but linking in object file built for iOS。这个错误至少是清晰的。- FFmpeg 的
configure有--arch但没有"平台"概念,平台信息全靠你通过--cc和--extra-cflags灌进去。 - 汇编优化(
--enable-neon --enable-asm)在 arm64 上默认开,老版本 Xcode 需要gas-preprocessor.pl,新版本 clang 自带的汇编器直接能吃 FFmpeg 的.S文件。如果编译在.S上报错,先查 Xcode 版本再查 FFmpeg 版本。 - 每个
libav*.a一个 XCFramework,或者先libtool -static合并成一个再包——推荐前者,问题好定位。
这一节的教训是通用的:"架构"这个词在 Apple 平台上不再是一个足够细的维度。 凡是按架构分发二进制的流程(fat library、lipo、按 arch 命名的目录),Apple Silicon 之后都要重新审视。
六、坑三:许可证是架构约束,不是法务的事
法务那封邮件问的问题,很多团队回答不上来,因为他们不知道自己编的 FFmpeg 是什么许可证。
FFmpeg 的许可证由 configure 决定:
- 默认是 LGPL 2.1+。
- 加了
--enable-gpl,或者链接了任何 GPL 的外部库(libx264、libx265、libxvid……),整个 FFmpeg 变成 GPL。 - 加了
--enable-nonfree(比如libfdk-aac),不可分发。
GPL 意味着链接它的整个 App 都要以 GPL 分发源码。这和 App Store 的分发条款不兼容——这一点在 FFmpeg 的法律页面和多个公开案例里都有说明。所以 --enable-gpl 在 iOS App 里是一条红线,而 libx264 是最容易不小心越过这条红线的东西,因为教程里到处都是它。
这就是第三节那一刀的第二重意义:用 VideoToolbox 做 H.264 编码,不只是省包体,是让你根本不需要 libx264,从而根本不需要 --enable-gpl。
LGPL 也有要求,而且在 iOS 上不像看起来那么轻松。LGPL 2.1 第 6 条要求:分发链接了 LGPL 库的程序时,要让用户能够用修改后的 LGPL 库重新链接你的程序。在桌面上这靠动态链接自然满足;在 iOS 上大家习惯静态链接,静态链接进去之后用户没法换库。
常见的两种合规做法(具体请以你们法务的意见为准,我只说工程上怎么配合):
- 把 FFmpeg 编成动态 framework 而不是静态库。iOS 8 起支持动态 framework,用户理论上可以替换 App 包里的 framework。这是最省事的一条,代价是启动时多加载几个 dylib(几十毫秒量级),以及 Extension 里动态库的内存记账更复杂。
- 静态链接,但提供你的 App 的目标文件(.o) 让用户能重链接。几乎没有团队真的这么做,但它是条款允许的。
还有一条被忽略的义务:LGPL 要求你在 App 里提供许可证文本和 FFmpeg 源码(或获取方式)的说明,通常放在"关于"页面的开源许可列表里。这个很容易做,但很多 App 没做。

这一节为什么放在技术文章里:因为许可证的选择直接决定了你的架构——用不用 libx264(编解码架构)、静态还是动态链接(集成架构)、要不要把 FFmpeg 隔离在一个可替换的模块里(依赖架构)。等法务来问的时候再改,代价是重做整个集成。
七、坑四:日志和线程在 iOS 上的默认值都是错的
两个小坑,各一段。
日志:FFmpeg 默认用 av_log_default_callback 打到 stderr。在 Xcode 里能看到,在线上用户手机上、在 Broadcast Extension 里,什么都没有。所有 AVERROR_INVALIDDATA 前面本来会有一行 [mov,mp4,m4a,3gp,3g2,mj2 @ 0x...] moov atom not found 这样的关键信息,全丢了。
av_log_set_callback { _, level, fmt, args in
guard level <= AV_LOG_WARNING, let fmt else { return }
var buf = [CChar](repeating: 0, count: 1024)
var prefix: Int32 = 1
av_log_format_line(nil, level, fmt, args, &buf, 1024, &prefix) // 注意 args 是 va_list,Swift 侧要用 CVaListPointer 桥接
Logger.ffmpeg.warning("\(String(cString: buf))")
}
av_log_set_level(AV_LOG_WARNING)
接进你自己的日志系统,跟着崩溃日志一起上报。第四节那些"错误信息归零"的问题,一半是因为日志根本没收集。
线程:FFmpeg 的解码器默认会开线程池(thread_count = 0 表示自动,按 CPU 核数),滤镜图也可能开。在主 App 里无所谓;在 Broadcast Extension 里,你的内存预算是 50MB,每个线程的栈是 512KB,一个 8 核设备上开 8 个解码线程加上 FFmpeg 内部的帧线程缓冲,就是十几 MB 没了。而且你在 Extension 里根本不该用 FFmpeg 软解——回到第三节。
如果确实要在受限环境里用,显式限制:
codecContext.pointee.thread_count = 1
codecContext.pointee.thread_type = FF_THREAD_SLICE // 不要 FRAME 线程,那个会缓冲多帧
八、坑五:API 在版本之间漂,把它关进一个 port 后面
FFmpeg 的 API 变化很快:av_register_all 在 4.0 废弃、4.4 删除;avcodec_decode_video2 换成 send_packet / receive_frame;AVStream.codec 换成 codecpar;5.x 又改了一批。你今天写的调用,两个版本后编译不过。
在 iOS 工程里这个问题更重,因为 Swift 调 C 的桥接本来就脆:va_list、函数指针、不透明结构体、UnsafeMutablePointer 满天飞。这些代码散在业务层里,升级 FFmpeg 就是一次全工程手术。
正确的结构是一层薄 port:
protocol MediaTranscoder {
func remux(input: URL, output: URL, progress: @escaping (Double) -> Void) async throws
func probe(_ url: URL) async throws -> MediaInfo
}
// 唯一 import CFFmpeg 的文件
final class FFmpegTranscoder: MediaTranscoder { ... }
// 测试和不需要 FFmpeg 的 target 用这个
final class AVFoundationTranscoder: MediaTranscoder { ... }
业务层只依赖协议。FFmpeg 的头文件只在一个 target 里被 import。升级版本时改一个文件;某天你决定某个功能用 AVFoundation 替代,换一个实现类。
这不是架构洁癖。FFmpeg 是你工程里最大、最不受你控制、许可证最敏感的依赖,它必须是最容易被拔掉的那个。 第四节的清单测试、第六节的动态 framework、这一节的 port,三件事合在一起才构成"可控的依赖"。
九、诚实对比:你可能根本不需要 FFmpeg
把这一节放在后面,是因为你需要先知道代价才能判断值不值。
AVFoundation 能做的,就不要用 FFmpeg。 它能做的比大多数人以为的多:
- MP4 / MOV / M4A 的读写、转码、剪辑、拼接:
AVAssetExportSession、AVAssetReader+AVAssetWriter。 - HLS 播放:
AVPlayer原生支持。 - 常见音频格式的解码:
AVAudioFile、AudioToolbox。 - 缩放、裁剪、旋转、简单叠加:
AVVideoComposition+ Core Image。
只有这几种情况 FFmpeg 是必要的:
- 非 Apple 容器:MKV、FLV、AVI、WebM 的读取。这是最常见的合理理由。
- 推流协议:RTMP 推流、RTSP 拉流。AVFoundation 没有。
- 复杂滤镜图:多输入的
filter_complex,比如画中画加转场加字幕,用 AVFoundation 拼要写很多代码。 - 非 Apple 输出格式:GIF、WebP 动图、Ogg。
- 精确到帧的切片且不重新编码:
-c copy配合关键帧对齐。AVFoundation 的AVAssetExportSession在 passthrough 模式下切点控制粗。
一条也不占,用 AVFoundation。占一条,把 FFmpeg 裁到只剩那一条需要的组件。
另外说一句 ffmpeg-kit:它曾经是 iOS 上最省事的集成方式(预编译好、Swift 友好、多种许可证变体)。作者已在 2025 年宣布项目退役,预编译包也已下线。如果你的工程还在用它,这篇的第五、六节就是你自建流程时要走的路。
十、收尾:三条可以带走的
第一,依赖图不可见的系统,先建清单测试,再动手裁。 FFmpeg 的组件依赖有一部分 configure 不解析、错误信息不暴露,唯一可靠的方式是把"编进去了什么"和"每种输入能不能跑通"都变成 CI 上的断言。这条对任何"按 feature flag 裁剪的大型 C 库"都成立——OpenSSL、SQLite、Boost 都是同一类问题。
第二,包体的大头来自"通用性",把通用换成专用是唯一的大刀。 砍一百个小组件不如砍三个大组件,而最大的那三个在 iOS 上恰好都有系统替代品。先问"哪些能力平台已经有了",再问"剩下的怎么裁"。顺序反了,你会花一周把包体从 30MB 砍到 25MB,然后发现真正的 20MB 在你从没想过要砍的地方。
第三,许可证是架构约束,不是法务的事。 用不用 libx264 决定编解码架构,静态还是动态决定集成架构,能不能被替换决定依赖架构。这三个决定在写第一行集成代码之前就该做,等法务的邮件来了再做,代价是整个集成重来。
回到开头那四件事:MKV 转不了是缺 parser,没声音是缺 bsf 的依赖,GPL 是 libx264,lipo 是 Apple Silicon。四个问题,没有一个在 FFmpeg 的文档里,全在"FFmpeg 和 iOS 之间"。
如果你正在排查一个裁剪后的 FFmpeg 只在某些输入上失败的问题,先把 av_log 接到你的日志系统里再看——十有八九缺的组件名就写在 AVERROR_INVALIDDATA 前面那一行里,只是你没收到。
关于本文代码
文中的 configure 参数和 Swift 示例用于说明裁剪思路、编译流程和 port 的形态,未逐行验证。组件名(demuxer / muxer / bsf / parser / protocol)以你使用的 FFmpeg 版本的 ./configure --list-* 输出为准,不同版本有增删;av_log_set_callback 的 Swift 桥接涉及 CVaListPointer,示例做了简化;XCFramework 的构建脚本省略了 -mios-version-min、头文件目录整理和 modulemap 生成。
包体数字全部以"量级"给出,具体值请用 size -m / linker map 在你的构建上实测。许可证部分转述的是 FFmpeg 官方法律页面与 LGPL 2.1 条款的公开内容,不构成法律意见,分发前请与法务确认。ffmpeg-kit 的退役信息以其仓库公告为准。