available没你想象中的可靠

231 阅读8分钟

available没你想象中的可靠

随着iOS系统的更新,iOS官方提供了许多的高版本才支持的功能。但是往往我们需要支持多个版本的iOS系统,在支持新功能的同时也要照顾低版本用户。通常情况下,我们会使用@available(iOS 14.0, *)等方式来做版本限制,但是这可能会引入非预期的crash。

1.背景

示例代码如上图所示,UniformTypeIdentifiers.h是一个iOS14以后的系统库,typeWithIdentifier也是一个iOS14以后才支持的API,那么如果我们把用到API的地方放到@available(iOS 14.0, *)的判断条件内,是不是就可以了呢?

当你在iOS13上运行时会发现报错,内容如上图所示,在低版本中,dyld在启动我们的程序时找不到对应的动态库。

那么为什么我的代码会新增对系统库UniformTypeIdentifiers的依赖呢?对比了两次提交的构建产物发现,我的代码提交之前对UniformTypeIdentifiers的依赖是weak,提交之后就不是了。

image.png

图片 1提交之前

image.png

图片 2 提交之后

很显然,我的available包裹的代码也参与的编译,导致系统认为我依赖了UniformTypeIdentifiers库,然后给我设置了强行依赖。

2.分析

探索编译参数

既然是编译后出现问题,那么想当然是编译的参数设置有问题。查看了build setting,build phases发现都没有设置UniformTypeIdentifiers的依赖。打开编译log搜索UniformTypeIdentifiers关键字,发现使用的是-framework进行的链接。

image.png 很显然这里是使用了strong的方式去依赖,如果改成-weak_framework方式,他就是weak了,但是这里并没有改动,说明这不是根本原因。肯定是某个地方强依赖了符号导致的。

探索符号依赖

进入对应的编译产物目录,找到对应的.o文件,通过nm命令查看对应的符号,发现对于UTType的依赖是强依赖,并不是weak。

这说明即使我们的代码使用了Available包裹,能够让他在低版本运行时不会因为方法找不到,但是也可能会导致因为低版本的系统库没有对应的动态库从而导致启动的时候就会crash。

3.解决方案

知道了问题然后解决方案就很多了,首先我们现在build phases中设置对于UniformTypeIdentifiers是weak,也就是可选依赖。

4.防劣化

那么怎么防止后续再出现同样的问题呢?

我们可以通过脚本在每次构建之后分析当前的动态库依赖,如果发现新增动态库或者动态库的依赖方式发生变化。则告警。

告警格式如下:

image.png

番外篇

上面解决了眼前的问题,但有个事一直没想通——为什么同样的代码放到 Demo 工程里就没事?

当我把同样的代码放到demo工程中的时候。表现却不一样。同样的代码,同样的最低系统支持,同样的编译参数。

image.png

图片 3编译参数

图片 4 产物依赖

图片 5 符号依赖

可以看到,符号依赖也是weak的。这就很奇怪了。按照上面的结论这里应该是strong才对。那么到底是为什么同样的代码在不同的工程中产生了不一样的结论呢?

抛开无关的条件之后,最基本的代码如下所示。

#import <UIKit/UIKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
 
void test(void) {
    if (@available(iOS 14.0, *)) {
        UTType *t = [UTType typeWithIdentifier:@"public.image"];
        (void)t;
    }
}

调用命令行编译

# 编译
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 "OBJC_CLASS.*UTType"
 

结果发现依然是weak

但是当我们改变一下import的顺序,发现结果不一样

代码如下:

#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <UIKit/UIKit.h>
 
void test(void) {
    if (@available(iOS 14.0, *)) {
        UTType *t = [UTType typeWithIdentifier:@"public.image"];
        (void)t;
    }
}
 

如果我们先导入UTI,然后再导入UIKit。他就会变成strong。

为了彻底搞清楚触发条件,设计了 6 个对照实验

实验环境:Apple Clang 17.0.0 (Xcode 26.0)、iPhoneOS 26.0 SDK、部署目标 iOS 12.0

场景代码编译选项结果
A先 UTI → 后 UIKit-fmodulesexternal Strong
B先 UIKit → 后 UTI-fmodulesweak external Weak
C只有 UTI-fmodulesweak external Weak
D先 UTI → 后 UIDocumentPicker-fmodulesexternal Strong
E先 UTI → 后 UIKit-fno-modulesweak external Weak
F源码 @class → UTI-fmodulesweak external Weak

