项目标题: "Xcode 15 适配 MonkeyDev"
升级到 Xcode 15 之后第一次打开那个跑了两年多的动态调试工程,Build 一次直接甩出四百多条红色报错,第一条是Sandbox: bash(38291) deny(1) file-write-create,往下翻是成片的Undefined symbols,再往下是ignoring duplicate libraries。这套工程基于 MonkeyDev 模板搭建,平时主要用来给自有 App 做行为注入验证、给团队做插件化原型的快速试验台,之前从 Xcode 12 一路平推到 14.3,一行配置没动过。结果 Xcode 15 换了默认链接器、默认打开了脚本沙盒、顺手把模块校验也加上了,三件事叠在一起,就是现在这个局面。MonkeyDev 这类模板的骨架是 Xcode 工程 + theos 编译链 + 一堆 Run Script 拼起来的,它天然比普通工程更容易被工具链变更打到。这篇就按我实际排障的顺序写下来:Xcode 15 具体动了哪几处、模板工程的变量是怎么流动的、每处该改哪个字段、为什么这么改、改完编译过了但运行期又栽在哪儿。适合手上还挂着一套 MonkeyDev 系工程、又不得不升 Xcode 的人看,从没碰过这类模板的也能顺着摸清整条链路。
1. Xcode 15 的三处默认变更,先弄清是谁在打你
1.1 链接器换代:ld_prime 接管后,旧式符号解析开始翻车
Xcode 15 把默认链接器从原来的 ld64 换成了内部代号 ld_prime 的新链接器。这个变化对普通业务工程基本透明,但 MonkeyDev 系工程的链接参数很"野":OTHER_LDFLAGS里常年塞着-lsubstrate、-force_load、-ObjC、-all_load,还有一堆手工指定的.a和.dylib路径。新链接器对这些参数的解析顺序、重复库的去重策略、未定义符号的报错时机都做了调整,最容易撞上的三种表现是:
| 报错形态 | 实际含义 | 常见诱因 |
|---|---|---|
Undefined symbols for architecture arm64 | 符号在新链接器下没被拉进来 | -force_load位置靠后、静态库依赖顺序被打乱 |
ld: warning: ignoring duplicate libraries | 同一个库被链接了两次 | 模板 xcconfig 与 Pods 各注入了一份 |
Assertion failed: (atom->fixupTargetIdx < ...) | 链接器内部断言 | 二进制里存在异常段或非标准 fat 结构 |
真正要命的不是报错本身,而是第一种:它会在你完全没动的代码上冒出来,让你误以为是模板库坏了。我第一次遇到时反复确认了三四遍 Podfile.lock,最后才定位到是链接器换了。
-force_load这个参数值得单独说一句。它的语义是"把整个静态库的所有目标文件都拉进链接结果,哪怕没有任何符号被引用",MonkeyDev 模板里用它来保证 Category 和 Logos 生成的符号不被链接器当垃圾回收掉。新链接器对-force_load的处理更严格,如果路径写成相对路径或用了$(SRCROOT)之外的变量展开,它有可能直接静默跳过——表现就是编译成功,运行时日志里却找不到你打的点。
1.2 User Script Sandboxing 默认打开,Run Script 全线阵亡
ENABLE_USER_SCRIPT_SANDBOXING这个设置在 Xcode 15 里的默认值从NO变成了YES。官方给的说明是提升构建安全性,但对 MonkeyDev 这种把大量工作塞进 Run Script 的工程来说,等于把厨房锁了。
模板里典型的脚本干这些事:往$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH里拷贝 dylib、调用外部命令行工具重写 Mach-O 的 load command、跑make package打包 deb、把产物同步到测试机的固定目录。这些动作全部涉及对 DerivedData 之外路径的写入,沙盒一开,第一个cp就被拦。
这里有个容易踩的坑:报错信息是分散的。Xcode 的 Build 面板只显示脚本的退出码非零,具体哪一行被拦要去Report Navigator里点开对应那一次的日志,展开看Sandbox:开头的那几行。我第一次只看到"Command PhaseScriptExecution failed",以为是脚本语法问题,白折腾了二十分钟。
提示:判断是不是沙盒问题有个快招——在脚本第一行加
echo $SANDBOX_ABORT_ON_ERROR或者直接ls /tmp,如果脚本连这行都没输出就挂了,基本可以锁定沙盒;如果输出了但后面某条命令失败,那是命令本身的问题,跟沙盒无关。
1.3 模块校验与产物结构,两个不报错但致命的变化
Xcode 15 引入了ENABLE_MODULE_VERIFIER,默认开启,会在编译后额外跑一遍模块校验,检查你的 module map 和公共头文件是否自洽。Pods 里那些上了年纪的第三方库(尤其是手工维护的.a+ 头文件组合)经常过不了这一关,报错关键字是error: module verification failed或者一串while building module ... imported from。
这个问题阴险的地方在于:它只在 Clean Build 之后出现,增量编译时看不出异常。我一度以为自己已经改好了,Clean 一次又炸,浪费了一整个下午。
另一个变化是产物目录。Xcode 15 对BUILD_DIR和CONFIGURATION_BUILD_DIR的展开更严格,模板脚本里如果用了硬编码的build/Debug-iphoneos相对路径,会出现"编译成功但脚本找不到文件"的情况。正确做法是统一用$TARGET_BUILD_DIR和$UNLOCALIZED_RESOURCES_FOLDER_PATH这类官方变量,让 Xcode 自己算路径,别去猜。
2. 先把工程链路摸清楚,再决定改哪一行
2.1 模板的三个入口:xcconfig、Run Script、Makefile
MonkeyDev 模板看着复杂,实际控制点就三个,把这三个地方读一遍比盲目搜报错高效得多。
第一个是各个 target 挂的.xcconfig文件。模板通常有MonkeyDev.xcconfig这类配置,里面的MonkeyDevPath指向本机的模板安装目录,然后一路派生出一堆HEADER_SEARCH_PATHS、LIBRARY_SEARCH_PATHS、OTHER_LDFLAGS。所有路径问题的根都在这里。
第二个是 Build Phases 里的 Run Script。不同模板脚本数量不一样,App 类模板一般有三个:拷贝 dylib、重签名、安装到设备。每个脚本的执行顺序很关键,Xcode 15 下顺序错乱会导致"文件拷进去了但签名是旧的"。
第三个是Makefile。这部分属于 theos 体系,负责make package打出 deb。它和 Xcode 的构建是两条并行的腿,make用 theos 的 SDK 和工具链,Xcode 用自己的一套,两边的ARCHS和IPHONEOS_DEPLOYMENT_TARGET不一致时会出现"Xcode 编得过、make 打不出包"的割裂现象。
我的做法是先在工作机上把三个入口的文件全打开,在纸上画一张简单的依赖图:谁给谁传变量、谁先执行。这一步花二十分钟,后面省两小时。
2.2 变量从 Xcode 传到 theos 的完整路径
这条链路不通,很多"明明配了却不生效"的谜题就解不开。大致流程是这样:
# Xcode 侧:xcconfig 里定义 MonkeyDevPath = /Users/xxx/MonkeyDev THEOS = $(MonkeyDevPath)/theos # 传给脚本环境 export THEOS export THEOS_DEVICE_IP = 192.168.1.10然后在 Run Script 里调用make,theos 再读自己的makefiles/,最后去$THEOS/sdks/找 SDK。
Xcode 15 在这条链路上改了一点:脚本执行时的环境变量继承更严格,某些之前在 Build Settings 里定义的自定义变量不会自动导出到脚本环境。解决办法显式export,别指望它自己传过去。
# 稳妥写法:脚本开头显式导出 export MonkeyDevPath="/Users/xxx/MonkeyDev" export THEOS="${MonkeyDevPath}/theos" export PATH="${THEOS}/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"PATH那行尤其重要。Xcode 15 在 Apple Silicon 机器上跑脚本时用的是/bin/sh,而不是你终端里的 zsh 配置,所以 Homebrew 装的ldid、dpkg-deb、insert_dylib这些工具全都不在路径里。表现是脚本报command not found,但你在终端里手敲同样的命令完全正常。
2.3 用 -showBuildSettings 抓一份基线快照
改配置之前先留证据,这是我踩过坑之后养成的习惯。命令行跑:
xcodebuild -workspace YourProject.xcworkspace \ -scheme YourScheme \ -configuration Debug \ -sdk iphoneos \ -showBuildSettings > before.txt把before.txt存好,改完之后再跑一次生成after.txt,diff一下。Xcode 15 改了默认值的地方(比如ENABLE_USER_SCRIPT_SANDBOXING、ENABLE_MODULE_VERIFIER、OTHER_LDFLAGS)会在这份快照里一目了然,比在 GUI 里一页页翻 Build Settings 快得多。
另外一个用途是找"我以为它没生效"的配置项:grep一下就知道 Xcode 最终解析出来的值是什么。很多时候你在 xcconfig 里写了,但被 target 级别的设置覆盖了,Xcode 的解析优先级是 target 设置 > project 设置 > xcconfig,-showBuildSettings显示的是最终结果,不会骗你。
3. 逐项改造:改动尽量收敛在 xcconfig 里
3.1 关闭脚本沙盒,以及顺手要处理的几个告警
最直接的一行:
// MonkeyDev.xcconfig ENABLE_USER_SCRIPT_SANDBOXING = NO我不建议手动在 GUI 里点,因为一是多个 target 都要改,二是一旦有人重新 pull 工程,target 级别的设置容易冲突。写进 xcconfig 是所有 target 统一生效,而且改动可被 git 记录,团队其他成员拉下来就是对的。
同一个文件里顺手再补两条:
ENABLE_MODULE_VERIFIER = NO SWIFT_ENABLE_EXPLICIT_MODULES = NO第一条解决前面提的模块校验报错,第二条是 Xcode 15 引入的显式模块依赖,对混编工程不友好,关掉能省不少Could not build module的麻烦。
还有一个-no_warn_duplicate_libraries。Xcode 15 对重复链接的库会刷大量告警,虽然不影响产物,但几百条黄字会把真正的错误埋掉。加在OTHER_LDFLAGS里:
OTHER_LDFLAGS = $(inherited) -Wl,-no_warn_duplicate_libraries注意:
$(inherited)一定要写在最前面,否则会把 Pods 通过 xcconfig 传进来的链接参数整个覆盖掉,症状是突然冒出一堆 Pod 的符号找不到。
3.2 回退旧链接器的三种写法,各有适用面
新链接器不是所有场景都得回退。我的判断标准是:如果报错集中在符号解析和静态库合并,先试回退;如果是真的二进制格式问题,回退也救不了,得换库。
三种写法按影响范围从小到大排:
第一种,只在出问题的 target 上加:
// 只影响当前 target OTHER_LDFLAGS = $(inherited) -ld_classic第二种,全局注入,适合模板库本身就用不了新链接器的情况:
# 在 Build Phase 里临时改环境,仅当前构建生效 export LDFLAGS="-Wl,-ld_classic ${LDFLAGS}"第三种是命令行构建时透传,适合 CI 场景,不改工程文件:
xcodebuild ... OTHER_LDFLAGS='$(inherited) -ld_classic'我自己的选择是第一种,因为 MonkeyDev 模板里的主 target 通常是唯一出问题的地方,Pod 里的库在新链接器下大多正常。全局回退会掩盖掉真正的兼容性问题,将来想再切回新链接器时会更痛苦。
回退之后要确认真的生效了,看 Build 日志里Ld那一行,正常应该能看到-ld_classic出现在参数列表里。如果没看到,说明配置被覆盖了,回去查-showBuildSettings。
3.3 架构、最低版本与 arm64e 的实际取舍
模板默认ARCHS = arm64 arm64e。arm64e是带指针认证的 ABI,Xcode 15 对它更严格,主要体现在三处:链接时的 ABI 版本校验、签名时的 entitlements 匹配、以及运行时 dyld 的校验。
真机上现在跑的基本都是 arm64 用户态,arm64e 主要是系统组件在用。除非你有明确需求,我自己是把ARCHS收窄成arm64,EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64保留给模拟器,这样能省掉一大类莫名其妙的崩溃。
ARCHS = arm64 VALID_ARCHS = arm64 IPHONEOS_DEPLOYMENT_TARGET = 12.0IPHONEOS_DEPLOYMENT_TARGET这条要注意:Xcode 15 支持的最低版本比之前高了,如果模板里还写着9.0之类的老值,会直接报错。改成12.0是个比较保险的中间值,太低会被 Xcode 15 拒绝,太高会限制测试机覆盖范围。
改完之后记得同步改Makefile里的对应变量,两边不一致会导致make package打出来的包和 Xcode 编出来的不匹配。
# Makefile ARCHS = arm64 TARGET = iphone:clang:latest:12.03.4 Swift 混编时的模块缓存与 header map
只要工程里有 Swift 文件,Xcode 15 下的坑会多一层。典型症状是改了 Swift 代码后编译报Redefinition of module 'xxx',或者Header map file not found。
原因是 Xcode 15 调整了模块缓存的存放位置和失效策略。以前删 DerivedData 就能好,现在光删 DerivedData 不够,模块缓存还有一份在~/Library/Caches/org.llvm.clang/ModuleCache和~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex。
清缓存的完整命令:
rm -rf ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex rm -rf ~/Library/Caches/org.llvm.clang/ModuleCache rm -rf ~/Library/Developer/Xcode/DerivedData/YourProject-*清完之后 Build 一次,如果还是同样的错,那就不是缓存问题,去看HEADER_SEARCH_PATHS里有没有重复的头文件目录。MonkeyDev 模板会把 theos 的 headers 和 Xcode 的 SDK headers 都塞进去,顺序不对时,同名的objc/runtime.h可能被解析到两份不同版本的。
排查手段:
xcodebuild ... -showBuildSettings | grep HEADER_SEARCH_PATHS看解析出来的顺序,Xcode SDK 的路径应该排在 theos 之前。
4. 编译通过只是第一关:注入与加载阶段的排查链
4.1 dylib 到底有没有进产物目录
编译绿了不等于跑得起来。第一步永远是确认产物在哪、有没有被塞进去。
# 找 App 的 .app 目录 find ~/Library/Developer/Xcode/DerivedData/YourProject-*/Build/Products -name "*.app" -maxdepth 2 # 检查 dylib 是否被拷进去 ls -la YourApp.app/Frameworks/ ls -la YourApp.app/ # 检查主二进制的 load commands otool -L YourApp.app/YourAppotool -L的输出里应该能看到你的 dylib 路径。如果看不到,说明 Run Script 的拷贝阶段成功但注入阶段失败——这两个步骤是分开的,很多人只验证了文件存在就去跑,结果运行时根本没加载。
注入这一步在 MonkeyDev 里通常靠脚本改写 Mach-O 的LC_LOAD_DYLIB,Xcode 15 下这个脚本最容易因为命令行工具路径问题失败。检查方法是在脚本里加set -x,把每条命令都打出来,看是哪一步中断。
4.2 加载失败的三种日志形态与对应原因
真机启动后闪退,日志会告诉你是哪一类:
| 日志关键字 | 原因 | 处理方向 |
|---|---|---|
code signature invalid | dylib 没重签或签错了 | 用codesign -f -s -重签,确认 entitlements 与主 App 一致 |
Library not loaded: @rpath/xxx.dylib | rpath 没配或实际路径不匹配 | 检查LD_RUNPATH_SEARCH_PATHS,确认 dylib 落到 Frameworks 目录 |
symbol not found in ... | dylib 依赖了不存在的库 | otool -L逐层查依赖链 |
日志怎么抓:真机上用 Xcode 的 Devices and Simulators 窗口里 View Device Logs,或者idevicesyslog这类工具实时看。关键是别只看崩溃报告里的最后一帧,往前翻二十行左右通常是真正的错误信息。
我遇到最多的是第二种。原因是@rpath的解析依赖主二进制里的LC_RPATH,而模板脚本注入 dylib 的时候不一定同时补了 rpath。手动补:
install_name_tool -add_rpath @executable_path/Frameworks \ YourApp.app/YourApp或者更省事的做法是把 dylib 直接放在.app根目录,@executable_path/xxx.dylib就能解析到。这一招在调试阶段挺管用,正式打包再挪回 Frameworks。
4.3 让日志自己说话:把可观测性补在前面
排查加载问题最痛苦的是"什么日志都没有"。所以我在模板脚本里固定加了三样东西,后面所有问题都变简单了。
第一样是构建阶段的版本戳,写进一个 plist 或者简单的文本文件,运行期能读到,用来确认到底跑的是哪一次构建的产物:
echo "$(date +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)" > \ "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/build_stamp.txt"第二样是入口处打一行日志,确认 dylib 真的被 dyld 加载了。这一行要打在构造函数或者+load里,比任何业务逻辑都早:
__attribute__((constructor)) static void md_entry(void) { NSLog(@"[MD] dylib loaded, pid=%d", getpid()); }第三样是关键路径的异常兜底,用@try/@catch包住初始化逻辑,避免一个初始化异常导致整个 App 起不来还看不到原因。
这三样加起来不到二十行代码,但把"能编译、跑不起来、不知道为什么"这种最耗时的场景压缩到了看一眼日志就能定位的程度。
5. 改完之后:我的回归清单与后续版本跟进方式
5.1 一份可以直接抄的回归清单
每次改配置之后,我按这个顺序过一遍,出错能快速定位在哪一层:
xcodebuild clean之后完整 Build 一次,确认没有依赖增量缓存的假绿。-showBuildSettings抓快照,grep确认四条关键配置:ENABLE_USER_SCRIPT_SANDBOXING = NO、ENABLE_MODULE_VERIFIER = NO、OTHER_LDFLAGS含-ld_classic、ARCHS只含arm64。otool -L确认主二进制的依赖列表里有目标 dylib。codesign -dvvv确认签名信息,重点看TeamIdentifier和Authority与主 App 是否一致。- 真机启动,抓系统日志确认入口那行
[MD] dylib loaded出现。 - 走一遍模板里最核心的那个功能点(比如 Hook 某个方法后打日志),确认行为符合预期。
make package打一次包,确认 theos 侧和 Xcode 侧产物一致。
第 2 条是最容易被跳过的,但也是最省时间的。配置项的最终值和你在 xcconfig 里写的值不一致,是这类工程的常态。
5.2 版本跟进:把改动做成可回退的最小单元
Xcode 15 适配不是一次性动作,16、17 还会继续来。我的做法是把这次的改动切成几个独立的 commit,每个 commit 只解决一类问题:沙盒一个、链接器一个、架构一个、签名一个。这样将来某个版本不需要其中某一项时,可以单独 revert,而不是把整坨改动一起回退。
另外,我会在工作机上留一个最小可复现工程:只有模板骨架 + 一个空 dylib + 一条日志,不放任何业务代码。每次 Xcode 升级先拿它试水,十分钟就能知道这次的坑在哪一层。这个东西的价值在升级初期特别明显——用真实工程去试,变量太多,报错指向性差;用最小工程试,一分钟就能区分是新链接器的问题还是我自己的代码问题。
还有一个习惯是给模板目录做快照。MonkeyDevPath指向的那个目录,在升级前后各打一个 tar 包存着。模板本身也会被 Xcode 版本变化影响(比如它内部的一些脚本依赖的 Swift 版本、SDK 路径),出问题时能快速对比出改了什么。
最后分享一个我在几次升级里反复验证过的判断:Xcode 大版本升级后,MonkeyDev 这类模板工程报的错,八成以上集中在四个地方——链接参数、脚本沙盒、签名链路、模块缓存。按这个顺序查,比从报错列表第一条往下点要快得多。我现在的习惯是升级完先不动工程,直接把这几处按顺序过一遍,往往在还没 Build 之前就把问题解决在配置层了,省下的时间比事后抢救多得多。