如果你启动 App 的第一秒就撞见这行红字——dyld: Library not loaded: @rpath/xxx.framework,先别慌,这个报错在 iOS / macOS 开发里出现频率极高,几乎每个做过动态库接入的人都至少被它折磨过一次。我第一次踩到是很多年前接第三方播放器 SDK,工程里明明能看到 framework,Build 也成功了,结果一启动 App 就瞬间闪退,控制台里只剩下这一句话,当时的我只能对着屏幕发呆。后来自己做组件化、自研 framework 分发,这个问题又反复出现,我干脆把它研究透了。这篇文章就把整套排查逻辑完整写出来,从 dyld 和 @rpath 的原理讲到几种常见翻车现场,再给到每一步可落地的修复操作。面向的是已经能正常写 iOS 工程、但一碰到动态库就头大的同学。看完之后,你再遇到类似报错,基本能在一杯咖啡的时间内定位并解决,而不是像当年我一样对着日志怀疑人生。
1. 先搞懂这行报错到底在说什么
1.1 一个“能编过,但跑不起来”的动态库
很多人的第一反应是“我链接没链对吧?”,但注意,这行报错出现的时候,Build 往往是成功的。也就是说,编译链接阶段一切正常,真正崩的地方是在 App 启动阶段,由 dyld 抛出来的。
简单补一下背景。iOS / macOS 上的可执行文件是 Mach-O 格式,里面会记录一份“我依赖哪些动态库”的清单,这些清单条目在 Mach-O 里叫LC_LOAD_DYLIB。编译链接时,链接器只是把“我要用 xxx.framework”这个需求记录到二进制里,并不会真的把代码塞进来。等 App 启动时,系统里的 dyld(动态链接器)会先读这份清单,把每个依赖库加载进内存,解析符号,全部就绪后才执行 main。
所以你就明白了:链接时能找到 framework,代表的是“编译机器上的搜索路径”没问题;而启动时 dyld 找不到,代表的是“运行时在 App 包内部的实际查找”出了问题。这两个阶段使用的路径体系完全不是一回事。一个能编过、一跑就崩的动态库,绝大多数病根都出在这个错位上。
为什么大家还要用动态库?因为代码复用、模块边界清晰、链接速度更快、主二进制体积也更可控。但它带来的代价就是,构建产物在运行时必须严格“对得上号”,framework 得出现在 dyld 预期的地方,签名还得合法。任何一环断了,就是这行Library not loaded。
1.2 @rpath 不是路径,而是一串“备选目录”
很多同学把@rpath/xxx.framework里的@rpath当成某种标准路径来记,其实它只是一个占位符。dyld 在工作时会遇到几种带@开头的东西:
@executable_path:主可执行文件所在的目录。App 里一般就是xxx.app/这个目录本身。@loader_path:当前正在“引用别人”的那个二进制所在的目录。比如xxx.app/PlugIns/Today.appex里的扩展,@loader_path就是指PlugIns/Today.appex这个路径。@rpath:它不是具体目录,而是“一堆目录”的简写。dyld 会去主可执行文件里查LC_RPATH列表,按顺序把@rpath替换成列表里的每个路径,逐个尝试,找到第一个能用的就加载。
打个比方:@rpath就像你面试时填的“备选工作地点”,系统拿着这份列表,先到第一家公司找候选人,不在就去第二家、第三家。列表里的候选地来自工程里的Runpath Search Paths设置,Xcode 默认新建的 App 工程通常会给@executable_path/Frameworks,也就是 App 包内的Frameworks目录。
明白了这层关系,再看@rpath/xxx.framework就很清楚了:dyld 会把@rpath替换成 App 里记录的若干真实路径,然后在那些路径下去找xxx.framework。任何一个候选路径下都没有这个框架,就直接抛Library not loaded。
1.3 报错里的信息量:除了第一行,后面的 Reason 才是重点
完整报错往往长这样:
dyld: Library not loaded: @rpath/xxx.framework Referenced from: /path/to/YourApp.app/YourApp Reason: image not found太多人只盯着第一行看,其实后面两行才是破案关键。Referenced from告诉你是哪个二进制引用了它,如果是扩展 target 的问题,这里会是.appex路径;Reason则是 dyld 给的失败原因,常见的有:
image not found:路径里根本没找到这个文件。no suitable image found. Did find: ...:文件找到了,但架构不匹配,后面的列表会列出它找到的 slice 架构。code signature invalid:文件在,架构也对,但签名不合法,真机上最常见。
不同 Reason 对应的排查方向完全不一样。所以以后遇到类似报错,别截第一行了,把完整日志翻出来再分析。
2. 最常见的几个翻车现场,你大概率在哪一个上面栽过
2.1 忘记勾选 Embed & Sign
这是新手最容易踩、老手偶尔也会阴沟翻船的一种情况。你在 Xcode 里拖了一个 framework 进工程,能看到文件,也能 import,Build 也过了,但一跑就崩。
原因在于,Xcode 的General -> Frameworks, Libraries, and Embedded Content面板里,新加的 framework 默认状态往往是Do Not Embed。链接器能把依赖写进 Mach-O,是因为Framework Search Paths里能找到它,这和“构建时把 framework 拷贝进 App 包”是两回事。这就好比你在菜谱上写了“需要面粉”,但根本没把面粉放进购物袋;做菜时翻遍厨房当然找不到。
解决方案很直接:在 Target 的General面板里,把该 framework 右侧的 Embed 状态从Do Not Embed改成Embed & Sign或Embed Without Signing,重新 Build 后再看,问题一般就没了。
2.2 手改 Build Settings 把 Runpath 改丢了
framework 明明已经嵌进包里了,但报错还是image not found,这时候八成是Runpath Search Paths为空或者值不对。
这种情况多出现在工程合并、迁移、或者有同事手贱清理“无用” Build Settings 之后。Xcode 默认模板通常会写好@executable_path/Frameworks,但一旦被清空,二进制里的LC_RPATH列表就是空的,@rpath/xxx.framework自然无从解析。
不同 target 需要的值不太一样:
- 普通 App target:
@executable_path/Frameworks - App Extension(Widget、Notification Service 等):建议同时保留
@executable_path/../Frameworks和@loader_path/Frameworks - macOS 命令行工具或非 App 形态产物:需要按产物实际目录结构重新设计
你可以在 Build Settings 里搜Runpath Search Paths直接加,多个值用换行分隔,每条占一行。
2.3 第三方集成工具带来的“半自动”坑
用 CocoaPods、Carthage、SPM 集成动态库时,这个报错也很常见,但各自原因不同。
CocoaPods 配合use_frameworks!时,Pods 会将依赖构建成动态 framework,并自动添加嵌入脚本。但如果你在 Podfile 里混用了静态库和动态库,或者仓库状态比较旧,pod install生成的脚本可能没有覆盖全部 framework,运行时就会缺这个缺那个。遇到这种情况,先重新执行pod install,再检查 Build Phases 里有没有Embed Pods Frameworks这个 Run Script 阶段。
Carthage 更直接:它默认只负责构建 framework,并不会把构建产物拷进你的 App。很多人添加 Carthage 依赖时只加了Framework Search Paths和链接项,完全忘了复制步骤。必须手动加一段 Run Script,调用carthage copy-frameworks,并把需要嵌入的 framework 列在 Input File Lists 里。
SPM 相对省心,一般由 Xcode 自动管理嵌入,但如果你接入的是带动态库的二进制 XCFramework,且包作者在Package.swift里没有正确配置linkerSettings,同样可能落到这个报错上。具体处理我放到第 4 章里讲。
2.4 真机上的签名问题伪装成“找不到”
模拟器上跑得好好的,一上真机就崩,这种情况最容易让人往路径上想,但很多时候其实是签名问题。
iOS 真机对动态库的签名校验非常严格。framework 文件如果带了别人的签名、或者只是模拟器架构的 slice、甚至根本没有签名,dyld 可能直接报code signature invalid,但也可能包装成五花八门的样子。再加上 arm64 和 x86_64 的 slice 不匹配时,日志里会出现no suitable image found,后面跟一串“Did find”的架构列表,很多人一看就懵。
所以真机调试时,不要只看第一行,把Reason和后续几行一起贴出来。真机最常见的路径就是:framework 是用Embed Without Signing嵌入的,但包内它保留了某位第三方开发者的证书签名,主 App 用自己的证书签名后,系统校验不通过。
3. 排查实操:按这几步,十分钟内定位病根
3.1 先看一眼 .app 里面有没有那个 framework
不要急着改配置,先确认事实。Build 成功后,在 Xcode 左侧的Products里找到你的.app,右键Show in Finder;再右键.app,选择Show Package Contents,进入Frameworks目录。
这一步能直接区分两类问题:
Frameworks目录里根本没有xxx.framework——那问题就是“嵌入阶段缺失”,去检查 Embed 设置。Frameworks目录里有xxx.framework,但还是报image not found——那就是路径解析或搜索目录的问题,继续往下查。
别小看这个操作,它能帮你快速砍掉一半的猜测。我曾经接过一个同事的工单,他信誓旦旦说“肯定嵌入了”,结果打开包一看,Frameworks 目录里空空如也。眼见为实,永远是排查第一原则。
3.2 用 otool 扒开二进制的“内脏”
当肉眼确认包里有 framework 后,下一步是用命令行工具看二进制内部记录。
# 查看 App 可执行文件依赖了哪些动态库(install names) otool -L path/to/YourApp.app/YourApp # 查看可执行文件里注册了哪些 rpath otool -l path/to/YourApp.app/YourApp | grep -A2 LC_RPATH # 查看某个 framework 自己的 install name otool -D path/to/YourApp.app/Frameworks/xxx.framework/xxx第一段命令的输出里,你会看到类似@rpath/xxx.framework的条目,这就是 App 依赖它的记录。第二段命令输出的是LC_RPATH,如果这里一行都没有,说明Runpath Search Paths是空的,@rpath自然找不到家。第三段命令查的是 framework 自身的安装名,如果它写的是/Users/xxx/Library/Developer/...这种绝对路径,那说明 SDK 厂商在打包机器上写死了路径,运行时必然出问题。
这三个命令组合起来,基本能把“没嵌”“没路径”“路径写死”三种情况一次性区分清楚。
3.3 开启动态库加载日志,看 dyld 到底搜了哪些目录
如果还想知道 dyld 实际按什么顺序找了哪些地方,可以开它的加载日志。
在 macOS 上开发调试时比较方便,直接给可执行文件注入环境变量:
DYLD_PRINT_LIBRARIES=1 ./YourApp它会打印出每个依赖库的加载尝试记录,包括按@rpath展开后的完整路径。iOS 上没法直接这样跑,但可以通过 Xcode 的 Console、Devices 面板或者崩溃日志来查看,崩溃日志里通常会有更完整的 dyld 搜索路径上下文。
LLDB 断点暂停时,也可以用image list查看当前进程已经加载了哪些镜像,看看目标 framework 是否真的被加载了。如果列表里根本没有它,那说明 dyld 在加载它之前就放弃了,配合崩溃日志里的Reason就能判断原因。
3.4 用一张检查清单把 Xcode 里相关设置过一遍
当你对二进制层面有了判断,再回 Xcode 做一次快速核对。我一般按这个顺序检查:
General -> Frameworks, Libraries, and Embedded Content:Embed 状态是不是Do Not Embed。Build Settings -> Runpath Search Paths:有没有@executable_path/Frameworks;扩展 target 有没有补@loader_path/Frameworks。Build Settings -> Framework Search Paths:链接阶段能不能找到这个 framework。Build Phases里有没有嵌入脚本:尤其是 CocoaPods 的Embed Pods Frameworks、Carthage 的 copy-frameworks、或者手写的cp -R脚本。Build Settings -> Architectures:是否排除了真机或模拟器架构。
这套清单配合命令行结果,基本能在十分钟内把病根锁定。
4. 修复方案:不同场景对号入座,附完整实操
4.1 最正派的做法:正确 Embed + 正确 Runpath
对付大多数常规场景,最稳妥的修复就是两件事:嵌入、路径。
第一步,选 Target,进General,在Frameworks, Libraries, and Embedded Content里点击+,把xxx.framework加进去;如果本来就在列表里,直接把右侧 Embed 改成Embed & Sign。这一步会把 framework 拷贝进Frameworks目录,并按主 App 的证书重新签名。
第二步,去Build Settings,搜索Runpath Search Paths,确认里面有@executable_path/Frameworks。没有就加一行。如果是扩展 target,比如 Today Widget,还需要补上@executable_path/../Frameworks,因为扩展本体在PlugIns目录里,它要访问主 App 的 Frameworks 目录,得先往上跳一级。
为什么强调Embed & Sign而不是Embed Without Signing?因为前者会在嵌入时用当前工程的代码签名证书重新签名 framework,真机上兼容性最好;后者适合纯内部调试、或者 framework 已经有合法签名的场景。提交 App Store 时,还是建议老老实实用Embed & Sign,省得在签名上踩坑。
4.2 命令行兜底:install_name_tool 和 codesign 的组合治疗
有些场景没法在 Xcode 图形界面里解决,比如第三方 SDK 是二进制分发、对方把 install name 写成了绝对路径,或者你的自动化打包流程没法手动点界面。这时候需要用命令行兜底。
# 给主 App 可执行文件补上 rpath(临时救急) install_name_tool -add_rpath @executable_path/Frameworks YourApp # 如果 framework 的 Install Name 是绝对路径,改成 @rpath 形式 install_name_tool -id @rpath/xxx.framework xxx.framework/xxx # 改完以后必须重新签名,否则真机一跑就会报签名错误 codesign --force --sign "你的证书名称" --preserve-metadata=entitlements xxx.frameworkinstall_name_tool -add_rpath是给二进制追加一条LC_RPATH;install_name_tool -id是修改动态库自己的安装名。改完 install name 后,签名信息必然失效,所以必须紧跟一条codesign重新签名。
注意,这只是“治疗手段”,不是“预防手段”。如果这个 framework 是你自己负责分发的,应该从源头把 install name 设成@rpath/xxx.framework,别让下游工程去帮你擦屁股。我在实际项目中见过太多 SDK 厂商把打包机的用户名和路径固化成 install name,结果每个接入方都要在 CI 脚本里做一次字符串替换,这种“遗传病”真的很折磨人。
4.3 CocoaPods、Carthage、SPM 的专项姿势
CocoaPods 场景下,先确认 Podfile 里有没有use_frameworks!。如果加了,Pods 会生成动态 framework,理论上会自动嵌入;如果报错,先重新pod install,再确认 Build Phases 里存在Embed Pods Frameworks脚本。要是你想彻底躲开动态库运行时问题,可以把 Podfile 改成:
platform :ios, '13.0' use_frameworks! :linkage => :static target 'YourApp' do pod 'AFNetworking' end:linkage => :static表示即使使用 framework 形式,也采用静态链接,代码在链接期就并进主二进制,运行时不再需要额外加载,自然也没有dyld: Library not loaded的烦恼。代价是主二进制体积会变大、链接时间变长,但对稳定性是实打实的帮助。
Carthage 场景下,用新版 XCFramework 时一般这么操作:
carthage update --use-xcframeworks然后在 Target 的Build Phases里新增一个 Run Script,写入:
/usr/local/bin/carthage copy-frameworks并在 Input File Lists 里填上 framework 的完整路径,例如:
$(SRCROOT)/Carthage/Build/iOS/xxx.xcframeworkCarthage 的设计哲学就是“只构建不拷贝”,所以这步脚本必不可少。漏了它,Build 同样能过,但运行时就找不到。
SPM 场景下,标准库依赖基本由 Xcode 自动处理。但如果你接的是本地二进制 XCFramework,且包作者没配好 linker settings,可以在 Package.swift 里手动补:
.target( name: "YourBinaryTarget", dependencies: [], path: "Sources/YourBinaryTarget", linkerSettings: [ .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks"]) ] )更推荐的做法是在宿主 App 的 Build Settings 里手动加Runpath Search Paths,因为unsafeFlags会影响所有依赖这个包的目标,可能会波及不该影响的子模块。
4.4 真机专项:签名要“补刀”
真机上反复遇到这个报错时,先检查签名。查看 framework 当前签名信息:
codesign -dv --verbose=4 YourApp.app/Frameworks/xxx.framework如果输出里没有Signature=adhoc,而是别人的 Developer ID 或者某位同事的证书,真机校验基本过不了。对策是把 framework 从工程里移除,重新用Embed & Sign嵌入,让 Xcode 重新签名;或者手动跑一条 codesign 命令。
有一种老办法是codesign --force --deep --sign,它会递归签名整个 .app 内所有内容。但 Apple 官方并不推荐--deep,因为它可能把不该动的嵌套签名也一起覆盖,引起更高层级的校验失败。我的建议是:能重新 Embed 就重新 Embed,别依赖--deep。
真机还有一个高频坑:framework 里只有模拟器架构,或者只有真机架构。在 Build Settings 里确认Excluded Architectures没有误排除 arm64,且 framework 本身是 XCFramework 或包含了对应架构的 slice。用lipo -info xxx.framework/xxx看一眼当前架构列表最直接。
5. 经验浓缩:避坑清单与速查表
5.1 我踩过、并且经常有人继续踩的坑
模拟器换真机后突然崩,这是高频中的高频。模拟器构建和真机构建的 framework 本质就是两批二进制,你在模拟器调试了三天,一上真机发现image not found,不要慌,先在真机模式下重新 Build,确认嵌入的 framework 是 arm64 版本再说。
第三方 SDK 打包机器的“绝对路径遗传病”也很常见。某个大厂 SDK 的 install name 里带着/Users/ci/build/...,你所有接入方都得靠install_name_tool改一遍。这种问题没法从工程侧根除,只能靠上游修复,但你可以写进 CI 脚本做自动化处理,避免每次手动操作。
扩展 target 遗漏@executable_path/../Frameworks的情况,比想象中多。很多人给主 App 配置好了,就以为万事大吉,直到用户在 Widget 上崩溃才想起扩展独立成 target,路径体系是另一套。检查时务必把所有 target 过一遍。
5.2 诊断速查表
| 现象 | 最大嫌疑 | 先查什么 | 对应解法 |
|---|---|---|---|
| 崩在启动,Reason 是 image not found | 没嵌入 | .app/Frameworks里有没有该 framework | 改成 Embed & Sign |
| framework 在包内,但仍然 image not found | Runpath 为空 | otool -l看 LC_RPATH | 补@executable_path/Frameworks |
| 模拟器正常,真机报错 | 架构或签名 | lipo -info、codesign -dv | 换真机构建重新嵌入 |
| 扩展 target 里崩 | Runpath 覆盖不全 | 扩展的 Runpath Search Paths | 补@executable_path/../Frameworks |
| CocoaPods 集成后崩 | 缺少嵌入脚本 | Build Phases 里有没有 Embed Pods Frameworks | 重新 pod install |
| Carthage 集成后崩 | 没拷贝 framework | Build Phases 里有没有 copy-frameworks | 添加 Run Script |
| framework 路径带 /Users/... | install name 写死 | otool -D查看框架自身 | 改成 @rpath 形式并重签名 |
5.3 一条长期有效的个人工作流
我自己在项目里固化了一套检查流程,每次往工程里加动态库,就按这套走,基本不会再被这个报错纠缠:
- 构建成功后,先去
.app包里确认Frameworks目录里有没有目标 framework。 - 用
otool -L看主二进制的依赖记录,和包内实际文件做对照。 - 用
otool -l查LC_RPATH,确认@rpath有地方可去。 - 在真机上跑一遍,排除签名和架构问题。
- 全流程走完后,把最终 framework 的 install name、rpath 配置写成一份 README,放进工程文档。
这套流程看起来麻烦,但真能帮你把“启动崩溃”的概率降到最低。
5.4 实在搞不定时的最后方案:从动态改静态
如果你被这个报错折磨到崩溃,还有一个绕开所有问题的终极大招:把动态 framework 改成静态链接。
CocoaPods 里用use_frameworks! :linkage => :static,Carthage 构建时用--no-use-binaries配静态 framework,SPM 里优先选择static产品类型,都能避开运行时加载这整个环节。代码在链接期就全部并入主二进制,启动时没有额外动态库要加载,自然不存在Library not loaded。
代价是主二进制变大、模块间耦合变高,但很多对启动稳定性要求极其严格的项目,反而更愿意接受这个取舍。我自己在做一个视频播放器 SDK 的时候,就特意同时提供静态和动态两个版本,让客户自己选。对大多数中小型 App,静态方式更省心;等团队大了、组件多了,再切回动态也不迟。
说了这么多,其实这个报错折腾到最后,我发现大多数情况下根本不是技能问题,而是习惯问题:每次往工程里加一个动态库,先养成三连问——嵌没嵌?路径对不对?签名在不在?养成这个习惯之后,真的能少很多事。我自己现在写了个简单的 CI 脚本,每次构建完自动检查.app/Frameworks里有没有声明过的动态库,没有再直接失败,立刻拦在测试之前。你要是也被这个问题坑过,不妨也把这套小检查固化到工程里,一次投入,长期受益。