有两点值得注意:

  • 只有场景 A 和 D 出问题,都是先 import UTI,后 import UIKit(或其子头文件)。场景 D 说明不一定要直接 import UIKit,只要 import 了属于 UIKit 模块的头文件(如 UIDocumentPickerViewController.h),整个 UIKit.pcm 就会被加载。
  • 关闭 Modules(场景 E)完全不受影响——同样的 import 顺序,不加 -fmodules 就是 Weak。说明问题跟头文件内容没关系,跟 Modules 的编译方式有关系。

复现脚本:

#!/bin/bash
set -e
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
TARGET="-target arm64-apple-ios12.0"

run_test() {
    local name="$1" src="$2" flags="$3"
    echo "$src" > "/tmp/test_${name}.m"
    rm -rf "/tmp/mc_${name}" && mkdir -p "/tmp/mc_${name}"
    xcrun clang -c "/tmp/test_${name}.m" -o "/tmp/test_${name}.o" \
        $TARGET -isysroot "$SDK" $flags \
        -fmodules-cache-path="/tmp/mc_${name}" 2>/dev/null
    local result=$(nm -m "/tmp/test_${name}.o" 2>/dev/null | grep "OBJC_CLASS.*UTType")
    local type="Strong ❌"
    echo "$result" | grep -q "weak" && type="Weak   ✅"
    printf "  %-45s %s\n" "$name" "$type"
}

# 场景 A: 先 UTI → 后 UIKit (Bug!)
run_test "A_uti_then_uikit" '
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <UIKit/UIKit.h>
void test(void) { if (@available(iOS 14.0, *)) { [UTType typeWithIdentifier:@"x"]; } }
' "-fmodules"

# 场景 B: 先 UIKit → 后 UTI (正确)
run_test "B_uikit_then_uti" '
#import <UIKit/UIKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
void test(void) { if (@available(iOS 14.0, *)) { [UTType typeWithIdentifier:@"x"]; } }
' "-fmodules"

# 场景 E: -fno-modules (正确)
run_test "E_no_modules" '
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <UIKit/UIKit.h>
void test(void) { if (@available(iOS 14.0, *)) { [UTType typeWithIdentifier:@"x"]; } }
' "-fno-modules"

深入 Clang:直击 Bug 根因

上面 Build Phases 和防劣化脚本都是工程上的补救。但根源在哪?import 顺序为什么会影响符号的链接属性?得往编译器里面看。

Redecl 链:Clang 怎么管理同名的多个声明

在 Clang 的 AST(抽象语法树,编译器内部用来表示代码的数据结构)中,同一个符号(类、函数、变量)的所有声明被串在一条环形链表上,叫做 redeclaration chain。

比如 UTType,它在两个地方被声明过:

  • UniformTypeIdentifiers 模块中:@interface UTType : NSObject,带了 API_AVAILABLE(ios(14.0), macos(11.0))
  • UIKit 模块中:@class UTType;,裸的前向声明,什么属性都没有

这两个声明在 Clang 看来是"同一个 UTType",被串到同一条链上。

源码在 clang/include/clang/AST/Redeclarable.h

template<typename decl_type>
class Redeclarable {
    DeclLink RedeclLink;  // 指向前一个节点(第一个节点例外,指向最后一个)
    decl_type *First;     // 指向链头
};

关键规则:

  • 链是环形的:first → latest → ... → first
  • 第一个节点的 RedeclLink 指向最后加入的节点(latest)
  • getMostRecentDecl() 返回的就是这个 latest 节点
  • 新节点通过 setPreviousDecl() 加入链尾,后加入者自动成为 latest

isWeakImported() 只查 latest 节点的属性:

// clang/lib/AST/DeclBase.cpp
bool Decl::isWeakImported() const {
    // ...
    for (const auto *A : getMostRecentDecl()->attrs()) {
        if (isa<WeakImportAttr>(A))
            return true;
        if (const auto *Availability = dyn_cast<AvailabilityAttr>(A)) {
            if (CheckAvailability(...) == AR_NotYetIntroduced)
                return true;
        }
    }
    return false;  // 什么都没找到 → Strong
}

逐场景还原:链上到底发生了什么

场景 A:先 import UTI,后 import UIKit → Strong

UTI.pcm(预编译模块缓存文件)先被加载,@interface UTType(带 ios 14.0 和 macos 11.0 两个属性)进入 redecl 链。然后 UIKit.pcm 被加载,@class UTType(无属性)加入链尾,成为 latest。

最终链: @interface (ios 14.0, macos 11.0) → @class (空)
                                                ↑ latest
isWeakImported() 查 latest → 空 → return false → Strong

