前言
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <UIKit/UIKit.h>
void test(void) {
if (@available(iOS 14.0, *)) {
UTType *t = [UTType typeWithIdentifier:@"public.image"];
(void)t;
}
}
如上所示,如果App的支持版本低于iOS14,那么最终对UniformTypeIdentifiers的依赖应该是weak的。因为UniformTypeIdentifiers的最低支持版本是高于我们的App。但是如果你去编译上述的代码你会发现无论是.o的符号还是最终的动态库依赖都是strong的,就让我们一起来帮Apple修复这个bug。
背景
决定一个动态库的强弱依赖是通过它里面的符号来决定的,而决定一个符号的强弱是通过符号的声明和依赖来决定的,也叫做符号决议。在编译、链接的过程中,每一步都会决定最终动态库依赖的强弱。
那么如何定位到上述的问题呢?通过现象来看,我们很明确的知道了符号是强的,因为最后生成的.o文件中对符号的依赖是强引用。那么在编译的过程中是不是强引用或者强依赖了这个库以及对应的符号呢?
我们首先想到的是去查看编译的参数,通过Xcode我们很快定位到了对应的文件编译命令。
可以看到他是用-framework的方式去编译和依赖的,并不是-weak_framework。那么问题会是这里吗?
此时如果我们调整import的顺序,将代码改成
#import <UIKit/UIKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
void test(void) {
if (@available(iOS 14.0, *)) {
UTType *t = [UTType typeWithIdentifier:@"public.image"];
(void)t;
}
}
注意此时是先import UIKit然后import UTI你会发现符号和最终产物对于UniformTypeIdentifiers的依赖都是weak。
有点奇怪,难道
import的顺序会影响符号的强弱?
我们继续把demo简化,调用最原始的xcrun命令来编译。
# 编译
xcrun clang -c test.m -o test.o \
-target arm64-apple-ios12.0 \
-isysroot $(xcrun --sdk iphoneos --show-sdk-path)
# 检查符号
nm -m test.o | grep UTType
这次出现的符号却是weak,而不是strong。
这说明问题不在
-framework 还是 -weak_framework,我们连任何链接参数都没加,符号就已经是对的了。很显然是Xcode在编译的过程中插入的某些参数影响到了符号的强弱,从而影响到了最终的依赖。
探究
打开Xcode查看对应的编译命令,发现Xcode默认会显示指定使用modules。(Xcode16之后默认开启)
让我们重新写个脚本验证一下
# 编译
xcrun clang -c test.m -o test.o \
-target arm64-apple-ios12.0 \
-isysroot $(xcrun --sdk iphoneos --show-sdk-path) \
-fmodules \
-fmodules-cache-path=/tmp/modcache
# 检查符号
nm -m test.o | grep UTType
这次我们显示的指定用modules编译。结果如下:
看起来和module是强相关。
我们把UTI放在最前,同时分别用
-fmodules和-fno-modules测试结果如下.
因此我们不难得出结论:
编译参数有
-fmodules且import UTI在最前时,会导致UTI的符号变成强符号,对其动态库的依赖也是强依赖。
那么为什么import的顺序和modules参数会影响到符号的强弱呢?这就要去查看编译的过程,所幸LLVM是开源的。
我本机的环境是
mac OS: 26.4
Xcode 26.0
clang 17.0.0
在wiki 上可以看到对应的clang 版本为19.1.5
编译过程追踪:一步一步定位 Redecl 链的问题
我们已经用实验锁定了 -fmodules + import 顺序两个条件,但这只是表象——根因藏在 Clang 编译流程的深处。下面沿着编译流程一步一步追踪。
Step 1:两条路径
Clang 编译一个 .m 文件,大致经过:词法分析 → 语法分析 → 生成 AST → 语义分析 → 代码生成 → 目标文件。其中语义分析阶段(Sema)负责检查类型、合并声明、处理属性等。
但当 -fmodules 开启后,流程出现了一个关键的分叉:
不用 modules 时,一切正常。但开启 modules 后,Clang 不再解析头文件,而是从 PCM(预编译模块)中加载序列化后的 AST——问题就出在这条反序列化路径上。
那么 PCM 路径下到底发生了什么?顺着这条分支继续追踪。但在此之前,需要先理解 Clang 中一个关键的数据结构。
Step 2:理解 Redeclaration Chain
在 Objective-C 中,同一个类可以被声明多次。最常见的情况:一个 .h 文件里有 @class Foo 的前向声明,另一个 .h 文件里有完整的 @interface Foo : NSObject ... @end 定义。这两个声明描述的是同一个符号,Clang 需要把它们关联起来——这就是 Redeclaration Chain(重声明链)的作用。
链的结构
Redeclaration Chain 是一个单向链表,通过每个声明节点内部的 PreviousDecl 指针串联:
当一个新声明出现时,它调用 setPreviousDecl(旧节点) 把自己挂到链的头部,成为"最新"节点。getMostRecentDecl() 返回的就是这个链头。
为什么链头这么重要
Clang 中很多判断只看链头。来看 isWeakImported() 的实现(clang/lib/AST/DeclBase.cpp):
bool Decl::isWeakImported() const {
bool IsDefinition;
if (!canBeWeakImported(IsDefinition))
return false;
// 只检查 getMostRecentDecl() 的属性——也就是链头!
for (const auto *A : getMostRecentDecl()->attrs()) {
if (const auto *AA = dyn_cast<AvailabilityAttr>(A)) {
// 检查是否匹配当前编译平台...
}
}
return false;
}
isWeakImported() 不遍历整条链,只检查链头节点的属性。 链头有什么,符号就是什么。
现在回到我们的问题。当 UTI 在前、UIKit 在后时,UTType 的 Redecl 链是如何一步步变化的?
Step 3:模块加载与 Redecl 链的构建
- 加载 UTI 模块后:链上只有
@interface,它是链头,5 个属性齐全。 - 加载 UIKit 模块后:
@class通过setPreviousDecl()把自己挂在@interface前面,成为新的链头,getMostRecentDecl()现在返回@class。
根据 Step 2 的分析,isWeakImported() 只看链头。现在链头是 @class,属性为空——它必须从旧节点 @interface "继承"属性。这个继承过程由 Clang 的 mergeInheritableAttributes 负责。
那合并过程到底是怎么做的?直接看源码。
Step 4:追踪属性合并——mergeInheritableAttributes
在 PCM 反序列化过程中,每构建一个 Redecl 链节点,Clang 都会调用 mergeInheritableAttributes(D=新节点, Previous=旧节点),把旧节点的可继承属性拷贝到新节点。
对于 UTType,旧节点 @interface 带有 5 个平台的 availability 属性(来自 API_AVAILABLE_BEGIN 宏展开):
从设计意图上说,所有 5 个属性都应该被拷贝。但实际发生了什么,需要看源码。
Step 5:揭开谜底——getAttr 只取第一个
mergeInheritableAttributes 的源码在 clang/lib/Serialization/ASTReaderDecl.cpp 中。属性合并的核心逻辑只有这几行:
if (!D->hasAttr<AvailabilityAttr>()) {
if (const auto *AA = Previous->getAttr<AvailabilityAttr>()) {
NewAttr = AA->clone(Context);
NewAttr->setInherited(true);
D->addAttr(NewAttr);
}
}
注意这一行:Previous->getAttr<AvailabilityAttr>()。
getAttr<T>() 的语义是"返回属性列表中第一个类型为 T 的属性",不是遍历全部。UTType 的 5 个平台属性按 API_AVAILABLE_BEGIN 宏展开顺序排列,macOS 排在第一位。
所以实际执行结果是:
根因浮现:合并后,链头 @class 只有一个 macOS availability。而 isWeakImported() 判定时的逻辑是:遍历链头属性,找匹配当前编译平台的 availability。编译目标是 iOS:
- 找到
macOS 11.0——平台不匹配,跳过 - 没有更多属性了
- 返回
false——符号变成 strong ❌
iOS、watchOS、tvOS、macCatalyst 四个平台的 availability 在合并中全部丢失了。
Step 6:回看验证——为什么 UIKit 在前就没事
发现了根因之后,回过头看"import 顺序决定结果"的现象就完全说得通了:
UIKit 在前时,@class 先加载(旧节点),@interface 后加载(新节点 = 链头)。@interface 本身就携带全部 5 个属性,D->hasAttr<AvailabilityAttr>() 为 true,整个合并逻辑被跳过。链头属性完整,isWeakImported() 正确返回 true——符号是 weak ✅。
这反过来也印证了根因:问题不在于"有没有合并",而在于"合并做得不完整"。
修复
理解了根因,修复只需要把 getAttr<T>() 换成 specific_attrs<T>()——前者只返回第一个,后者遍历全部:
// ❌ 修复前:getAttr 只返回第一个
if (const auto *AA = Previous->getAttr<AvailabilityAttr>()) {
NewAttr = AA->clone(Context);
D->addAttr(NewAttr);
}
// ✅ 修复后:specific_attrs 遍历全部
for (const auto *AA : Previous->specific_attrs<AvailabilityAttr>()) {
NewAttr = AA->clone(Context);
NewAttr->setInherited(true);
D->addAttr(NewAttr);
}
一个 if 变 for,效果天差地别:
至此,链头属性完整,isWeakImported() 能正确匹配到 iOS availability,符号正确标记为 weak。
这个 bug 的影响范围不只是 UTType。任何跨模块的 ObjC 声明,只要涉及多个平台的 availability 属性,在特定的模块加载顺序下都可能出现属性丢失。修复已合入 LLVM main 分支,如果你使用自定义编译的 Clang 或等待 Apple 更新 Xcode 中的 Clang 版本,这个问题将不再出现。