场景 B:先 import UIKit,后 import UTI → Weak

@class UTType(空)先进链,@interface UTType(带属性)后进链,成为 latest。

最终链: @class (空) → @interface (ios 14.0, macos 11.0)
                         ↑ latest
isWeakImported() 查 latest → 找到 ios 14.012.0 < 14.0 → Weak

唯一的区别:谁最后进链。后进者就是 latest,isWeakImported() 只看它。

为什么不开 Modules 就没事

这是理解整个 Bug 最关键的一环。同样的 import 顺序,-fmodules 出问题,-fno-modules 正常。区别在哪?

不开 Modules(文本包含)

所有头文件当作文本展开到同一个翻译单元。编译器(具体说是 Sema,语义分析阶段)依次处理 @interface@class。处理到 @class 时发现已经有一个 @interface 了,于是调用 mergeDeclAttributes@interface 上的属性全部拷贝给 @class。最终 @class 节点也带着 ios 14.0 属性,查链尾当然能找到。

开 Modules(PCM 预编译)

UIKit.pcm 是独立编译好的二进制文件。在编译 UIKit.pcm 的时候,编译器只看到 @class UTType;,UniformTypeIdentifiers 模块还没加载——所以 @class 节点序列化进 PCM 时空空如也,永久固化。等你的 .m 文件加载 UIKit.pcm 时,反序列化出来的 @class 节点还是空的。而且因为是从 PCM 反序列化的,Sema 不会对它重新做属性合并。

PCM(Precompiled Module,预编译模块)本质上是把 AST 存成二进制文件,下次编译时直接从磁盘加载,跳过文本解析。但副作用就是——属性在序列化时什么样,加载时还是什么样。后续加载的其他模块不会回来补属性。

真正的根因:两次合并用了不同的迭代器

上面说到,Sema 的 mergeDeclAttributes 会把属性正确地传播给新声明。但 PCM 反序列化时跳过 Sema,走的是另一条路径——反序列化器自己的 mergeInheritableAttributes

对比两条路径:

源码编译(走 Sema):
  mergeDeclAttributes
    → specific_attrs<InheritableAttr>()    // 遍历所有属性
    → 逐个传给 mergeAndInferAvailabilityAttr
    → ios ✅  macos ✅  全部传播

跨模块(走 PCM 反序列化):
  mergeInheritableAttributes
    → getAttr<AvailabilityAttr>()          // 只取第一个
    → 只拿到 macos,ios 被跳过 ❌

getAttr<T>() 的语义是"返回第一个匹配的属性"。当 @interface 上有 availability(macos, introduced=11.0)availability(ios, introduced=14.0) 两个属性时,它只返回 macos。ios 属性被静默丢弃。

而 Sema 路径用的 specific_attrs<T>() 会遍历所有同类型属性。同样的数据,两条路径拿到了不一样的结果。

UTType 的 API_AVAILABLE(ios(14.0), macos(11.0)) 恰好把 macos 写在前面——所以 getAttr 返回的是 macos。部署目标是 iOS 12.0,macos 的属性不匹配当前平台,ios 的属性又被丢了。isWeakImported() 查链尾的 @class,什么都找不到,返回 false → Strong。

Bug 代码clang/lib/Serialization/ASTReaderDecl.cpp

// 修复前:getAttr<T>() 只返回第一个匹配的属性
const auto *AA = Previous->getAttr<AvailabilityAttr>();
if (AA && !D->hasAttr<AvailabilityAttr>()) {
    NewAttr = AA->clone(Context);
    NewAttr->setInherited(true);
    D->addAttr(NewAttr);
    // 如果 Previous 上还有 availability(ios, introduced=14.0),
    // 它被跳过了——因为 getAttr 只返回了第一个 (macOS)。
}

修复(LLVM commit c39d655):

// 修复后:specific_attrs<T>() 遍历所有同类型属性
if (!D->hasAttr<AvailabilityAttr>()) {
    for (const auto *AA : Previous->specific_attrs<AvailabilityAttr>()) {
        NewAttr = AA->clone(Context);
        NewAttr->setInherited(true);
        D->addAttr(NewAttr);
    }
}

specific_attrs<T>() 遍历 Previous 上所有 AvailabilityAttr(macos、ios 等等),逐个 clone 到新声明上。这样 @class 拿到完整的属性集合,isWeakImported() 查链尾时就能找到 ios 属性了。

这个修复已经合入了 LLVM main 分支(2026 年 5 月),等 Apple 集成到未来的 Xcode 版本,编译器层面就彻底不会有这个问题了。