普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月26日掘金 iOS

使用Wireshark进行TCP数据包抓包分析:三次握手与四次挥手详解

2026年3月26日 18:11

wireshark抓包分析TCP数据包

除了Wireshark,Sniffmaster作为一款全平台抓包工具,支持HTTPS、TCP和UDP协议,可在iOS、Android、Mac、Windows设备上实现无需代理、越狱或root的抓包操作,特别适合移动端和跨平台网络分析。

1、直接从TCP的 三次握手 开始说起

三次握手就是客户与服务器建立连接的过程

  • 客户向服务器发送SYN(SEQ=x)报文,然后就会进入SYN_SEND状态
  • 服务器收到SYN报文之后,回应一个SYN(SEQ=y)ACK(ACK=x+1)报文,然后就会进入SYN_RECV状态
  • 客户收到服务器的SYN报文,回应一个ACK(ACK=y+1)报文,然后就会进入Established状态

举例时间到!我们把客户端比作男生,服务器比作女生

第一次握手就像是男生对女生的告白:我喜欢你我们在一起吧。(之后,男孩就要等待女孩的回复,因为要确定女孩听到他说的话)

第二次握手则是女生的回应:好呀好呀。(之后,女孩也要等待,因为要确定男孩听到她的答复)

第三次握手就是男生的回应:真好,我们去吃火锅吧~。(此时,两人都确定对方收到了消息,关系成功建立)

也就是客户端和服务器数据的传输

接下来,我们抓包分析一下三次握手建立的过程

第一次握手:我向服务器发送了SYN,并设置Seq=0(x),请求与服务器建立连接

第二次握手:服务器向我回应了SYN,并设置Seq=0(y),ACK=1(x+1)

第三次握手:我收到服务器的SYN报文,回应一个ACK=1(y+1)

2、再接着说说四次挥手

四次挥手就是客户与服务器断开连接的过程

  • 客户发送一个FIN,断开与服务器的连接
  • 服务器收到FIN,回应一个ACK,确认序号为收到的序号加1
  • 服务器关闭客户端的连接,并发送一个FIN
  • 客户回发ACK确认,并将确认序号设置为收到序号加1

又到了举例时间!我们同样把客户端比作男生,服务器比作女生

第一次挥手:随着时间的流逝,女生变了,于是男生给女生发了分手短信,然后等待女生的回复

第二次挥手:女生听到后,伤心欲绝,就告诉男生:分手就分手,我把你的东西收拾收拾都还给你。男生就知道了女生同意了分手,于是等待女生把东西收拾好交还给他

第三次挥手:女生把男生的东西都收拾好,给男生发了第二条短信让他来取

第四次挥手:男生收到后,在回复最后一条短信,我知道了,我现在去取。于是关系断了

也就是客户端和服务器的连接中断

接下来,抓包看一下四次挥手的过程

第一次挥手:我向服务器发送FIN,Seq=3092,Ack=183

第二次挥手:服务器回发了ACK,Seq=183,Ack=3093

第三次挥手:服务器发送FIN,Seq=183,Ack=3093

第四次挥手:我向服务器回复了ACK,Seq=3093,Ack=184

3、TCP报文段格式分析

源端口和目的端口: 各占16位,这两个字段分别填入发送该报文段应用程序的源端口号和接收该报文段的应用程序的目的端口号

序列号: 占32位,TCP连接中传送的数据流中的每一个字节都编上一个序号,序号字段的值则指的是本报文段所发送的数据的第一个字节的序号

确认号: 占32位,表示期望收到对方下一个报文段的第一数据字节的序号。

数据偏移: 占4位,又称首部长度。指出首部的长度,即数据离开报文段开始的偏移量。

保留: 占6位,留待后用,目前置为0

标志: 占6位,又称控制字段,各位都有特定意义

  • 紧急URG,表示本报文数据的紧急程度,URG=1表示本报文具有高优先级
  • 确认ACK,ACK=1时,确认号字段才有意义
  • 推送PSH,PSH=1时,表示请求接收端TCP将本报文段立即送往其应用层
  • 复位RST,RST=1时,表示TCP连接中出现了严重错误,必须释放传输连接,而后在重建
  • 同步SYN,该位在连接建立时使用,起着序号同步的作用
  • 终止FIN,用来释放一个链接

窗口: 占16位,该字段用于流控制

校验和: 占16位,该字段的校验范围是整个报文段(包括首部和数据)

紧急指针: 占16位,当URG=1时有意义,指出紧急数据的末尾在报文段中的位置,使得接收端能知道紧急数据的字节数

选项与填充: 最长可达40B

Flutter iOS 包破解风险处理 可读信息抹除

2026年3月26日 17:50

Flutter 项目上线 iOS 后,如果有人拿到 IPA,第一步有可能不是反编译,而是直接解包。解压之后,目录结构非常清晰:Dart 代码、资源文件、插件模块都在不同位置。只要把这些信息拼起来,就能还原出应用的大致逻辑。

在一个包含会员系统和动态配置的 Flutter 项目中,我们专门做过一次抗破解处理。


先把 Flutter IPA 拆开看

构建完成 IPA 后,直接解压:

unzip Runner.ipa

进入目录:

Payload/Runner.app

可以看到几个关键内容:

App.framework
flutter_assets/
Frameworks/

进入 flutter_assets

assets/
isolate_snapshot_data
kernel_blob.bin

其中:

  • kernel_blob.bin:Dart 编译产物
  • assets/:资源文件
  • App.framework:部分逻辑代码

先处理 Dart 层(但不要停在这里)

Flutter 提供了混淆选项:

flutter build ios --obfuscate --split-debug-info=./symbols

执行后:

  • Dart 符号被替换
  • 生成符号映射文件

但这一步完成后,如果你再解包 IPA,会发现:

  • 资源名称仍然清晰
  • JS / JSON 可读
  • iOS 原生符号仍然存在

也就是说,这一步只是处理了 Dart 层。


处理 Flutter 资源目录(重点)

进入 flutter_assets/assets,如果看到类似:

images/vip_banner.png
config/payment.json
html/activity.html

这些名称已经足够说明业务结构。

我们做的处理是:不改 Flutter 工程,而是在 IPA 层统一修改

使用 Ipa Guard:

  • 导入 IPA
  • 切换到资源模块
  • 勾选图片、JSON、HTML、JS

资源混淆

执行后:

vip_banner.png → a8d3k.png
payment.json → x92ks.json

把 JS / HTML 再压一遍

如果 Flutter 中嵌入了 H5 页面(WebView),这些文件仍然是可读的。

在构建阶段或解包后处理:

terser main.js -o main.min.js

或者:

uglifyjs page.js -o page.min.js

处理后再放回 IPA,再用 Ipa Guard 改名。

这样做的结果是:

  • 内容压缩
  • 文件名无意义
  • 路径不可读

处理 iOS 原生层(很多人忽略)

Flutter 并不完全是 Dart,还包含:

  • 插件代码(Swift / OC)
  • 原生桥接层
  • SDK 逻辑

这些内容在 IPA 中属于 Mach-O 二进制。

检查一下:

strings AppBinary | grep Manager

如果看到:

FlutterPaymentManager
UserAuthHandler

说明原生层完全可读。


用 Ipa Guard 做二进制混淆

在代码模块中:

  • 选择 Swift 类
  • 选择 OC 方法
  • 勾选关键符号

代码混淆

执行后:

FlutterPaymentManager → k39sd2

再次查看:

strings AppBinary | grep Payment

已经找不到原始名称。


修改资源 MD5(解决“复用识别”问题)

如果多个应用使用同一套 UI 资源,即使改名也可能被识别。

Ipa Guard 提供 MD5 修改功能:

  • 图片内容不变
  • 文件指纹改变

md5修改

验证:

md5 vip_banner.png

处理前后不同。

这一步更多是避免资源被简单比对。


删掉那些“多余信息”

Flutter 构建过程中,有时会带入调试信息。

可以检查:

strings AppBinary | grep Flutter

如果输出包含日志或调试字段,可以在 IPA 处理阶段清理。

Ipa Guard 支持删除部分调试信息。


签名并直接安装测试

所有修改完成后,必须重新签名。

可以使用:

kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

或者直接在 Ipa Guard 中配置证书。

设备连接后可以直接安装。 重签名


测试关注点(Flutter 特有)

Flutter 项目测试时,需要特别看:

  • 页面渲染是否正常
  • Dart 调用是否异常
  • 插件是否还能调用
  • WebView 是否加载成功

如果某些页面加载失败,基本可以定位到资源路径被误处理。


Flutter iOS 包的破解入口并不只有 Dart 代码。资源目录、JS 文件、原生模块符号,这些地方同样可以被利用。单一手段很难覆盖所有暴露点。

在实际项目中,通过 Flutter 构建参数处理 Dart 层,再结合 Ipa Guard 对 IPA 进行资源混淆、二进制符号处理和 MD5 修改,可以在不侵入项目结构的情况下完成一轮补强。

参考链接:ipaguard.com/blog/159

ObservableObject @Published @ObservedObject那些事

2026年3月26日 16:27

先理解这三个为什么要一起讲

它们是一套组合拳,缺一不可:

角色 是什么
ObservableObject 一个协议,贴在 class 上,宣告"我是可被观察的数据源"
@Published 一个 Property Wrapper,贴在属性上,宣告"这个属性变化时要通知订阅者"
@ObservedObject 一个 Property Wrapper,贴在 View 的属性上,宣告"我订阅这个数据源,它变化我就刷新"

为什么需要这套东西?@State 不够用吗?

@State 适合简单的值类型,但现实中你的数据模型往往是一个 class,有很多属性和方法,且需要被多个平级 View 共享

// 一个用户信息模型,多个页面都要用
class UserModel {
   var name: String = "Tom"
   var age: Int = 18
   var score: Int = 0
   // ... 还有很多方法
}

把这个 class 塞进 @State 是行不通的——@State 是为值类型设计的,对 class 的引用地址变化不敏感,属性改了 UI 也不会刷新。


三件套的用法

// 第一步:让你的 class 遵守 ObservableObject 协议
class UserModel: ObservableObject {
   // 第二步:在需要触发 UI 刷新的属性上加 @Published
   @Published var name: String = "Tom"
   @Published var score: Int = 0
   var internalCache: String = ""  // 不加 @Published,改它不会刷新 UI
}

// 第三步:在 View 里用 @ObservedObject 订阅这个模型
struct ProfileView: View {
   @ObservedObject var user: UserModel

   var body: some View {
       VStack {
           Text(user.name)
           Text("\(user.score)")
           Button("加分") {
               user.score += 1   // 改 @Published 属性 → 触发 UI 刷新
           }
       }
   }
}

// 使用:顶层 View 用 @StateObject 持有并创建模型
struct ContentView: View {
   @StateObject var user = UserModel()

   var body: some View {
       ProfileView(user: user)
   }
}

三件套的本质

@Published 本质上是:

@propertyWrapper
public struct Published<Value> {
   // 每次 wrappedValue 被 set,就通过 objectWillChange 发出通知
   public var wrappedValue: Value
   // $score 拿到的是一个 Combine Publisher,可以接链式操作
   public var projectedValue: Publisher
}

ObservableObject 协议本质上是:

public protocol ObservableObject: AnyObject {
   // 编译器会自动合成这个,你的 @Published 属性改变时,它会发出信号
   var objectWillChange: ObservableObjectPublisher { get }
}

@ObservedObject 本质上是:View 订阅了 user.objectWillChange,只要它 emit,SwiftUI 就重新计算这个 View 的 body。

整个流程: user.score += 1@Published 的 setter 触发 → user.objectWillChange.send() → 订阅了它的 @ObservedObject 感知到 → SwiftUI 重新渲染对应的 View


@ObservedObject vs @StateObject

这是一个非常容易踩的坑:

@ObservedObject @StateObject
数据归属 不拥有,由外部传入 拥有,由这个 View 创建和持有
生命周期 跟随外部,不负责销毁 跟随 View,View 消失时销毁
典型场景 子 View 接收父 View 传来的模型 根 View 或顶层 View 创建模型

经验法则:谁创建,谁用 @StateObject;谁接收,谁用 @ObservedObject


使用时需要关心的问题

  1. 只有 class 能用ObservableObjectAnyObject 的子协议,struct 和 enum 无法遵守,这套机制天生是为引用类型设计的。

  2. @Published 要精准:不是所有属性都需要 @Published,只给真正需要驱动 UI 的属性加,滥加会导致不必要的 View 重渲染,影响性能。

  3. objectWillChange 是"将要改变":SwiftUI 在属性改变之前就会收到通知,你通常不需要手动调用它,但在某些手动控制的场景可以用 objectWillChange.send() 主动触发刷新。

@Binding 的那些事

2026年3月26日 16:25

先理解 @Binding 解决什么问题

@State 的时候,状态归属于某一个 View。但子 View 怎么修改父 View 的状态?

struct ParentView: View {
    @State var isOn: Bool = false

    var body: some View {
        ToggleView(isOn: isOn) // ❌ 子 View 拿到的只是一个值的拷贝
    }
}

你把 isOn 传给子 View,子 View 改了它自己的拷贝,父 View 毫不知情,UI 也不会更新。


@Binding 就是用来解决这个问题的

@Binding 不是一份数据的拷贝,而是一条双向通道,指向原始数据的存储位置。 读它,读的是原始值;写它,写的是原始存储,父 View 会同步感知并刷新。

// 父 View:状态归属于这里
struct ParentView: View {
    @State var isOn: Bool = false

    var body: some View {
        // 用 $ 前缀把 @State 转成 Binding 传下去
        ToggleView(isOn: $isOn)
    }
}

// 子 View:不拥有状态,只拿到一条"通道"
struct ToggleView: View {
    @Binding var isOn: Bool  // 声明为 Binding,表示"我不拥有这个数据"

    var body: some View {
        Button("切换") {
            isOn.toggle()   // 写的是父 View 里的原始 @State,触发父 View 刷新
        }
    }
}

@Binding 的本质

@propertyWrapper
public struct Binding<Value> {
    // 你平时用 isOn 读写的就是这个
    public var wrappedValue: Value { get nonmutating set }

    // 你用 $isOn 拿到的还是 Binding 自身,可以继续往下传
    public var projectedValue: Binding<Value> { get }
}

@Binding 内部存的不是值本身,而是一对 getter + setter 闭包,分别指向上层 @State(或其他数据源)的读写操作。所以写 isOn = true 时,实际上是调用了那个 setter 闭包,最终改变的是父 View 的 @State


使用 @Binding 时需要关心的问题

  1. 数据归属权问题@Binding 的原则是"我不拥有数据,我只是一个读写通道"。如果一个 View 需要拥有状态,用 @State;如果只是借用和修改上层的状态,用 @Binding

  2. 单向来源原则(Single Source of Truth):一条 @Binding 链条最终必须溯源到某个真实的数据存储(比如 @State@StateObject 中的属性),不要出现 Binding 套 Binding 套 Binding 的迷宫,链条越短越清晰。

  3. $ 符号的含义$isOn 拿到的是 projectedValue,对 @State 来说它是一个 Binding<Bool>,这就是为什么父 View 传 $isOn,而子 View 声明 @Binding var isOn,类型是对得上的。

  4. 不要在 body 外部调用:和 @State 一样,对 @Binding 属性的读写应发生在 bodybody 调用的方法中,以确保 SwiftUI 能正确追踪依赖。

Xcode 26.4 AFNetworking 私有头文件报错处理记录

作者 Dante丶
2026年3月26日 10:22

问题现象

在当前工程执行 Pods 编译时,AFNetworking 4.0.1 出现以下报错:

/Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m:26:9 Use of private header from outside its module: 'netinet6/in6.h'
/Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m:32:9 Use of private header from outside its module: 'netinet6/in6.h'

根因分析

AFNetworking 4.0.1 在以下源码中直接引用了私有头文件 #import <netinet6/in6.h>

  • Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m
  • Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m

新版 Xcode / Clang 会将该引用识别为“模块外私有头文件访问”,从而在编译阶段直接报错。

这里并不需要通过放宽全局编译限制来绕过问题。对应文件实际依赖的 IPv6 结构体和常量可由公开头 #import <netinet/in.h> 提供,因此直接移除 #import <netinet6/in6.h> 即可。

处理方案

为了避免每次 pod install 后手动修改 Pods 目录,本次将修复逻辑固化到 Podfilepost_install 阶段。

处理原则如下:

  1. 扫描 AFNetworking 目录下所有 .h/.m 文件。
  2. 查找 #import <netinet6/in6.h>
  3. 如果存在,则自动删除该导入。
  4. 由于 Pods 内目标文件可能是只读权限,写入前临时补充写权限,写入后恢复原权限。

最终 Podfile 补丁

本次在 Podfile 中新增以下逻辑:

def patch_afnetworking_private_header(installer)
  # 扫描并移除 AFNetworking 对私有 IPv6 头文件的直接引用,兼容新版 Xcode 的模块校验。
  afnetworking_dir = File.join(installer.sandbox.pod_dir('AFNetworking'), 'AFNetworking')
  return unless Dir.exist?(afnetworking_dir)

  private_header_import = '#import <netinet6/in6.h>'
  Dir.glob(File.join(afnetworking_dir, '**', '*.{h,m}')).each do |file_path|
    next unless mcs_file_exists(file_path)

    file_content = File.read(file_path)
    next unless file_content.include?(private_header_import)

    original_mode = File.stat(file_path).mode
    File.chmod(original_mode | 0o200, file_path)
    File.write(file_path, file_content.gsub(private_header_import, ''))
    File.chmod(original_mode, file_path)
    puts "patched AFNetworking private header import: #{File.basename(file_path)}"
  end
end

post_install do |installer|
  patch_afnetworking_private_header(installer)

  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0'
      config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
      config.build_settings['CLANG_ENABLE_OBJC_WEAK'] = 'YES'
      config.build_settings['SWIFT_VERSION'] = '5.0'
    end
  end
end

实际修复结果

执行 pod install 后,以下两个文件中的私有头导入已被移除:

  • Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m
  • Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m

修复后的关键导入区如下:

#import <netinet/in.h>

#import <arpa/inet.h>
#import <ifaddrs.h>
#import <netdb.h>

验证过程

1. 安装 Pods

执行:

pod install

结果:

  • Pod installation complete!
  • post_install 补丁正常执行。

2. 检查私有头是否已全部移除

执行:

rg -n "netinet6/in6.h" Pods/AFNetworking/AFNetworking

结果:

  • 无输出。
  • 说明 AFNetworking 目录下已不存在该私有头引用。

3. 单独编译 AFNetworking Target

执行:

xcodebuild -project Pods/Pods.xcodeproj \
  -scheme AFNetworking \
  -configuration Debug \
  -sdk iphonesimulator \
  -derivedDataPath /tmp/TXLAPP_IOS_Pods_DerivedData \
  CODE_SIGNING_ALLOWED=NO build

结果:

** BUILD SUCCEEDED **

说明:

  • AFNetworking 已成功完成真实编译。
  • AFHTTPSessionManager.mAFNetworkReachabilityManager.m 均已通过编译。
  • 本次处理的私有头报错已被清除。

影响文件

  • Podfile
  • Podfile.lock
  • Pods/Manifest.lock
  • Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m
  • Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m

注意事项

  1. 这次修复是对第三方库源码的安装阶段补丁,不建议直接长期手改 Pods 文件后不保留 Podfile 逻辑。
  2. 后续只要重新执行 pod install,该补丁就会再次自动生效。
  3. 当前 Podfile 里仍将 Pods 的 IPHONEOS_DEPLOYMENT_TARGET 设置为 9.0,在新 Xcode 下会出现最低部署版本警告;这不是本次私有头报错的根因,但后续可以单独再整理处理。

结论

本次问题的本质是旧版 AFNetworking 源码中引用了私有系统头文件,导致新编译环境不再允许通过。采用 Podfile post_install 自动移除 netinet6/in6.h 引用的方式,可以稳定修复该问题,并保证后续重新安装 Pods 时无需重复人工处理。

@state的一些琐事

2026年3月25日 20:49

先理解 Property Wrapper 是什么

@propertyWrapper 就是让你可以自定义 @ 修饰符的机制。 @State@Binding 这些不是Swift内置的魔法,它们本质上就是普通的 struct,只不过被 @propertyWrapper 修饰了,所以才能用 @ 语法来用。 能理解吗?是不是还是很难理解,没事我写一个例子你就能理解了

假设你有一个属性,每次读取它都想打印一条日志:
var age: Int = 18
var age: Int = 18 { 
    didSet { print("age 变了,新值是 \(age)") }
}
但如果你有 100 个属性都需要这个功能呢?你要写 100 次 `didSet`?

Property Wrapper 就是用来解决这个问题的

你可以把"通用的包装逻辑"封装起来,然后像帖标签一样贴到任何属性上。

// 第一步:定义一个 Property Wrapper
@propertyWrapper
struct Logged {
    private var value: Int
    // initialValue 参数后面可以跟很多参数,自定义
    init(initialValue: Int) {
        self.value = initialValue
    }
    
    var wrappedValue: Int {
    //这里的get 和set 我们可以自定义任何我们想要的操作,比如有多个参数我们可以把这些参数拼接起来返回等等
        get { value }
        set {
            print("值变了,新值是 \(newValue)")  // 通用逻辑写在这里
            value = newValue
        }
    }
}

// 第二步:像贴标签一样使用它
@Logged var age = 18
@Logged var score = 100

// 现在 age 和 score 改变时,都会自动打印日志
age = 20   // 打印:值变了,新值是 20
score = 99 // 打印:值变了,新值是 99

所以 @propertyWrapper 本质上就是

把"对属性的操作逻辑"打包成一个 struct,然后用 @ 语法贴到属性上,让这个属性自动拥有那些逻辑。

回到 @State

@State 干的事情无非就是:

@propertyWrapper
public struct State<Value> {  
    // 1. 让你能直接赋初始值
    public init(initialValue value: Value)   
    // 2. 你平时用 brain 读写的就是这个 (这里set 之后苹果偷偷的去给你刷新了UI)
    public var wrappedValue: Value { get nonmutating set }    
    // 3. 你用 $brain 拿到的就是这个(一个 Binding)
    public var projectedValue: Binding<Value> { get }
}

@State 非常适合 struct 或者 enum 这样的值类型,它可以自动为我们完成从状态 到 UI 更新等一系列操作。但是它本身也有一些限制,我们在使用 @State 之前,对 于需要传递的状态,最好关心和审视下面这两个问题:

  1. 这个状态是属于单个 View 及其子层级,还是需要在平行的部件之间传递和使 用?@State 可以依靠 SwiftUI 框架完成 View 的自动订阅和刷新,但这是有 条件的:对于 @State 修饰的属性的访问,只能发生在 body 或者 body 所调 用的方法中。你不能在外部改变 @State 的值,它的所有相关操作和状态改变 都应该是和当前 View 挂钩的。如果你需要在多个 View 中共享数据,@State 可能不是很好的选择;如果还需要在 View 外部操作数据,那么 @State 甚至 就不是可选项了。
  2. 状态对应的数据结构是否足够简单?对于像是单个的 Bool 或者 String, @State 可以迅速对应。含有少数几个成员变量的值类型,也许使用 @State 也还不错。但是对于更复杂的情况,例如含有很多属性和方法的类型,可能其 中只有很少几个属性需要触发 UI 更新,也可能各个属性之间彼此有关联,那 么我们应该选择引用类型和更灵活的可自定义方式。

Xcode MCP Server 完全指南:从智能配置到编程控制

2026年3月25日 19:23

目录概要


为什么需要 MCP?

如果你用过 Cursor 或 Claude 写代码,一定有过这样的体验:AI 能侃侃而谈生成代码,但真要它帮你跑个测试、修个编译错误,它就傻眼了——因为它"看不见"你的 Xcode 工程,也"摸不着"编译器。

Model Context Protocol (MCP) 就是来解决这个问题的。它像一根 USB 线,把 AI 助手和 Xcode 连接起来,让 AI 可以直接读取文件、运行构建、执行测试,甚至渲染 SwiftUI 预览。换句话说,MCP 让 Xcode 变成了一个可编程的"智能引擎"。

本文将从系统配置工具实战,带你完整掌握 Xcode MCP Server 的使用。文章后半部分,我还会穿插一些编译器演进的历史故事——毕竟,理解了"从哪里来",才更能明白"往哪里去"。

graph LR
    subgraph AI["🤖 AI 助手"]
        A1[Cursor]
        A2[Claude CLI]
        A3[Codex]
    end

    subgraph MCP["🔌 MCP 桥梁"]
        B[mcpbridge]
    end

    subgraph Xcode["🛠️ Xcode 引擎"]
        C1[📄 文件读写]
        C2[🔨 编译构建]
        C3[✅ 测试运行]
        C4[👁️ UI 预览]
        C5[🔍 代码搜索]
    end

    A1 --> B
    A2 --> B
    A3 --> B
    B --> C1
    B --> C2
    B --> C3
    B --> C4
    B --> C5

    style AI fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    style MCP fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
    style Xcode fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

一、系统与环境前置要求

要玩转 Xcode Intelligence 和 MCP,硬件和系统是第一道门槛。Apple 这次把门槛卡得很死:

  • macOS:必须运行 macOS Sequoia 15.2 或更高版本。别问为什么,问就是 Apple Intelligence 强依赖于端侧 NPU 算力。
  • 硬件:必须使用 Apple Silicon (M1 及后续芯片) 的 Mac。Intel 用户暂时只能眼馋。
  • XcodeXcode 26.3 或更高版本。这个版本内置了 mcpbridge 工具,也就是 MCP 的服务端。

另外,需要在 系统设置 > Apple Intelligence & Siri 中确保开关已开启。Xcode 的智能功能是 Apple Intelligence 的一部分,系统层面不开,Xcode 里也开不了。

graph TD
    subgraph 前置检查清单
        direction TB
        H["💻 硬件检查"] --> H1{"Apple Silicon?<br/>M1 / M2 / M3 / M4"}
        H1 -->|✅ 是| S["🖥️ 系统检查"]
        H1 -->|❌ Intel| FAIL["⛔ 不支持"]
        S --> S1{"macOS Sequoia 15.2+?"}
        S1 -->|✅ 是| X["📱 Xcode 检查"]
        S1 -->|❌ 版本过低| UPDATE1["⬆️ 升级 macOS"]
        X --> X1{"Xcode 26.3+?"}
        X1 -->|✅ 是| AI["🧠 Apple Intelligence"]
        X1 -->|❌ 版本过低| UPDATE2["⬆️ 升级 Xcode"]
        AI --> AI1{"系统设置中<br/>Apple Intelligence 已开启?"}
        AI1 -->|✅ 是| OK["🎉 环境就绪!"]
        AI1 -->|❌ 未开启| ENABLE["⚙️ 前往设置开启"]
    end

    style FAIL fill:#ffcdd2,stroke:#c62828
    style OK fill:#c8e6c9,stroke:#2e7d32
    style UPDATE1 fill:#fff9c4,stroke:#f57f17
    style UPDATE2 fill:#fff9c4,stroke:#f57f17
    style ENABLE fill:#fff9c4,stroke:#f57f17

二、开启 Xcode Intelligence:模型提供商配置

Xcode 26.3 的智能功能采用了插件化的模型提供商架构(Provider Architecture),你可以同时接入多个模型源,根据任务需求灵活切换。

在 Xcode 中打开 Settings (⌘,) > Intelligence,你会看到三个主要提供商:

A. Apple (本地/云端混合)

  • 默认集成,无需额外配置。
  • 提供基础的代码补全(Predictive Code Completion)和轻量级重构建议,针对 Swift 和 Apple SDK 有优化。

B. ChatGPT (OpenAI)

  • 点击 ChatGPT in Xcode 下的 Turn On。
  • 绑定 ChatGPT 账号(支持 Free 和 Plus)。
  • 在 Project Editor 中,可以为特定 Target 选择模型的 Reasoning Level(推理等级),控制生成代码的深度。

C. Claude (Anthropic)

  • 点击 Claude 下的 Sign In 授权。
  • 如果安装了 Claude Agent 组件,可以在此配置其构建和测试权限。

小贴士:Xcode 内置的 Agent 配置目录位于 ~/Library/Developer/Xcode/CodingAssistant/,与标准的 .codex.claude 配置独立,所以不会干扰你现有的命令行工具配置。

D. 关键一步:启用 Xcode Tools MCP Server

在 Intelligence 设置页面的最底部,找到 Model Context Protocol 区域,将 Xcode Tools 的开关拨至 ON

技术原理:开启后,Xcode 主进程会启动一个名为 mcpbridge 的 XPC 服务,监听来自外部工具的连接请求。当外部工具首次尝试连接时,Xcode 会弹出权限确认对话框——务必点击 Allow,否则一切免谈。

graph TB
    subgraph XcodeSettings["⚙️ Xcode Settings > Intelligence"]
        direction TB
        P1["🍎 Apple<br/>━━━━━━━━━━<br/>本地 + 云端混合<br/>代码补全 / 重构建议<br/>🟢 默认开启"]
        P2["🤖 ChatGPT (OpenAI)<br/>━━━━━━━━━━<br/>Turn On → 绑定账号<br/>支持 Free / Plus<br/>可调 Reasoning Level"]
        P3["🟣 Claude (Anthropic)<br/>━━━━━━━━━━<br/>Sign In → 授权<br/>Claude Agent 构建权限<br/>独立配置目录"]
        P4["🔌 MCP Server<br/>━━━━━━━━━━<br/>Xcode Tools → ON<br/>启动 mcpbridge XPC<br/>⚠️ 首次连接需 Allow"]
    end

    P1 --- P2
    P2 --- P3
    P3 --- P4

    P4 -->|开启后| XPC["mcpbridge<br/>XPC 服务启动"]
    XPC -->|外部工具连接| ALLOW{"权限弹窗<br/>Allow?"}
    ALLOW -->|✅ Allow| READY["🎉 MCP 就绪"]
    ALLOW -->|❌ Deny| BLOCKED["⛔ 连接被拒"]

    style P4 fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
    style READY fill:#c8e6c9,stroke:#2e7d32
    style BLOCKED fill:#ffcdd2,stroke:#c62828

三、MCP 架构揭秘:桥梁是如何搭建的

MCP 是一个标准化的协议,旨在解决 AI 模型与本地开发环境"隔离"的问题。它的架构可以用以下流程图清晰地表示:

graph TD
    subgraph Client["🌐 MCP 客户端"]
        A1["Claude Code<br/>(CLI)"]
        A2["Cursor<br/>(IDE)"]
        A3["Codex<br/>(CLI)"]
    end

    subgraph Bridge["🌉 MCP Bridge 层"]
        B["xcrun mcpbridge<br/>━━━━━━━━━━━━━<br/>协议: stdio JSON-RPC<br/>角色: 翻译官"]
    end

    subgraph XcodeProcess["🏗️ Xcode 主进程"]
        C["Xcode App<br/>━━━━━━━━━━━━━<br/>通信: XPC"]
        C --> D["🔨 Build System<br/>增量编译 / 错误诊断"]
        C --> E["📝 Source Editor<br/>文件读写 / 代码分析"]
        C --> F["🧪 XCTest Runner<br/>测试运行 / 结果收集"]
        C --> G["👁️ Preview Engine<br/>SwiftUI 预览渲染"]
        C --> H["📚 Documentation<br/>Apple 文档搜索"]
    end

    A1 -->|"stdio<br/>JSON-RPC"| B
    A2 -->|"stdio<br/>JSON-RPC"| B
    A3 -->|"stdio<br/>JSON-RPC"| B
    B -->|"XPC<br/>进程间通信"| C

    style Client fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    style Bridge fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
    style XcodeProcess fill:#d1c4e9,stroke:#512da8,stroke-width:2px

关键路径解读

  • mcpbridge 是一个命令行工具,通过 xcrun mcpbridge 启动。
  • 它与 Xcode 主进程通过 XPC 通信,调用 Xcode 内部的构建、编辑、调试等 API。
  • 外部客户端(如 Cursor、Claude CLI)通过**标准输入输出(stdio)**与 mcpbridge 交互,协议基于 JSON-RPC
  • 简单来说,mcpbridge 就是那个"翻译官",把 AI 的意图翻译成 Xcode 能懂的操作。
sequenceDiagram
    participant AI as 🤖 AI 助手 (Cursor)
    participant MCP as 🌉 mcpbridge
    participant Xcode as 🛠️ Xcode

    AI->>MCP: JSON-RPC 请求<br/>"BuildProject"
    MCP->>Xcode: XPC 调用<br/>触发编译
    Xcode-->>Xcode: 执行增量构建...
    Xcode->>MCP: XPC 响应<br/>编译结果 + 错误列表
    MCP->>AI: JSON-RPC 响应<br/>结构化错误信息

    Note over AI: 解析错误,定位文件和行号

    AI->>MCP: JSON-RPC 请求<br/>"XcodeRead" 读取错误文件
    MCP->>Xcode: XPC 调用
    Xcode->>MCP: 文件内容
    MCP->>AI: 带行号的源码

    Note over AI: 分析问题,生成修复代码

    AI->>MCP: JSON-RPC 请求<br/>"XcodeUpdate" 修复代码
    MCP->>Xcode: XPC 调用
    Xcode->>MCP: 更新成功
    MCP->>AI: 确认响应

四、客户端接入:让 Cursor/Claude 操作 Xcode

场景 A:命令行工具 (Claude CLI / Codex)

Claude Code

claude mcp add --transport stdio xcode -- xcrun mcpbridge

Codex

codex mcp add xcode -- xcrun mcpbridge

场景 B:集成开发环境 (Cursor / Trae)

在编辑器的 MCP 配置文件中添加 Server 定义。

GUI 方式:进入 Settings > Features > MCP,点击 + Add New MCP Server。

Name: xcode
Transport: stdio
Command: xcrun mcpbridge

JSON 方式:修改配置文件 ~/.cursor/mcp.json~/.config/trae/mcp.json

{
  "mcpServers": {
    "xcode": {
      "command": "xcrun",
      "args": ["mcpbridge"]
    }
  }
}

注意mcpbridge 会自动检测当前运行的 Xcode 进程 ID(PID),一般无需手动指定环境变量。如果 Xcode 没打开,连接会失败——这是最常见的坑。

场景 C:项目级上下文提示

在项目根目录添加 AGENTS.mdCLAUDE.md 文件,里面可以写清楚:

  • 核心 Scheme 名称
  • 主要的 Test Plan
  • 特殊的构建脚本路径
  • 架构模式(MVVM/TCA 等)

MCP Client 会优先读取这些文件作为 System Prompt 的一部分,让 AI 更懂你的项目结构。

graph LR
    subgraph CLI["💻 命令行接入"]
        C1["claude mcp add<br/>--transport stdio<br/>xcode -- xcrun mcpbridge"]
        C2["codex mcp add<br/>xcode -- xcrun mcpbridge"]
    end

    subgraph IDE["🖥️ IDE 接入"]
        I1["Cursor<br/>~/.cursor/mcp.json"]
        I2["Trae<br/>~/.config/trae/mcp.json"]
    end

    subgraph Context["📋 项目上下文"]
        X1["AGENTS.md"]
        X2["CLAUDE.md"]
    end

    CLI --> MCP["🔌 mcpbridge"]
    IDE --> MCP
    Context -.->|"System Prompt"| MCP
    MCP --> Xcode["🛠️ Xcode"]

    style CLI fill:#e3f2fd,stroke:#1565c0
    style IDE fill:#f3e5f5,stroke:#7b1fa2
    style Context fill:#e8f5e9,stroke:#2e7d32
    style MCP fill:#fff9c4,stroke:#fbc02d,stroke-width:2px

五、MCP 工具集详解(20 个工具分类说明)

xcrun mcpbridge 暴露了 20 个核心工具,覆盖了从文件操作到测试运行的方方面面。下面按功能分类逐一说明。

graph TB
    subgraph Tools["🧰 MCP 工具集 - 20 个工具"]
        direction TB
        subgraph FileOps["📄 文件操作 (6)"]
            F1["XcodeRead"]
            F2["XcodeWrite"]
            F3["XcodeUpdate"]
            F4["XcodeMV"]
            F5["XcodeRM"]
            F6["XcodeMakeDir"]
        end
        subgraph SearchOps["🔍 代码搜索 (3)"]
            S1["XcodeGrep"]
            S2["XcodeGlob"]
            S3["XcodeLS"]
        end
        subgraph BuildOps["🔨 构建诊断 (4)"]
            B1["BuildProject"]
            B2["GetBuildLog"]
            B3["XcodeListNavigatorIssues"]
            B4["XcodeRefreshCodeIssuesInFile"]
        end
        subgraph TestOps["✅ 测试运行 (3)"]
            T1["GetTestList"]
            T2["RunSomeTests"]
            T3["RunAllTests"]
        end
        subgraph PreviewOps["👁️ 预览运行时 (2)"]
            P1["RenderPreview"]
            P2["ExecuteSnippet"]
        end
        subgraph DocOps["📚 文档窗口 (2)"]
            D1["DocumentationSearch"]
            D2["XcodeListWindows"]
        end
    end

    style FileOps fill:#e3f2fd,stroke:#1565c0
    style SearchOps fill:#fff3e0,stroke:#e65100
    style BuildOps fill:#fce4ec,stroke:#c62828
    style TestOps fill:#e8f5e9,stroke:#2e7d32
    style PreviewOps fill:#f3e5f5,stroke:#7b1fa2
    style DocOps fill:#efebe9,stroke:#4e342e

1. 文件操作类

工具 功能 关键参数
XcodeRead 读取文件内容,支持分页(limit/offset),最多 600 行 filePath, offset, limit
XcodeWrite 创建新文件或覆盖现有文件,自动添加到工程组 filePath, content
XcodeUpdate 增量编辑(基于字符串替换),比全量重写更省 token filePath, edits
XcodeMV 移动或重命名文件,保持工程结构一致性 sourcePath, destPath
XcodeRM 删除文件 filePath
XcodeMakeDir 创建目录 path

最佳实践:务必使用 XcodeRead 返回的行号作为参考,避免后续编辑时行号偏移。路径格式示例:MyProject/ViewControllers/MyViewController.swift

2. 代码搜索类

工具 功能 关键参数
XcodeGrep 在工程中搜索文本模式(支持正则) pattern, path, glob, type, outputMode
XcodeGlob 基于 glob 模式列出文件(如 **/*.swift pattern
XcodeLS 列出目录内容,类似 ls 命令 path

3. 构建与诊断类

工具 功能 关键返回值
BuildProject 触发当前 Scheme 的增量构建(阻塞调用) buildResult, errors[], elapsedTime
GetBuildLog 获取最近一次构建的详细日志 log
XcodeListNavigatorIssues 获取 Issue Navigator 中的实时问题(无需完整构建) issues 列表
XcodeRefreshCodeIssuesInFile 强制刷新并检索特定文件的编译器诊断 filePath 对应的诊断信息

4. 测试运行类

工具 功能 关键返回值
GetTestList 获取所有可用测试的层级结构(Test Plan → Class → Method) tests 层级列表
RunSomeTests 运行指定的测试用例 指定测试的结果
RunAllTests 运行当前 Scheme 中的所有测试 counts, results[](最多 100 条)

测试标识符示例MyProjectTests/UserProfileTests/testUserNameValidation

5. 预览与运行时

工具 功能 关键参数
RenderPreview 构建并渲染 SwiftUI 预览(#PreviewPreviewProvider),返回图片路径 sourceFilePath, timeout
ExecuteSnippet 在目标文件的上下文中动态执行 Swift 代码,类似 LLDB 的 expression 代码片段

6. 文档与窗口

工具 功能
DocumentationSearch 搜索 Xcode 内置的 Apple 开发文档和 WWDC 视频
XcodeListWindows 列出当前打开的 Xcode 窗口信息

注意:所有工具的参数中,tabIdentifier 通常可以省略,mcpbridge 会自动关联当前活跃的 Xcode 窗口。


六、实战场景与最佳实践

场景 1:修复编译错误

// 1. 构建并获取错误列表
BuildProject()

// 2. 读取有错误的文件
XcodeRead(filePath: "MyProject/ViewControllers/MyViewController.swift")

// 3. 修改文件
XcodeWrite(filePath: "MyProject/ViewControllers/MyViewController.swift", content: "修正后的代码...")

// 4. 再次构建验证
BuildProject()

场景 2:代码搜索与重构

// 搜索所有调用某个类的地方
XcodeGrep(pattern: "MyViewController", outputMode: "filesWithMatches")

// 逐个读取文件并修改
XcodeRead(filePath: "MyProject/ViewControllers/AnotherViewController.swift")
XcodeUpdate(...)

场景 3:测试驱动开发

// 获取测试列表
GetTestList()

// 运行指定测试
RunSomeTests(testIdentifiers: ["MyProjectTests/UserProfileTests/testUserInitialization"])

// 如果失败,修复后再运行

场景 4:UI 预览验证

// 修改 SwiftUI 代码后
XcodeWrite(filePath: "MyProject/Views/ProfileView.swift", content: "更新后的预览代码...")

// 渲染预览
RenderPreview(sourceFilePath: "MyProject/Views/ProfileView.swift")

最佳工作流建议

graph LR
    A["📖 读<br/>XcodeRead"] --> B["✏️ 写<br/>XcodeUpdate"]
    B --> C["🔨 验<br/>BuildProject"]
    C --> D{"编译通过?"}
    D -->|❌ 失败| A
    D -->|✅ 通过| E["🔍 搜<br/>XcodeGrep"]
    E --> F["🧪 测<br/>RunSomeTests"]
    F --> G{"测试通过?"}
    G -->|❌ 失败| A
    G -->|✅ 通过| H["👁️ 看<br/>RenderPreview"]
    H --> I["🎉 完成"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#fff3e0,stroke:#e65100
    style C fill:#fce4ec,stroke:#c62828
    style D fill:#f5f5f5,stroke:#616161
    style E fill:#f3e5f5,stroke:#7b1fa2
    style F fill:#e8f5e9,stroke:#2e7d32
    style G fill:#f5f5f5,stroke:#616161
    style H fill:#ede7f6,stroke:#311b92
    style I fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px

七、踩坑指南:解决 Cursor 兼容性问题

在 Xcode 26.3 (26.3 RC) 中,xcrun mcpbridge 返回的响应不完全符合 MCP 规范——缺少 structuredContent 字段,这会导致 Cursor 报错。解决办法是写一个 Python 包装脚本,在中间层添加缺失的字段。

graph LR
    subgraph Problem["❌ 问题"]
        P1["Cursor"] -->|"JSON-RPC"| P2["mcpbridge"]
        P2 -->|"响应缺少<br/>structuredContent"| P1
        P1 --> P3["⛔ 报错!"]
    end

    subgraph Solution["✅ 解决方案"]
        S1["Cursor"] -->|"JSON-RPC"| S2["🐍 Python Wrapper<br/>mcpbridge-wrapper"]
        S2 -->|"透传请求"| S3["mcpbridge"]
        S3 -->|"原始响应"| S2
        S2 -->|"注入<br/>structuredContent"| S1
        S1 --> S4["🎉 正常工作"]
    end

    style Problem fill:#ffebee,stroke:#c62828
    style Solution fill:#e8f5e9,stroke:#2e7d32
    style P3 fill:#ffcdd2,stroke:#c62828
    style S4 fill:#c8e6c9,stroke:#2e7d32

步骤 1:创建脚本 ~/bin/mcpbridge-wrapper(记得 chmod +x):

#!/usr/bin/env python3
"""
Wrapper for xcrun mcpbridge that adds structuredContent to responses.
"""
import sys, json, subprocess, threading

def process_response(line):
    try:
        data = json.loads(line)
        if isinstance(data, dict) and 'result' in data:
            result = data['result']
            if isinstance(result, dict):
                if 'content' in result and 'structuredContent' not in result:
                    content = result.get('content', [])
                    if isinstance(content, list) and len(content) > 0:
                        for item in content:
                            if isinstance(item, dict) and item.get('type') == 'text':
                                text = item.get('text', '')
                                try:
                                    result['structuredContent'] = json.loads(text)
                                except json.JSONDecodeError:
                                    result['structuredContent'] = {"text": text}
                                break
        return json.dumps(data)
    except json.JSONDecodeError:
        return line

def main():
    proc = subprocess.Popen(
        ['xcrun', 'mcpbridge'] + sys.argv[1:],
        stdin=subprocess.PIPE, stdout=subprocess.PIPE,
        stderr=sys.stderr, text=True, bufsize=1
    )

    def pipe_output(stdout):
        for line in stdout:
            print(process_response(line.strip()), flush=True)

    threading.Thread(target=pipe_output, args=(proc.stdout,), daemon=True).start()

    for line in sys.stdin:
        proc.stdin.write(line)
        proc.stdin.flush()

if __name__ == '__main__':
    main()

步骤 2:修改 ~/.cursor/mcp.json

{
  "mcpServers": {
    "xcode-tools": {
      "command": "/Users/YOUR_USERNAME/bin/mcpbridge-wrapper"
    }
  }
}

重启 Cursor,问题解决。


八、从 GCC 到 MCP:编译器与工具链的进化之路

写到这里,我突然想起多年前研究 Clang 和 Swift 编译时写的一篇文章(就是简书上那篇《OC 与 Swift 编译对比》)。当时梳理了从 GCC 到 LLVM 再到 Swift 的历史,现在回头看,MCP 的出现其实也是这条进化线上的必然一环。

timeline
    title 编译器与工具链进化史
    section GCC 时代
        2000s : GCC 作为 Xcode 默认编译器
              : 编译器是"黑盒"
              : IDE 交互能力有限
              : 扩展困难
    section LLVM/Clang 时代
        2007 : Chris Lattner 创建 LLVM
             : Clang 取代 GCC
             : 模块化设计 + libTooling
             : SourceKit 诞生
    section Swift 编译器
        2014 : Swift 语言发布
             : SIL 中间表示层
             : ARC 优化 / 泛型特化
             : 智能代码补全基础
    section MCP 时代
        2025-2026 : Xcode Intelligence
                  : mcpbridge MCP Server
                  : AI 可"操作"代码
                  : 编译-测试-预览全闭环

为什么这么说?

  • GCC 时代:编译器是个"黑盒",只负责把源码变成机器码,与 IDE 的交互很有限。Xcode 早期也是通过调用 GCC 来完成构建,但想要扩展功能(比如代码索引、实时诊断)非常困难。

  • LLVM/Clang 时代:LLVM 的模块化设计让编译器变成了可重用的库。Clang 提供了 libTooling,开发者可以编写插件遍历 AST,实现代码检查、重构。Xcode 的 SourceKit 也应运而生,为 IDE 提供了实时的代码分析能力。

  • Swift 编译器:更进一步,引入了 SIL (Swift Intermediate Language),在 LLVM IR 之前增加了一层高级中间表示,专门用于 Swift 特有的优化(如 ARC 优化、泛型特化)。这为更智能的代码补全和诊断打下了基础。

  • MCP 时代:现在,我们把编译器 + IDE 的能力通过 MCP 暴露给 AI。AI 不再是"看"代码,而是能"操作"代码——读取、修改、构建、测试、预览,整个闭环自动化。

graph BT
    subgraph Evolution["📈 能力进化路径"]
        direction BT
        L1["🔧 GCC<br/>编译源码 → 机器码<br/>黑盒,不可扩展"]
        L2["⚙️ LLVM / Clang<br/>模块化编译器库<br/>libTooling + AST 遍历<br/>SourceKit 实时分析"]
        L3["🦅 Swift Compiler<br/>SIL 中间表示<br/>ARC / 泛型优化<br/>智能补全基础"]
        L4["🤖 MCP<br/>编译器 + IDE 可编程化<br/>AI 直接操作工程<br/>读-写-编译-测试-预览 闭环"]

        L1 -->|"模块化突破"| L2
        L2 -->|"语言级创新"| L3
        L3 -->|"AI 可编程化"| L4
    end

    style L1 fill:#efebe9,stroke:#4e342e
    style L2 fill:#e3f2fd,stroke:#1565c0
    style L3 fill:#fff3e0,stroke:#e65100
    style L4 fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px

从 GCC 到 LLVM,我们解决了编译器的模块化;从 SourceKit 到 MCP,我们解决了 IDE 能力的可编程化。每一步都在打破工具的边界,让开发者(或 AI)能更深入地控制开发环境。


九、总结与展望

Xcode MCP Server 的出现,意味着 AI 辅助编程进入了一个新阶段:从聊天式代码生成,进化到工程级自动运维。你可以让 AI 帮你修编译错误、跑测试、甚至验证 UI,而不再只是粘贴代码让你自己试错。

当然,目前还有些粗糙(比如 Cursor 兼容性问题),但方向已经非常明确。未来,随着 Apple Intelligence 的成熟和第三方模型的接入,Xcode 可能会变成一个"AI 优先"的 IDE——你只需要描述需求,剩下的交给 AI 和 MCP 去执行。

graph LR
    subgraph Past["📼 过去"]
        P1["AI 生成代码片段"] --> P2["复制粘贴到 IDE"] --> P3["手动编译调试"]
    end

    subgraph Present["📍 现在 (MCP)"]
        N1["AI 理解工程结构"] --> N2["直接读写文件"] --> N3["自动编译测试"]
    end

    subgraph Future["🔮 未来"]
        F1["描述需求"] --> F2["AI 自主规划"] --> F3["全自动交付"]
    end

    Past -.->|"进化"| Present
    Present -.->|"展望"| Future

    style Past fill:#efebe9,stroke:#795548
    style Present fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Future fill:#e1f5fe,stroke:#0277bd

奇奇怪怪的无用知识又增加了:从 GCC 到 LLVM 再到 MCP,每一步都是因为开发者"受不了"现有工具的局限而推动的。历史总是惊人地相似,但每次进化都让工具离人更近一步。


昨天以前掘金 iOS

解决 Swift Testing 中 DI 容器的竞态条件

作者 RickeyBoy
2026年3月24日 20:38

欢迎给个 star:RickeyBoy-Github

🎬 背景:单元测试不稳定

当项目逐渐扩大,Unit Test 越来越多的时候,必然会出现问题:某些单元测试有时能通过,有时又不行。最麻烦的是有些时候本地运行能通过,在 CI Pipeline 中又通过不了。

通常来讲本地设备和 CI 设备确实很不一样,一个常见的问题就是存在静态条件(Race Condition)的问题,本篇就尝试从根本上解决这类似的问题,并且从原理层面也讲清楚来龙去脉。

🔁 快速回顾:为什么 DI 很重要

如果是在 SwiftUI 上使用 MVVM 的架构,那 DI (Dependency Injection 依赖注入)对你来说肯定不陌生。这里简单回顾一下,为什么要用 DI

不可测试的 ViewModel

final class CheckoutViewModel {
    private let paymentService = PaymentService()  // 硬编码的依赖

    func placeOrder() async throws {
        try await paymentService.charge(amount: 99.99)
    }
}

这个 ViewModel 直接持有了它的依赖。想在测试中把 PaymentService 换成 mock?根本换不了。

可测试的 ViewModel

final class CheckoutViewModel {
    private let paymentService: PaymentServiceProtocol

    init(paymentService: PaymentServiceProtocol) {
        self.paymentService = paymentService
    }
    func placeOrder() async throws {
        try await paymentService.charge(amount: 99.99)
    }
}

现在我们可以在测试中注入 mock,在生产环境中使用真实服务了:

// 生产环境
let vm = CheckoutViewModel(paymentService: StripePaymentService())
// 测试
let vm = CheckoutViewModel(paymentService: MockPaymentService())

生产环境中,ViewModel 连接真实服务。测试中,由 mock 替代:

image.png

道理很简单,小项目用着也没问题。但随着 app 不断膨胀,事情就开始变味了。

💥 MVVM 的依赖问题

构造器注入复杂度爆炸

现实中的 ViewModel 不可能只有一个依赖,随着业务迭代它们只会越来越多:

init(
    paymentService: PaymentServiceProtocol,
    orderRepository: OrderRepositoryProtocol,
    analyticsTracker: AnalyticsTrackerProtocol,
    authService: AuthServiceProtocol,
    featureFlagService: FeatureFlagServiceProtocol
) { ... }

而且如果父级 ViewModel 需要创建子级 ViewModel,那父级就必须知道子级的全部依赖。往下嵌套三层之后再新增一个依赖,你就得一路往上改文件……

SwiftUI 的 @Environment 帮不上忙

SwiftUI 有一个内置的 DI 机制,@Environment

struct CheckoutView: View {
    @Environment(.paymentService) var paymentService  // 可以!
}

看起来挺好。但问题是 @Environment 只能在 View 的 body 里用,不适用于 ViewModel:

class CheckoutViewModel {
    @Environment(.paymentService) var paymentService  // 编译报错
}

所以我们需要一个像 @Environment 一样好用,但能在 View 层之外工作的方案。

🏭 Factory:适配 MVVM 的 DI 框架

Factory 是一个轻量级的 DI 框架,刚好解决了这个问题。ViewModel 通过 @Injected 主动从容器中拉取依赖:

final class CheckoutViewModel {
    @Injected(.paymentService) private var paymentService
    @Injected(.orderRepository) private var orderRepository

    func placeOrder() async throws {
        let order = try await orderRepository.createOrder()
        try await paymentService.charge(amount: order.total)
    }
}

不用传 init 参数,也不用维护依赖链。容器负责创建每个服务,@Injected 在运行时自动解析,ViewModel 压根不需要关心依赖从哪来。

Factory 就像个中间人,根据当前环境决定注入什么:

image.png

ViewModel 只管说"我要什么",Factory 负责"给你什么"。

Container:依赖注册中心

Container 是你定义每个依赖创建方式的地方:

extension Container {
    var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
    var orderRepository: Factory<OrderRepositoryProtocol> {
        self { OrderRepository() }
    }
}

在生产环境中,@Injected(.paymentService) 解析为 StripePaymentService()。在测试中,你可以覆盖它:

Container.shared.paymentService.register { MockPaymentService() }

SharedContainer:按模块组织依赖

一个全局 Container 小项目够用。但在拥有几十个服务的模块化工程里,它很快就变成了大杂烩。Factory 提供了 SharedContainer,让你按模块拆分管理,就像是文件夹一样:

PaymentSharedContainer
├── paymentService
└── paymentGateway

OrderSharedContainer
├── orderRepository
└── orderValidator

AuthSharedContainer
├── authService
└── tokenStorage

每个模块管好自己的容器,边界清晰,各司其职:

public final class PaymentSharedContainer: SharedContainer {
    public static var shared = PaymentSharedContainer()
    public let manager = ContainerManager()

    public var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
    public var paymentGateway: Factory<PaymentGatewayProtocol> {
        self { PaymentGateway() }
    }
}

在 ViewModel 中使用:

final class CheckoutViewModel {
    @Injected(\PaymentSharedContainer.paymentService)
    private var paymentService
}

到这里一切都很美好,直到你开始并行跑测试(尤其是 Swift Testing 默认就是并行模式)。

🐛 核心问题:并行测试与共享可变状态

好,现在终于说到重点了。

一个典型的测试配置

@Suite
struct CheckoutViewModelTests {
    let mockPayment: PaymentServiceProtocolSpy
    let sut: CheckoutViewModel

    init() {
        mockPayment = PaymentServiceProtocolSpy()
        PaymentSharedContainer.shared.paymentService.register { mockPayment }
        sut = CheckoutViewModel()
    }

    @Test func placeOrder_chargesCorrectAmount() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.chargeCallCount == 1)
    }
}

看着没毛病,单独跑这个测试 Suite 的时候确实也没问题。

并行执行时发生了什么

Swift Testing 默认并发执行测试。这对 CI 速度来说是好事,但这意味着多个测试 Suite 同时执行,且共享同一个 PaymentSharedContainer.shared 实例:

image.png

Suite A 注册了 MockASuite B 在同一个容器上注册了 MockB。当 Suite A 解析依赖时,它可能拿到的是 MockB,反过来也一样。最终结果完全取决于线程调度顺序,而线程调度本身就是不确定的。换句话说,你的测试结果现在全凭运气。

更糟糕的是:Suite 内部的竞态

即使是同一个 Suite 内部的测试,也可能产生竞态。Swift Testing 并不保证 @Test 方法按顺序执行:

@Suite
struct OrderViewModelTests {
    let mockRepo: OrderRepositoryProtocolSpy
    let sut: OrderViewModel

    init() {
        mockRepo = OrderRepositoryProtocolSpy()
        OrderSharedContainer.shared.orderRepository.register { mockRepo }
        sut = OrderViewModel()
    }

    @Test func fetchOrders_success() async {
        mockRepo.fetchOrdersResult = [Order.sample]
        await sut.fetchOrders()
        #expect(sut.orders.count == 1)
    }

    @Test func fetchOrders_empty() async {
        mockRepo.fetchOrdersResult = []
        await sut.fetchOrders()
        #expect(sut.orders.isEmpty)
    }
}

两个测试配置的是同一个 mockRepo 实例。并发运行时,一个测试的配置会影响到另一个,本来期望拿到空列表的测试,结果却看到了一条数据,显然这样会影响最终的测试结果。

最终症状

  • 测试本地通过但 CI 上失败(不同机器,不同时序)
  • 重跑失败的流水线就能通过
  • 添加或删除不相关的测试导致其他地方失败
  • 稳定运行很长时间的测试突然变得不可靠

根本原因都是一样的:DI 容器中的共享可变状态,导致 Race Condition

🔐 解决方案:使用 @TaskLocal 实现容器隔离

思路很简单:给每个测试分配自己的独立容器副本。不共享,自然就不会有竞态。

理解 @TaskLocal

在讲方案之前,先简单科普一下 @TaskLocal。这是 Swift Concurrency 提供的一个属性,它能让每个 Task 持有自己的 copy,互相之间完全隔离:

enum Scope {
    @TaskLocal static var currentUser: String = "default"
}

// Task A 看到 "Alice"
Task {
    Scope.$currentUser.withValue("Alice") {
        print(Scope.currentUser)  // "Alice"
    }
}

// Task B 看到 "Bob",完全独立
Task {
    Scope.$currentUser.withValue("Bob") {
        print(Scope.currentUser)  // "Bob"
    }
}

每个 Task 有自己的 currentUser。无需加锁,没有竞态,没有共享状态。

将 @TaskLocal 应用到容器

聪明的你大概已经猜到了,把容器的 shared 属性标记为 @TaskLocal,每个测试任务就能拿到自己的容器了:

public final class PaymentSharedContainer: SharedContainer {
    @TaskLocal public static var shared = PaymentSharedContainer()
    // ^^^^^^^^^ 就是这行代码

    public let manager = ContainerManager()

    public var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
}

就这么一行代码的事。当测试运行在一个绑定了新容器到 $sharedTask 里时,所有 @Injected 解析拿到的都是当前测试专属的容器,而不是全局那个。

Factory 的内置支持:Container Traits

当然,你也可以手动调用 $shared.withValue(...),但写起来实在太啰嗦了。Factory(通过 FactoryTesting)提供了 Container Traits 来帮你搞定这些样板代码,可以直接嵌入 Swift Testing 的 @Suite@Test 属性。

定义 Container Trait

为每个 SharedContainer 在测试支持模块中定义一个 trait:

import Testing
import FactoryTesting

// 辅助方法:配置测试默认值
private func configurePaymentDefaults(_ container: PaymentSharedContainer) {
    container.paymentService.register { PaymentServiceProtocolSpy() }
    container.paymentGateway.register { PaymentGatewayProtocolSpy() }
}

// Suite 级别的 trait:隔离整个 Suite
extension SuiteTrait where Self == ContainerTrait<PaymentSharedContainer> {
    static var paymentContainer: ContainerTrait<PaymentSharedContainer> {
        let container = PaymentSharedContainer()
        configurePaymentDefaults(container)
        return .init(shared: PaymentSharedContainer.$shared, container: container)
    }
}

// Test 级别的 trait:允许单个测试覆盖配置
extension TestTrait where Self == ContainerTrait<PaymentSharedContainer> {
    static func paymentContainer(
        _ configure: @escaping (PaymentSharedContainer) -> Void
    ) -> ContainerTrait<PaymentSharedContainer> {
        let container = PaymentSharedContainer()
        configurePaymentDefaults(container)
        configure(container)  // 应用测试专属的配置
        return .init(shared: PaymentSharedContainer.$shared, container: container)
    }
}

在测试中使用 Traits

Suite 级别的隔离。 Suite 中的每个测试都获得自己的容器:

@Suite(.paymentContainer, .orderContainer)
struct CheckoutViewModelTests {
    let mockPayment: PaymentServiceProtocolSpy
    let sut: CheckoutViewModel

    init() {
        // 在隔离的容器上注册测试专属的 mock
        let spy = PaymentServiceProtocolSpy()
        PaymentSharedContainer.shared.paymentService.register { spy }
        mockPayment = spy
        sut = CheckoutViewModel()
    }

    @Test func placeOrder_chargesOnce() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.chargeCallCount == 1)
    }

    @Test func placeOrder_passesCorrectAmount() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.lastChargedAmount == 99.99)
    }
}

这样两个测试就可以放心地并行跑了,各自都有独立的 PaymentSharedContainer 实例,互不干扰。

Test 级别的覆盖。 还可以针对某个测试单独定制配置:

@Suite(.paymentContainer)
struct DiscountTests {
    @Test(
        "高级用户享受 20% 折扣",
        .paymentContainer { $0.discountRate.register { 0.20 } }
    )
    func premiumDiscount() async {
        let vm = CheckoutViewModel()
        let price = vm.calculateFinalPrice(for: 100.0)
        #expect(price == 80.0)
    }

    @Test("普通用户没有折扣")
    func noDiscount() async {
        let vm = CheckoutViewModel()
        let price = vm.calculateFinalPrice(for: 100.0)
        #expect(price == 100.0)  // 使用 Suite 默认值(无折扣)
    }
}

整体运作方式

用上 Container Traits 之后,每个测试都拥有自己的独立容器,彼此之间完全隔离:

image.png

不再有共享状态,不再有竞态,单元测试从此稳定!

📋 完整实践清单

我们在每个模块中遵循的模式:

1. 为 SharedContainer 标记 @TaskLocal

public final class OrderSharedContainer: SharedContainer {
    @TaskLocal public static var shared = OrderSharedContainer()
    public let manager = ContainerManager()
    // ... 依赖定义
}

2. 在测试支持模块中创建 trait 辅助方法:

// OrderMocks/ContainerTraits.swift
private func configureOrderDefaults(_ container: OrderSharedContainer) {
    container.orderRepository.register { OrderRepositoryProtocolSpy() }
    container.orderValidator.register { OrderValidatorProtocolSpy() }
}

extension SuiteTrait where Self == ContainerTrait<OrderSharedContainer> {
    static var orderContainer: ContainerTrait<OrderSharedContainer> {
        let container = OrderSharedContainer()
        configureOrderDefaults(container)
        return .init(shared: OrderSharedContainer.$shared, container: container)
    }
}

3. 在测试Suite中应用 traits:

@Suite(.orderContainer, .paymentContainer)
struct OrderFlowTests {
    // 每个测试都获得隔离的容器,可安全并行执行
}

🧭 核心要点

  1. DI 的本质是为了可测试性
  2. Factory 填补了 MVVM + DI 的空白,解决了 SwiftUI @Environment 无法覆盖的场景
  3. 并行测试 + 共享容器(Shared Container) = 竞态条件(Race Condition)
  4. @TaskLocal 容器隔离从根本上解决了问题,为每个测试提供独立的容器实例
  5. Container Traits 让方案切实可行, 每个模块定义一次,在每个测试Suite中声明式地应用

不依赖 Mac 也能做 iOS 开发?跨设备开发流程

2026年3月24日 17:58

在 iOS 开发这个领域,需要一台 Mac几乎是默认前提。项目创建、代码编译、设备调试都围绕着 macOS 和 Xcode 展开。但在一些实际场景里,比如临时接手项目、在非 Mac 设备上验证功能,这个前提会变成限制条件。

前段时间在帮朋友看一个小项目时,我尝试了一种不同的方式:在没有使用传统 Mac 开发环境的情况下,完成 iOS 应用的编写和运行。

项目不复杂,但流程完整,刚好可以验证这种开发方式是否可行。


在非传统环境中创建 iOS 项目

打开快蝎 IDE 后,可以直接进入项目创建界面。界面提供几种项目类型:

  • Swift
  • Objective-C
  • Flutter

选择 Swift 项目,输入名称后点击创建,IDE 会生成项目目录。

项目结构已经包含基础代码文件和资源目录。打开入口文件就可以开始写代码,没有额外的初始化步骤。

在这个阶段没有遇到环境缺失的问题。IDE 已经准备好编译所需工具,因此项目创建后可以直接进入开发阶段。 创建项目


编写一个简单功能验证项目

为了测试开发流程,我写了一个简单页面:

  • 一个按钮
  • 一个文本区域

按钮点击后读取本地数据,并把结果显示在界面上。

在代码编辑过程中,IDE 提供了自动补全和语法提示。输入类名或方法时,会弹出可选项列表。保存文件后,IDE 会检查代码结构并标记错误位置。

编辑体验接近常见代码编辑器,键盘操作和插件支持也比较完整。


连接 iPhone 并执行应用构建

代码写好之后,需要在真实设备上运行。

将 iPhone 连接到电脑 IDE 开始执行构建流程。

构建过程中会完成:

  • 编译源代码
  • 构建应用程序
  • 安装到手机

构建完成后,手机桌面上会出现应用图标。点击打开应用,可以看到界面正常显示。

点击按钮后,文本区域成功更新为读取的数据,说明代码已经正确执行。 连接手机


修改代码并再次运行

在开发过程中,需要不断调整代码。

我在按钮点击逻辑中增加了一段处理,然后保存文件并再次点击运行按钮。IDE 会重新编译应用并安装新版本。

打开手机应用,可以看到更新后的效果。

整个过程保持一致:

修改代码 → 点击运行 → 编译应用 → 安装到设备 → 查看结果

没有出现额外导出或手动安装的步骤。


编译能力的实现方式

在这个流程中,没有使用 Mac 上的 Xcode。

快蝎 IDE 内置了一套编译工具套装。安装 IDE 时,这些工具已经配置完成。点击运行或构建时,IDE 会调用内部工具完成代码编译和应用构建。

开发者在这种环境中可以直接编写 iOS 应用,并完成编译和运行。

对于需要在非 Mac 环境下验证代码的场景,这种方式提供了一种可行路径。


多项目类型的开发测试

为了进一步验证 IDE 的能力,我创建了一个 Flutter 项目。

Flutter 页面写好后,连接设备点击运行,IDE 可以完成编译并安装应用。

随后测试了 Objective-C 项目,也可以正常运行。

在同一个开发环境中可以处理:

  • Swift 项目
  • Objective-C 项目
  • Flutter 项目

这在需要跨项目开发时会比较方便。


构建安装包用于分发

当应用开发完成之后,需要生成安装包。

在快蝎 IDE 中,可以通过构建功能生成应用安装文件。IDE 会执行编译并输出安装包。

构建日志会显示在输出面板中,如果出现编译问题,可以查看详细信息。

生成的安装文件可以用于测试或分发。 构建

对于开发者来说,这种方式可以在特定场景下使用,例如临时开发、功能验证或环境受限时。 参考链接:www.kxapp.com/blog/15

Windows 上传 IPA 到 App Store 的步骤讲解

2026年3月24日 16:40

在 Windows 环境开发 iOS 项目时,最容易卡住的一步不是写代码,而是怎么把 IPA 上传到 App Store。

很多资料默认使用 Xcode 或 Transporter,但这些工具依赖 macOS。 如果开发和发布都在 Windows 上完成,就需要把流程分开:

  • IPA 可以在任意环境生成
  • 上传只是一个独立步骤

只要 IPA 符合要求,上传完全可以在 Windows 上完成。


确认 IPA 已经具备上传条件

在考虑上传之前,需要先确认 IPA 是“可发布包”。

检查三个关键点:

1. 使用的是发布证书

打包时必须使用:

  • Distribution 证书

如果是 Development 证书:

  • IPA 可以安装
  • 但无法上传 App Store

2. 描述文件类型正确

描述文件必须是:

  • App Store 类型

可以通过解包 IPA 检查:

unzip -p app.ipa Payload/*.app/embedded.mobileprovision

确认没有设备 UDID。


3. Bundle ID 与后台一致

需要保证:

  • IPA 中 Bundle ID
  • App Store Connect 中的 Bundle ID

完全一致。


在 Windows 准备上传环境

Windows 上没有 Xcode,但可以使用以下工具:

  • AppUploader
  • iTMSTransporter(需额外配置 Java 环境)
  • Fastlane(Windows 支持有限)

在实际使用中,直接使用图形工具或命令行工具会更稳定。


使用 AppUploader 上传 IPA

在 Windows 上,AppUploader(开心上架) 可以直接完成上传操作。

具体步骤如下:


1. 打开上传界面

启动 AppUploader,进入「提交上传」页面。


2. 设置 Apple 专用密码

需要在 Apple ID 中生成 App 专用密码。

输入:

  • Apple ID
  • 专用密码

注意:这里不能使用账号登录密码。 专用密码

app专用密码


3. 选择 IPA 文件

点击选择本地 .ipa 文件。


4. 选择上传通道

工具提供多个上传通道:

  • 通道 1(旧通道)
  • 通道 2(新通道)

如果上传过程中卡住,可以切换通道重新尝试。 上传页面


5. 执行上传

点击上传按钮,等待完成。

上传成功后,Apple 会返回处理结果。 上传成功


上传后的状态确认

上传完成并不代表立即可见。

需要进入:

App Store Connect → My Apps → TestFlight

Apple 会进行一次处理(Processing)。

处理完成后:

  • 构建版本才会出现
  • 才能提交审核

如果上传成功但没有构建

遇到上传成功但没有构建时,可以检查:

检查构建号

  • CFBundleVersion 必须递增

检查签名类型

  • 是否使用 App Store 描述文件

检查 Bundle ID

  • 是否与 App Store Connect 中一致

重新上传

可以换一个上传通道再次提交。


结合其他工具的上传方式

在一些团队中,上传流程会和构建工具结合。

例如:

Fastlane + Windows

可以在 Mac 构建后,将 IPA 传到 Windows,再用 AppUploader 上传。


CI 流程

流程可以拆成:

  1. Mac 或云端构建 IPA
  2. 上传到服务器
  3. Windows 节点执行上传

这样可以减少对 macOS 的依赖。


实际流程示例

团队使用 Windows 开发时,可以这样:

  1. 使用 HBuilderX 或 CI 构建 IPA
  2. 使用 AppUploader 创建证书和描述文件
  3. 下载 .p12.mobileprovision
  4. 打包生成 IPA
  5. 在 Windows 上使用 AppUploader 上传

整个流程中:

  • 不需要 Xcode 上传
  • 不依赖 macOS 设备

参考链接:www.appuploader.net/blog/231

iOS复习必看!weak关键字底层原理(Deepseek&豆包)回答整理

作者 WaywardOne
2026年3月24日 09:41

一 weak引用

当一个对象被 weak 引用时,Runtime 需要把这个 weak 指针记录到一个全局的数据结构中,以便将来对象销毁时能够找到它并置为 nil。整个过程涉及编译器、Runtime 函数和复杂的数据结构。下面我们详细拆解这个过程。


1. 编译器对 weak 变量的处理

假设我们有如下代码:

NSObject *obj0 = [NSObject new];
__weak id obj1 = obj0;

在 ARC 环境下,编译器会将 __weak 变量的赋值操作编译为对 Runtime 函数的调用。具体来说:

  • 对于 __weak 变量的初始化(首次赋值),编译器会调用 objc_initWeak
  • 对于 __weak 变量的重赋值(即已经存在一个 weak 变量,再将其指向另一个对象),编译器会调用 objc_storeWeak

这两者最终都会调用核心函数 objc_storeWeak(id *location, id newObj),其中:

  • location 是 weak 指针的地址(例如 &obj1)。
  • newObj 是要指向的对象(即 obj0)。

所以,我们以 objc_storeWeak 为主线,讲解存储过程。


2. 进入 Runtime:objc_storeWeak 的主要流程

objc_storeWeak 的核心逻辑可以简化为以下步骤:

  1. 获取要指向的对象 newObj
  2. 如果有旧值(即这个 weak 指针之前已经指向过某个对象),需要先从 weak 表中移除该指针。
  3. 如果 newObj 不为 nil,则将当前 weak 指针注册到 newObj 的 weak 表中。
  4. 返回 newObj

下面重点说明注册过程,即如何将 weak 指针存储到表中。


3. 存储 weak 指针的核心步骤

3.1 获取 SideTable

Runtime 维护一个全局的 SideTables 哈希表(实际上是一个 StripedMap),它以对象的内存地址为 key,映射到一个 SideTable 结构体。这个 SideTable 包含了引用计数和 weak 表等。

为了操作 obj0 的 weak 表,需要先通过 obj0 的地址找到对应的 SideTable

SideTable *table = &SideTables[obj0];

这里使用 obj0 的地址进行哈希计算,得到一个索引,从而取出对应的 SideTable

为什么需要 SideTable 主要为了锁分离,提高并发性能。不同的对象可能映射到不同的 SideTable,操作不同对象的 weak 引用时可以并行执行。

3.2 获取 weak_table_t

每个 SideTable 内部包含一个 weak_table_t 结构,它是一个独立的哈希表,专门管理所有指向该 SideTable 所管辖对象的 weak 引用。

weak_table_t &weak_table = table->weak_table;

weak_table_t 的定义大致如下:

struct weak_table_t {
    weak_entry_t *weak_entries;   // 哈希数组,元素是 weak_entry_t
    size_t    num_entries;        // 当前条目数
    uintptr_t mask;               // 掩码,用于哈希计算
    uintptr_t max_hash_displacement; // 最大偏移量
};

3.3 在 weak_table_t 中查找或创建 weak_entry_t

接下来,使用 对象地址 obj0 作为 key,在 weak_table_t 中查找对应的 weak_entry_t

weak_entry_t 是一个容器,它负责存储所有指向同一个对象的 weak 指针地址(即 location)。

  • 如果找到已有的 weak_entry_t,说明已经有一些 weak 指针指向 obj0,那么直接将当前 weak 指针地址添加到这个 weak_entry_t 中。
  • 如果没找到,说明这是第一个指向 obj0 的 weak 指针,需要创建一个新的 weak_entry_t,并将它插入到 weak_table_t 中。

3.4 weak_entry_t 的结构:如何存储多个 weak 指针

weak_entry_t 的设计目标是高效地存储多个指向同一个对象的 weak 指针。它的简化结构如下:

struct weak_entry_t {
    DisguisedPtr<objc_object> referent;  // 指向对象,即 obj0
    union {
        struct {
            weak_referrer_t *referrers;   // 动态数组
            uintptr_t        out_of_line : 1; // 标志位,表示使用动态数组
            // ... 其他字段
        };
        struct {
            weak_referrer_t  inline_referrers[4]; // 静态数组,容量为4
        };
    };
};

这里有一个优化:当一个对象只有少量(<=4)weak 引用时,使用静态数组 inline_referrers,避免额外堆分配。当超过4个时,会转换为动态数组 referrers,可以动态扩容。

weak_referrer_t 本质上就是 id *,即 weak 指针的地址(例如 &obj1)。存储的是指针的地址,这样当对象销毁时,Runtime 可以找到这个地址并将其内容置为 nil

3.5 添加 weak 指针到 weak_entry_t

在找到或创建了 weak_entry_t 之后,调用 weak_entry_append 或类似函数,将当前 weak 指针的地址(location)添加到 weak_entry_t 的数组中。

添加时,会根据当前是静态数组还是动态数组,采取不同的插入逻辑。如果是静态数组且未满,直接放入;如果静态数组已满(即已有4个),则触发扩容,将静态数组内容迁移到新分配的动态数组,再加入新元素。

3.6 线程安全

整个过程中,对 SideTable 的操作都在其持有的自旋锁(spinlock_t)保护下进行,确保多个线程同时操作 weak 引用时的安全性。


4. 一个完整的例子

让我们用代码模拟这个过程:

NSObject *obj0 = [NSObject new];
__weak id obj1 = obj0;
__weak id obj2 = obj0;
  1. 编译器为 obj1 的赋值生成类似 objc_storeWeak(&obj1, obj0) 的代码。
  2. 进入 objc_storeWeak
    • 获取 obj0SideTable,加锁。
    • weak_table_t 中以 obj0 为 key 查找 weak_entry_t,未找到,创建一个新的 weak_entry_t,其中 referent 指向 obj0,静态数组 inline_referrers 为空。
    • &obj1 添加到 weak_entry_t 的数组中。
    • weak_entry_t 插入 weak_table_t
    • 解锁。
  3. 编译器为 obj2 的赋值生成 objc_storeWeak(&obj2, obj0)
  4. 再次进入 objc_storeWeak
    • 获取 obj0SideTable,加锁。
    • weak_table_t 中以 obj0 为 key 查找 weak_entry_t,这次找到了。
    • &obj2 添加到该 weak_entry_t 的数组中(此时数组内已有 &obj1&obj2)。
    • 解锁。

此时,obj0 的 weak 表中就记录了这两个 weak 指针的地址。


5. 为什么存储的是 weak 指针的地址,而不是指针本身?

当对象 obj0 销毁时,Runtime 会遍历 weak_entry_t 中的数组,对于数组中的每一项(即每个 weak_referrer_t,它是一个 id * 类型),执行 *referrer = nil,将 weak 变量本身置为 nil。如果存储的是指针的值(即 obj1 本身),那么 Runtime 只能知道这个 weak 指针指向了 obj0,却无法修改这个 weak 变量(因为不知道它的内存位置)。存储地址使得 Runtime 能够直接修改该变量的内容,从而实现自动置 nil


6. 小结

对象被 weak 引用后,存储到 weak 表中的过程可以概括为:

  1. 编译器将 weak 赋值转换为 objc_storeWeak 调用。
  2. Runtime 通过对象地址找到对应的 SideTable(分离锁)。
  3. SideTableweak_table_t 中以对象地址为 key 查找或创建 weak_entry_t
  4. weak_entry_t 是一个容器(静态或动态数组),存储所有指向该对象的 weak 指针的地址。
  5. 将当前 weak 指针地址添加到该容器中。

这种设计保证了:

  • 高效查找:通过两层哈希表,快速定位到对象对应的 weak 容器。
  • 并发友好:通过 SideTable 分离锁,不同对象的 weak 操作可以并行。
  • 内存优化:使用小对象优化(inline array),减少大多数情况下的堆分配。
  • 自动置 nil:存储指针地址,使得对象销毁时可以直接修改 weak 变量的内容。

二 释放weak引用

当一个对象的引用计数降为 0 时,系统会回收该对象。在这个过程中,Runtime 必须完成两件与 weak 相关的重要工作:

  1. 将所有指向该对象的 weak 指针置为 nil,防止产生悬垂指针。
  2. 清理该对象在 weak 表中对应的条目(weak_entry_t),避免表膨胀。

下面我们从“引用计数归零”开始,逐步剖析整个流程,重点讲解 weak 的处理机制。


1. 引用计数归零的入口

在 ARC 下,当对象最后一次被释放时,会调用 objc_releaseobjc_release 内部会执行:

if (--newRetainCount == 0) {
    // 引用计数变为 0,准备销毁
    ((id)obj)->dealloc();
}

因此,引用计数归零的最终结果就是调用对象的 dealloc 方法。


2. dealloc 的核心调用链

对象的 dealloc 方法(通常由编译器自动生成)最终会调用 Runtime 函数 objc_destructInstance,该函数负责真正的析构工作。objc_destructInstance 的执行顺序大致如下:

  1. 如果有 C++ 析构函数,先调用。
  2. 如果有关联对象,则移除关联对象。
  3. 调用 clearDeallocating,这是处理 weak 和引用计数表的关键步骤。

3. clearDeallocating 的作用

objc_object::clearDeallocating 函数的简化逻辑如下:

void objc_object::clearDeallocating() {
    // 获取 SideTable
    SideTable *table = SideTable::tableForPointer(this);
    
    // 加锁,防止并发操作
    table->lock();
    
    // 处理 weak 引用:将指向当前对象的所有 weak 指针置 nil,并移除 weak_entry_t
    weak_clear_no_lock(table, this);
    
    // 处理引用计数表:清除该对象在 RefcountMap 中的条目
    table->refcnts.erase(this);
    
    table->unlock();
}

可见,weak 的处理先于引用计数表的清理。这是因为 weak_clear_no_lock 需要读取 weak_table_t,而该表在对象销毁后便不再需要,所以先处理 weak 再清理 refcnts 是合理的。


4. weak_clear_no_lock 详解:将 weak 指针置 nil 并清理 entry

weak_clear_no_lock 是真正执行 weak 清理的函数。它的实现思路是:

  1. 根据对象地址(this)找到对应的 weak_table_t
  2. weak_table_t 中查找该对象对应的 weak_entry_t
  3. 如果找到了,就遍历 weak_entry_t 中的所有 weak 指针地址,将每个指针的内容置为 nil
  4. weak_table_t 中移除这个 weak_entry_t(释放其占用的内存)。

下面我们拆解每一步。

4.1 获取 SideTable 和 weak_table_t

SideTable *table = &SideTables[this];
weak_table_t *weak_table = &table->weak_table;

这里 this 就是待释放对象的地址。通过对象地址取模得到对应的 SideTable(分离锁),然后取出其中的 weak_table_t

4.2 在 weak_table_t 中查找 weak_entry_t

以对象地址为 key,在 weak_table_t 的哈希表(weak_entries 数组)中查找对应的 weak_entry_t。如果找不到,说明没有 weak 指针指向该对象,直接返回。否则,进入下一步。

4.3 遍历 weak_entry_t,将 weak 指针置 nil

weak_entry_t 内部存储着所有指向该对象的 weak 指针的地址(即 weak_referrer_t,类型是 id *)。weak_clear_no_lock 会遍历这个容器(无论是静态数组还是动态数组),对每个 referrer 执行:

*referrer = nil;

这一步直接修改了 weak 变量的内容,使其变为 nil。由于 weak 变量本身是 __weak 修饰的,它们的存储位置可能是栈上的局部变量,也可能是堆上的实例变量,但都是有效的内存地址,因此可以直接赋值。

4.4 从 weak_table_t 中移除 weak_entry_t

遍历并置 nil 完成后,weak_entry_t 已经没有任何作用了。需要将其从 weak_table_t 的哈希表中删除,并释放 weak_entry_t 占用的内存(如果是动态数组,也要释放)。

这一步涉及哈希表的删除操作,具体会:

  • weak_entries 数组中对应的槽位标记为空。
  • 减少 weak_table_tnum_entries 计数。
  • 如果 weak_entry_t 使用了动态数组(即 out_of_line 标志为 1),则释放 referrers 指向的堆内存。

5. 引用计数表的清理

weak_clear_no_lock 之后,clearDeallocating 还会调用:

table->refcnts.erase(this);

refcnts 是一个 DenseMap(或类似结构),存储了该对象的额外引用计数信息(例如 weak 引用计数、deallocating 标志等)。由于对象即将被销毁,这些信息也不再需要,因此从表中删除。


6. 为什么 weak 处理必须加锁?

在整个过程中,SideTable 的锁一直持有,直到 clearDeallocating 结束。这是因为可能有多个线程同时操作同一个对象的 weak 引用(例如一个线程正在释放对象,另一个线程正在对这个对象取 weak 值),锁保证了操作的原子性,避免出现数据竞争。


7. 完整流程图示

对象引用计数 → 0
    ↓
调用 dealloc
    ↓
objc_destructInstance
    ↓
clearDeallocating
    ↓
获取 SideTable,加锁
    ↓
weak_clear_no_lock
    ├── 根据对象地址查找 weak_table_t
    ├── 在 weak_entries 中找到 weak_entry_t
    ├── 遍历 referrers 数组
    │     └── 将每个 weak 指针内容置为 nil
    └── 从 weak_entries 中移除 weak_entry_t,释放内存
    ↓
从 refcnts 中擦除该对象的条目
    ↓
解锁 SideTable
    ↓
对象内存被释放(free

8. 总结

当对象引用计数归零时,Runtime 通过以下步骤处理 weak

  1. 定位 SideTable:通过对象地址找到对应的 SideTable,加锁保证线程安全。
  2. 查找 weak_entry_t:在 weak_table_t 的哈希表中找到该对象对应的 weak_entry_t
  3. 遍历并置 nil:遍历 weak_entry_t 中存储的所有 weak 指针地址,将每个指针的值设为 nil。由于存储的是指针的地址,所以可以直接修改 weak 变量的内容。
  4. 清理 entry:从 weak_table_t 中删除该 weak_entry_t,释放相关内存。
  5. 清理引用计数表:从 refcnts 中删除该对象的条目。
  6. 解锁:完成 weak 和引用计数表的清理后,解锁 SideTable

这一过程确保了在对象被彻底销毁前,所有指向它的 weak 指针都被安全地置为 nil,从而避免程序出现野指针崩溃。同时,通过 SideTable 和锁的分离设计,保证了多线程环境下的性能和正确性。

基于Mach-O文件的动态库与静态库归属方案及API扫描实践

作者 bcbnb
2026年3月23日 18:31

Mach-O简介:

Mach-O文件全称Mach Object,是在MacOS、iOS、iPadOS上的可执行文件,类似于Windows上PE文件。支持的CPU架构类型主要有x86_64、armv7、arm64。

Mach-O文件的生成过程 源代码-->预处理-->词法分析-->语法分析-->语义分析-->中间代码-->生成目标代码-->汇编-->机器码-->静态链接-->Mach-O文件

Mach-O能做什么

了解Mach-O格式的结构和加载过程,可以帮助我们更容易的理解APP的启动过程、C函数的hook、动态库的懒加载等原理。常见的应用场景有:

① Crash的符号化

② Bitcode分析

③ APP启动速度的优化

④ 优化APP包体积

⑤ 方法调用链分析

接下来,本文针对第5种应用场景,介绍下工作中用到的两个实践项目。

1.基于Mach-O文件的动态库与静态库的归属方案。

2.基于Mach-O的API扫描方案。

由于目前APP Store上基本已废弃对armv7的支持,所以接下来介绍的方案都是基于arm64架构的Mach-O分析。

实践项目一:基于Mach-O文件的动态库与静态库归属方案

背景:大部分的APP都会包含多个动态库与静态库,之家APP也一样,并且随着业务的增长,APP内集成的功能越来越多,静态库和动态库的数量也在不断增加。为了提升用户体验,之家APP进行了多个维度的数据采集,如:网络、崩溃、卡顿、秒开、图片性能等,而采集后的数据如何精准、快速的分发到研发人员进行解决,一直是我们面对的难题。

基于Mach-O结构与Runtime的原理,我们通过不断实践,实现了一套自定义归属方案,此方案可以对性能数据进行库的归属划分,再通过库归属找到开发人员,从而解决分发难题。

下面对此方案做一个详细说明。

首先,归属划分主要涉及动态库与静态库两种场景:

(1)运行时创建的某个对象归属于哪个库(通过类查找库)。

(2)公共库的某个方法被哪个库所调用(通过堆栈查找库)。

因为动态库其本身就是代码隔离的,查找非常方便,而静态库最终会被编译到主程序的二进制文件中,或者多个静态库被包到一个动态库中,所以无法直接进行类和堆栈的归属划分。

接下来本段落会先介绍下动态库的归属方法,然后重点介绍静态库的归属方法。

动态库:

类定位:先用对象查找isa指针获取类,然后使用类查找所在的可执行文件即可;

NSBundle *bundle = [NSBundle bundleForClass:objClass];

堆栈定位:获取的堆栈可以直接区分出所归属的动态库,如图:

NSArray<NSNumber *> *callAddresses = [NSThread callStackReturnAddresses];
long long callStackAddress = [callAddresses[i] longLongValue];
Dl_info info = {0};
dladdr((void *)callStackAddress, &info); //获取堆栈地址对应的可执行文件信息
NSString *dliFname = [NSString stringWithFormat:@"%s",info.dli_fname]; //取出库名

静态库:

名词解释:

Mac服务器:用于编译APP和dSYM的符号解析,以下简称Mac服务器。

日志服务器:记录线上APP上报的性能数据,以下简称日志服务器。

ASLR:全称Address spce layout randomization,地址空间布局随机化,通过对堆、栈、共享库等关键数据区域的地址空间随机化,防止攻击者直接定位代码位置来篡改程序。这种技术会使得APP或者动态库每次运行加载到内存中时的基地址都是随机的。

静态库归属常见的方案是基于dSYM的符号解析,主要流程:

1、打包时在Mac服务器上存储所有库的dSYM文件。

2、线上APP上报执行文件名称、发布版本、偏移量等信息。

3、日志服务端收到APP的上报信息,通过版本号、执行文件名称查找缓存的dSYM,最后在Mac服务器上使用偏移量进行dSYM符号解析,返回静态库名。

这种方案需要在Mac服务器上对所有动态库的所有版本dSYM进行缓存,增大了服务器的存储成本,并且由于需要三端的交互才能完成静态库的归属,稳定性较差,同时在APP运行时无法做到直接定位,可读性也不高。

针对dSYM符号解析的问题,我们通过分析Mach-O的结构与原理,探索出了基于Mach-O的静态库归属方案,具体如下:

在编译期通过解析Mach-O和Link Map文件,生成静态库的类地址区间和汇编代码段的地址区间,在运行时根据isa指针(指向了Mach-O中的类声明地址)和代码偏移地址解析出静态库名。

先解析Mach-O文件的结构,从Mach-O的头部开始,Header中包含了二进制文件的大小、支持的CPU类型、Load Commands的数量和大小,Segment中包括了各Section和符号表等的偏移位置和大小。

Mach-O的结构比较复杂,Segment现在已知的类型有50多种,不同类型职责不同。

该方案只用到了代码段和类列表,所以只对__text和__objc_classlist进行解析。

解析Mach-O头部,关键代码如下:

mach_header_64 mhHeader;
//读取头部信息
[fileData getBytes:&mhHeader range:NSMakeRange(0, sizeof(mach_header_64))];
for (int i = 0; i < mhHeader.ncmds; i++) {
    load_command* cmd = (load_command *)malloc(sizeof(load_command));
    //读取Load_command
    [fileData getBytes:cmd range:NSMakeRange(currentLcLocation, sizeof(load_command))];
    if (cmd->cmd == LC_SEGMENT_64) {
      segment_command_64 segmentCommand;
      [fileData getBytes:&segmentCommand range:NSMakeRange(currentLcLocation, sizeof(segment_command_64))];
      NSString *segName = [NSString stringWithFormat:@"%s",segmentCommand.segname];
      //提取汇编代码 __TEXT
      if ([segName isEqualToString:SEGMENT_TEXT] || [segName isEqualToString:SEGMENT_BD_TEXT]) {
         section_64 sectionHeader;
         [fileData getBytes:§ionHeader range:NSMakeRange(currentSecLocation, sizeof(section_64))];
         NSString *secName = [[NSString alloc] initWithUTF8String:sectionHeader.sectname];
      }else if ([segName isEqualToString:SEGMENT_DATA]) {
         //提取指定DATA sectionHeader信息
         unsigned long long currentSecLocation = currentLcLocation + sizeof(segment_command_64);
      }
    //符号表
    }else if (cmd->cmd ==LC_SYMTAB){
       symtab_command  tsymtabcommand;
       [fileData getBytes:&tsymtabcommand range:NSMakeRange(currentLcLocation, sizeof(segment_command_64))];
       symtabcommand = tsymtabcommand;
    }else if(cmd->cmd == LC_FUNCTION_STARTS){
       [fileData getBytes:&funcStartHeader range:NSMakeRange(currentLcLocation, sizeof(linkedit_data_command))];
    }
}

如果对每个类都标记库名会使生成的ClassMap文件会过大,对包体积影响较大。通过分析APP的编译过程,发现静态库是顺序编译,在每个Section下静态库也都是分段的,所以最终通过计算每个静态库的偏移地址和大小来生成静态库的位置标记。

  • 静态库类的标记: 解析Mach-O的ClassList段,借助LinkMap文件,反向解析出每个静态库的类声明位置,找到第一个类的声明地址为起始地址,最后一个类的声明地址+类声明的字节大小为类声明的结束地址。
  • 静态库代码段标记:原理与查找类声明类似,结合__TEXT、Symbol Table和Function Starts, 找到静态库第一个类的第一个方法的起始地址作为库的代码段起始地址,找到静态库的最后一个类的最后一个方法的结束地址,作为静态库代码段的结束地址。

通过上面两步生成ClassMap和TextMap导入到ipa中,文件小于1kb,对APP大小几乎无影响。

生成脚本的位置要在Link Binary With Libraries之后,Copy bundle Resources之前。

前面介绍了编译期的工作,下面介绍下运行时的定位原理。

1.运行时获取对象对应的isa指针,找到class,再通过class指针地址减去动态库加载到内存的起始地址,算出对应的偏移量(上面提到的ASLR,会使每次运行APP的内存基地址发生改变,所以需要计算偏移地址),然后使用偏移量去ClassMap中找到对应的静态库名。

NSString *imageName = [[NSString alloc] initWithUTF8String:_dyld_get_image_name(i)];
//找到APP主二进制
if (_dyld_get_image_header(i)->filetype == MH_EXECUTE &&
[imageName containsString:mainBundle.executablePath]) {
  mainExecuteAddress = _dyld_get_image_vmaddr_slide(i);
    break;
}
uintptr_t os = (uintptr_t)objClass;
//计算出类的偏移地址
uintptr_t classInBundleAddress = os-mainExcuteAddress;
NSDictionary *classMap = [self pluginAllAddressWithClass];
for (NSString * key in classMap.allKeys) {
    NSDictionary *addressDic =  classMap[key];
    if (addressDic && [addressDic[@"end"] longLongValue]>classInBundleAddress && classInBundleAddress>=[addressDic[@"start"] longLongValue]) {
        return key;
    }
}

2.基于堆栈定位静态库。首先获取到无符号的堆栈数组,然后找到上一个调用的callback地址,检索到堆栈地址所在动态库起始地址,使用栈地址-动态库起始地址得到偏移量,再用偏移量去TextMap中找到对应的静态库名。

NSBundle *mainBundle = [NSBundle mainBundle];
Dl_info info = {0};
//获取堆栈地址对应的插件信息
dladdr((void *)callStackAddress, &info);
//获取二进制名
NSString *dliFname = [NSString stringWithFormat:@"%s",info.dli_fname];
//获取偏移量
uintptr_t callStackBundleAddress = callStackAddress-[self mainStartAddress];
NSDictionary *textMap = [self pluginAllAddressWithText];
for (NSString * key in textMap.allKeys) {
NSDictionary *addressDic = textMap[key];
//查找所属静态库
   if ([addressDic[@"end"] longLongValue]>callStackBundleAddress &&callStackBundleAddress>=[addressDic[@"start"] longLongValue])
    {
         return key;
    }
}

这样静态库的归属便可以在运行时完成,耗时小于1ms,可以大范围应用于APP各种场景中。

小结:基于Mach-O文件的动态库与静态库归属方案介绍完了,在静态库归属上,相比之前dSYM的方案,它的复杂度更低、易用性更高,可以在运行时实时解析,而且更容易迁移到 其它 APP上使用。

实践项目二:基于Mach-O的API的扫描

背景:在实际工作中,由于经常需要定位APP中调用过的API,为了减少重复的工作,我们实现了一套自动扫描的工具。

API扫描常见的方案是基于语法树的扫描,代码在编译时会生成语法树,通过遍历语法树可以实现API的扫描。

由于语法树扫描存在以下缺点,无法满足使用需求。

1、不支持黑盒扫描,语法树是在编译时才能生成,所以无法扫描三方SDK。

2、扫描速度太慢,语法树扫描的功能强大但是在扫描性能上较低,对2万行代码树的扫描在优化的情况下也需要1分钟左右的时间。

为了解决上面的两个问题,我们实现了基于Mach-O的API扫描方案。

实践项目一中介绍的Mach-O结构,本段落还会继续用到

__objc_classrefs:类引用列表。

__objc_selrefs:方法引用列表。

Mach-O解析的主要步骤:

1、首先解析__objc_classrefs,读取所有调用的外部API,检索被扫描API的类地址记录为class_addr。

2、解析__objc_selrefs,检索被扫描API的方法地址记录为method_addr。

3、把二进制机器码解析出汇编代码,找到所有的bl指令,计算bl指令最近的x1寄存器的值,对比x1寄存器与method_addr是否相等,相等则记录bl指令的位置。

4、在bl指令的位置向上检索是否有要找的类地址class_addr,一直检索到最近的函数入口,找到则输出结果,未找到进入第5步。

5、找到调用者类的起始和结束地址,检索起始与结束地址的所有汇编指令是否存在被扫描API的类,出现则输出结果。

API扫描流程图

反汇编代码:

vm:(unsigned long long)vm {
   mach_header_64 mhHeader;
   //解析头部
   [fileData getBytes:&mhHeader range:NSMakeRange(0, sizeof(mach_header_64))];
   // 获取汇编代码的偏移地址和大小
   char *ot_sect = (char *)[fileData bytes] + begin;
   uint64_t ot_addr = vm + begin;
   csh cs_handle = 0;
   cs_insn *cs_insn = NULL;
   cs_err cserr;
   if ((cserr = cs_open(CS_ARCH_ARM64, CS_MODE_ARM, &cs_handle)) != CS_ERR_OK ) {
       NSLog(@"未能初始化: %d, %s.", cserr, cs_strerror(cs_errno(cs_handle)));
       return NULL;
   }
   // 设置解析模式
   cs_option(cs_handle, CS_OPT_DETAIL, CS_OPT_ON);
   cs_option(cs_handle, CS_OPT_SKIPDATA, CS_OPT_ON);
   // 反汇编
   size_t disasm_count = cs_disasm(cs_handle, (const uint8_t *)ot_sect, size, ot_addr, 0, &cs_insn);
   if (disasm_count < 1 ) {
       NSLog(@"汇编指令解析不符合预期!");
       return NULL;
   }
   return cs_insn;
}

检索方法的范围:

//检索方法偏移范围
do {
       @autoreleasepool {
           unsigned long long index = (end - textList.addr) / 4;
           char *dataStr = s_cs_insn[index].mnemonic;
           //查找是否是函数跳转指令,记录方法的开始和结束地址
           if (strcmp(dataStr, "b")== 0|| strcmp(dataStr, "ret")==0) {
               unsigned long long nextSymoble = end + 4;
               MethodHelper *nextMethodHelper = [objectSymbolMap objectForKey:[NSNumber numberWithUnsignedLong:nextSymoble]];
               if (nextMethodHelper && ![nextMethodHelper.className isEqualToString:className]){
                   //找到类的最后一个函数地址作为类的结束地址
                   callClassHelper.end = end;
                   return callClassHelper;
               }
           }
           end += 4;
       }
   } while (end <= textList.addr + textList.size);

查找objc_msgSend调用位置,检索调用的外部方法名。

依据runtime的原理,objc_msgSend调用时x1寄存器是存放的方法地址,所以主要查找bl指令前的x1寄存器信息。

在扫描过程中发现,在调用objc_msgSend时的寄存器有时是需要ldr指令计算得出,这里涉及到汇编中高低位地址的查找。

汇编指令中会把地址拆分为高低位,所以检索时要注意x1寄存器值的计算过程。如上图所示,需要计算x8寄存器+低位地址0x908,然后对比被检测API的地址与x1寄存器是否相等,最后再向上查找被检测API的类,如果匹配则输出结果。

小结: 至此基于Mach-O的API扫描介绍完了,Mach-O的扫描方式相比传统的语法树扫描,在本质上脱离了代码,可以对任意的动态库和静态库进行扫描,而且扫描过程可以采用多线程分段扫描进行提速,能够在2秒内完成对3万行代码编译产物的扫描,相比语法树扫描在速度上有20倍以上的提升。

在语言上除了对OC方法的扫描,还支持Swift、C方法的扫描,原理与OC扫描类似,这里就不做详细介绍。

总结与展望:

以上是基于Mach-O结构的动态库与静态库的归属和API的扫描方案,已应用于生产环境,助力团队降本提效。

未来我们还会持续在以下方面进行探索与实践:

1、扩展归属检索的范围,如类别、block、常量、C方法等。

2、API使用规范的扫描,基于Mach-O生成简易的调用关系图来查看API的规范情况,可扫描冲突的分类方法,提前发现隐藏的bug。

3、安全方面:防止反编译、动态注入。在iOS应用安全方面,除了通过Mach-O分析来防止反编译和动态注入,开发者还可以使用专业的混淆工具如IpaGuard来加强保护。IpaGuard是一款强大的iOS IPA文件混淆工具,无需源码即可对代码和资源进行混淆加密,支持多种开发平台,有效增加反编译难度。

参考文档:

Overview of the Mach-O Executable Format (apple.com)

Introduction (apple.com)

我的 App 审核被卡了? -- 肘子的 Swift 周报 #128

作者 东坡肘子
2026年3月24日 07:51

issue128.webp

我的 App 审核被卡了?

上周四,我 Discord 社区里的一位网友抱怨,说他的应用在 App Store Connect 上提交了四五天,却迟迟没有进入审核状态。就在我还津津有味地跟大伙儿分析原因时,突然心里一紧:我周一提交的应用,好像也一直没收到审核动态?

有网友建议我去申请一下“加急审核”。可当我点进页面时,系统却提示我“没有可加急的应用”。仔细一查才发现,原来是太久没更新 App,业务都生疏了——我的应用虽然完成了所有前置步骤,但我压根儿就没点那个“提交以供审核”的按钮。

补点按钮没过几个小时,应用就顺利上架了。

尽管我这纯属虚惊一场,但最近社区里关于“苹果审核变慢”的讨论确实多了起来。很多人猜测,这或许与近期 Vibe Coding 的盛行有关。虽然没有官方证实,但 Vibe Coding 确实在降低开发门槛的同时,也在短时间内放大了应用提交的数量与迭代频率,从而把压力传导到了审核环节。

事实上,苹果最近也确实对 Replit 这类允许普通用户进行 Vibe Coding 的应用在审核上进行了卡关。即便允许其上架,也要求在核心功能上做出妥协。在 Michael Tsai 关于此事的博客介绍中,我看到了一条非常敏锐的留言:

这些提供 Vibe Coding 功能的应用(本身也是 Vibe Code 的产物),正被用来批量制造纯靠 Vibe Code 生成的 App 并提交上架。

AI 不仅在重塑开发方式,也正在对应用审核与发行体系提出新的挑战。有人或许会问:如果用魔法打败魔法,让 AI 也全面接管审核流程,会不会更高效?

苹果的审核机制向来不够透明,有时候应用能否顺利过审,甚至取决于是否“碰巧”遇到一位气味相投的审核员。但换个角度看,至少“人”仍然是这道防线中最重要的一环。人的判断会出错,也会带有偏差,但在面对规则时仍保有一定的弹性。

我不希望,未来的软件生态,走向“AI 开发 -> AI 审核”的闭环。

本期内容 | 前一期内容 | 全部周报列表

原创

CDE:一次让 Core Data 更像现代 Swift 的尝试

上周的文章 中,我聊了聊 Core Data 在当下项目中的一些现实处境:它并没有消失,也仍然有其独特价值,但它和现代 Swift 项目之间的错位感却越来越明显。在本文中,我想继续顺着这个问题往下走,介绍我的一个实验性项目:Core Data Evolution(CDE)。

它不是一个取代 Core Data 的新框架,也不是要把开发者重新拉回旧技术。更准确地说,它是我面对这些错位时,给自己的一种回答:如果我仍然认可 Core Data 的对象图模型、迁移体系和成熟运行时能力,那么能不能让它在现代 Swift 项目中以一种更自然的方式继续存在下去?

近期推荐

实现平滑的 SwiftUI List 展开动画 (Expanding Animations in SwiftUI Lists)

开发者经常会遇到一个动画窘境:在动态调整 List 中某一行高度时,内容并不是平滑展开,而是伴随着明显的高度跳变。在本文中,Pavel Zak 通过几个实验,展示了为什么常见的 if 条件渲染、withAnimation 甚至 .transitionList 中都难以达到理想效果。尽管 DisclosureGroup 这种内建方案可以达到预期,但 Pavel 还是给出了一个更灵活的方案:基于 Animatable 与视图尺寸测量的实现方式,让 List 在动画过程中始终获得连续变化的高度,从而实现平滑的展开动画。

List(底层仍然是 UIKit/AppKit 的列表实现)有一个核心特点:它需要在布局阶段就拿到每一行的“确定高度”。因此,对开发者来说,不要让 List 面对结构变化,而是像 DisclosureGroup 那样,将“离散变化”转化为“连续变化”,持续提供可插值的高度值。这也是在处理动画异常时,开发者常常借助 Animatable 协议的原因。想进一步了解该协议的原理与适用场景,可以阅读我之前的一篇文章


如何更好的适配 iPadOS 的布局 (SwiftUI iPad Adaptive Layout: Five Layers for Apps That Don’t Break in Split View)

尽管苹果强化 iPadOS 多窗口能力的初衷是好的,但这也显著提升了开发者在布局适配上的复杂度。应用可能以类 iPhone、传统 iPad 全屏、Stage Manager 窗口等多种模式呈现。Wesley Matlock 指出,仅依赖 horizontalSizeClass 进行布局判断在实际环境中往往是不够的。开发者需要结合容器尺寸与 size class 构建更细粒度的 LayoutEnvironment,并在根视图中统一完成布局分支决策;同时借助 ViewThatFits 等机制,让系统基于真实约束选择最合适的界面形式,而不是由开发者预先假设设备类型。


RGB HDR Gain Map + ImageIO 中的使用陷阱 (Pitfalls and workarounds when dealing with RGB HDR Gain Map using ImageIO)

iOS 18 中引入的基于 ISO 21496-1 标准的 RGB HDR Gain Map,让开发者在处理 HDR 图像时获得了更高的表现力,但在实际应用中也更容易踩坑:尽管相关接口能够返回辅助数据字典,但在 RGB Gain Map 场景下却缺失了实际的位图数据(kCGImageAuxiliaryDataInfoData),导致后续处理无法继续。换句话说,ImageIO 在这一场景下甚至无法完整读取自身生成的内容。Weichao Deng 提出了一种混合方案:使用 Core Image 读取 Gain Map 的 CIImage,手动渲染为 Bitmap Data,补齐缺失字段后,再通过 ImageIO 写回文件。对于正在开发相机或图像处理类应用、需要处理 HDR Gain Map 的开发者来说,这篇文章或许能帮你省下不少调试时间。


Swift 社区的网络愿景 (A Vision for Networking in Swift)

Swift Ecosystem Steering Group 上周发布了一份关于网络编程的愿景文档,讨论了 Swift 网络生态当前的困境以及未来可能的发展方向。

文档指出,Swift 在网络领域存在明显的分裂:URLSession、SwiftNIO 与 Network.framework 并存,功能重叠却互不兼容,开发者往往需要在项目早期就押注某一套技术栈,且切换成本极高。与此同时,现有的大多数网络 API 都诞生于 Swift Concurrency 之前,依赖 completion handler、delegate 或响应式模式,与现代 Swift 的语言特性存在明显脱节。

文档提出的方向是构建一套分层统一的网络架构:底层共享 I/O 原语与缓冲类型,中间层复用 TLS、HTTP/1.1/2/3、QUIC、WebSocket 等协议实现,顶层提供以 async/await 和结构化并发为基础的客户端与服务端 API。已有的 swift-http-types(定义了 HTTPRequest / HTTPResponse)可以视为这一思路的早期实践。文档同时强调,SwiftNIO 和 Network.framework 不会被废弃,而是将逐步向统一的底层原语收敛。

该愿景目前正在征集社区反馈,可以在此参与


让你的 iOS 项目更适合 AI 协作 (Preparing Your iOS Codebase for AI Agents)

随着 AI agent(如 Codex、Claude Code 等)逐渐参与到实际开发流程中,问题开始从“如何使用 AI 写代码”转向“如何让代码库本身适合 AI 协作”。Hesham Salman 从工程实践的角度,系统性地探讨了这一转变。

Hesham 指出,相较于提示词,AI 更依赖显式契约。通过分层的 AGENTS.md 文档明确项目约定与行为规则,使用 Makefile 将构建、测试等操作统一为可执行入口,并通过“skills”将多步骤流程编码为可复用的执行方法,从而将原本隐性的工程知识结构化地嵌入到代码库中。

文章中有一个细节令人印象深刻:作者要求 agent 在发现未记录的约定时主动更新文档,同时加入了一条强约束——每次修改都必须让文档更短或更有用。这一自维护机制既防止文档腐化,也避免文档膨胀,是一个值得借鉴的平衡策略。


iOS Conf SG 2026 视频

2026 年 iOS Conf SG 于 1 月 21 日至 23 日在新加坡举行,来自全球的数十位开发者与内容创作者分享了各自在苹果生态开发中的经验与思考。上周,官方放出了本届的全部演讲视频。我也有幸参与了其中的一场分享,感兴趣的读者可以按需挑选观看。

工具

TaskGate:解决 Actor 重入的工具

尽管 actor 在很大程度上避免了数据竞争,但其可重入(reentrancy)特性也意味着,一些看似串行的逻辑在 await 之后可能失去原有的执行顺序,进而造成重复执行或状态不一致。

Matt Massicotte 编写的 TaskGate 正是为这类场景准备的。它提供了 AsyncGateAsyncRecursiveGate 两种机制,用来为 actor 内部的异步代码定义“临界区”,确保同一时间只有一个任务能够进入相关逻辑。与传统锁不同的是,它允许在持有 gate 的同时安全地执行异步调用。

Matt 明确指出:该库并不是用来替代良好的 actor 设计,而更像是一种在其他手段不够合适时的补充工具。库中将 gate 刻意设计为 non-Sendable,以降低跨 actor 误用的风险。如果你正在处理 actor 重入导致的状态一致性问题,或希望更深入理解 Swift 并发中的这一薄弱环节,这个库以及 Matt 在 Reddit 中的 讨论 都值得一看。


pico-bare-swift

苹果当年创建 Swift 时,对它的期待显然不只是用于开发 App,而是希望它最终成长为一门适用于不同领域、不同层次的通用语言。不过,在相当长一段时间里,Swift 在那些传统上由 C/C++ 或 Rust 主导的领域中,始终没有展现出足够的存在感。kishikawa katsumi 通过这个示例项目展示了另一种可能:借助 Embedded Swift,Swift 已经开始具备进入嵌入式开发场景的能力。

这个项目最吸引我的地方,在于它将一件原本带有明显“底层/C 语言专属”色彩的事情,组织成了一条相当清晰的学习路径。它不仅是“用 Swift 点亮一个 LED”,而是将启动代码、向量表、内存初始化、寄存器访问,以及 UART、PWM、I2C、SSD1306 OLED 等外设驱动一并纳入 Swift 的实现范围之中。某种程度上,这类项目的意义不在于“是否实用”,而在于它重新划定了 Swift 的能力边界

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

独立 App 配置阿里云 CDN 记录

作者 RickeyBoy
2026年3月23日 18:16

独立 App 图片加速:从 OSS 直连到 CDN 的迁移记录。

⚠️ 背景:为什么要做这件事

我的独立 App 里有大量色组图片,存储在阿里云 OSS(深圳节点)。之前图片 URL 是这样的:

https://your-bucket.oss-cn-shenzhen.aliyuncs.com/color-group/example-image.jpg

一直没出什么问题,但最近发现了一个规律:国内用户加载图片很流畅,但国外用户明显慢很多

原因其实很简单——所有用户,不管在哪里,都要跑去深圳的 OSS 拿图片。国内用户到深圳延迟低,自然快;国外用户跨洋传输,当然慢。

所以这次做了一件事:给图片加上 CDN 加速


📚 相关概念

OSS 是什么

OSS(Object Storage Service,对象存储服务)是阿里云提供的云存储服务,可以理解为一个"云端硬盘"——你把文件(图片、视频、文档等)上传上去,它给你一个 URL,任何人通过这个 URL 就能下载或查看文件。

和传统服务器上的文件存储相比,OSS 的优势在于:

  • 容量无限:不需要提前规划磁盘大小,用多少算多少
  • 高可靠:数据自动多副本存储,不用担心硬盘坏了丢数据
  • 按量计费:存储费用和请求次数都是用多少付多少

我的独立 App 中大量图片就存在阿里云 OSS 的深圳节点上。每张图片有一个类似这样的访问地址:

https://your-bucket.oss-cn-shenzhen.aliyuncs.com/color-group/example-image.jpg

其中 your-bucket 是存储桶名称,oss-cn-shenzhen 表示深圳节点。

如何配置阿里云 OSS 可以参考:独立 App 使用阿里云 OSS 的基础配置

CDN 是什么

CDN(Content Delivery Network,内容分发网络)是一个分布在全球各地的缓存节点网络。核心思路是:

把内容缓存到离用户最近的节点,让用户从"最近的服务器"拿数据,而不是每次都跑去源站。

加了 CDN 之后的访问路径变成这样:

用户(UK)→ CDN 节点(英国)→ 命中缓存,直接返回
                             ↓ 首次未命中(cache miss)
                           OSS(深圳)→ 回源取图 → 缓存到节点

第一个访问某张图的用户还是要等回源,但之后同一地区的所有用户都走缓存,速度质的飞跃。缓存是按文件、按节点存储的,跟设备无关——A 设备触发缓存后,B 设备访问同一张图也能命中。

OSS 和 CDN 的关系

OSS 是存储,CDN 是分发。两者是互补关系:

rickey_381.png

  • OSS 负责保存原始文件,是"唯一真实来源"(source of truth)
  • CDN 负责把文件高效地分发给全球用户,OSS 作为 CDN 的回源站

这意味着文件只需要存一份,CDN 自动负责缓存和分发。原来的 OSS URL 永远有效,CDN 只是提供了一条更快的访问路径。

CNAME 是什么

CNAME(Canonical Name)是 DNS 的一种记录类型,作用是把一个域名指向另一个域名

比如这次配置的:

img.example.comimg.example.com.w.cdngslb.com

用户访问 img.example.com 时,DNS 会告诉浏览器"去找 img.example.com.w.cdngslb.com",后者是阿里云 CDN 的接入域名,CDN 再根据用户位置选择最近的节点返回内容。整个过程对用户完全透明。

SSL 证书是什么

HTTPS 需要 SSL 证书来证明"你确实是 img.example.com 的拥有者",同时加密传输内容。

iOS App 默认强制要求 HTTPS(ATS,App Transport Security),所以这一步不能跳过。

证书是跟域名绑定的——之前 OSS 原始域名的证书是阿里云帮你配好的,换了自己的域名之后,证书需要自己申请。


🔀 请求路径对比

加 CDN 之前,所有用户无论在哪里,都直接访问深圳 OSS:

rickey_382.png

加 CDN 之后 · 首次访问(cache miss),请求经 DNS CNAME 解析到最近节点,节点没有缓存则回源 OSS,取回后缓存到节点:

rickey_383.png

加 CDN 之后 · 再次访问(cache hit),节点已有缓存,直接返回,不再访问 OSS:

rickey_384.png

同一地区只有第一次访问某张图时才会回源,之后都命中缓存直接返回。


📊 实测数据

在 UK 网络下对比同一张图片(约 120 KB):

访问方式 耗时 说明
OSS 直连(深圳) 3.74s 每次都跨洋回源
CDN 首次访问(cache miss) 2.68s 需要先回源,但节点在欧洲更近
CDN 命中缓存(cache hit) 0.14s 直接从英国节点返回

命中缓存后快了将近 27 倍。对 App 用户来说,首次打开色组时可能稍慢(第一个用户触发缓存),之后所有用户都是极速加载。


⚙️ 配置过程

一、申请 SSL 证书(Let's Encrypt)

没有选择购买阿里云的 SSL 证书(免费版只有 90 天,且需要手动续期;付费版自动续期要 270 元/次),而是用 Let's Encrypt + acme.sh

Let's Encrypt 是由非营利组织 ISRG 运营的免费证书颁发机构(CA),资金来自 Mozilla、Google、Meta 等企业赞助。它的目标是消除 HTTPS 的经济门槛——2015 年上线后,直接推动全网 HTTPS 覆盖率从约 40% 升到 80% 以上。

acme.sh 是一个纯 Shell 脚本实现的 ACME 协议客户端,用来自动向 Let's Encrypt 申请和续期证书。支持通过 DNS API 验证域名归属,不需要在服务器上部署 web 服务。

这套方案的优势:

  • 完全免费
  • 有效期 90 天,acme.sh 会自动添加 cron 任务每天检查并续期
  • 全球主流设备均信任

安装 acme.sh:

curl https://get.acme.sh | sh -s email=your@email.com
source ~/.zshrc  # 或重启终端

用阿里云 DNS API 自动完成域名验证并申请证书:

export Ali_Key="your_access_key_id"
export Ali_Secret="your_access_key_secret"

acme.sh --issue --dns dns_ali -d img.example.com

acme.sh 在验证域名归属时,会自动调用阿里云 DNS API 临时添加一条 TXT 记录,验证完成后自动删除,全程无需手动操作。

证书文件生成在 ~/.acme.sh/img.example.com_ecc/ 目录下:

img.example.com.cer    # 域名证书
img.example.com.key    # 私钥
fullchain.cer          # 完整证书链(上传到 CDN 用这个)

二、在阿里云 CDN 控制台添加加速域名

前提条件:你需要有一个自己的域名(如 example.com)。如果加速区域包含中国大陆,域名必须完成 ICP 备案,否则阿里云 CDN 不允许接入。仅加速海外则不需要备案。(但是都用阿里云了肯定是支持国内对不对...)

进入 CDN 控制台 → 域名管理 → 添加域名,配置如下:

配置项
加速域名 img.example.com
源站类型 OSS 域名
源站地址 your-bucket.oss-cn-shenzhen.aliyuncs.com
加速区域 全球
业务类型 图片小文件

添加完成后,在 HTTPS 配置 里上传刚才申请的证书(fullchain.cer.key 文件内容)。

另外配置了月流量封顶(100 GB),防止被恶意刷量导致费用失控。

三、配置 DNS CNAME

CDN 控制台会生成一个 CNAME 接入地址,格式类似:

img.example.com.w.cdngslb.com

在阿里云云解析 DNS 添加一条记录:

主机记录 类型 记录值
img CNAME img.example.com.w.cdngslb.com

DNS 记录生效通常需要 10~30 分钟(TTL 决定)。

四、验证是否生效

# 验证 HTTP
curl -I "http://img.example.com/path/to/image.jpg"
# 期望:HTTP/1.1 200 OK

# 验证 HTTPS
curl -I "https://img.example.com/path/to/image.jpg"
# 期望:HTTP/1.1 200 OK

# 验证缓存命中(连续跑两次,第二次应该出现 X-Cache: HIT)
curl -o /dev/null -s -w "time: %{time_total}s\n" "https://img.example.com/path/to/image.jpg"

响应头里的 X-Cache: HIT TCP_MEM_HIT 说明命中了 CDN 缓存。

五、更新代码中的 URL

代码里维护了一份图片 URL 映射表,把所有 URL 的域名部分批量替换成 CDN 域名:

// 替换前
https://your-bucket.oss-cn-shenzhen.aliyuncs.com/color-group/image.jpg

// 替换后
https://img.example.com/color-group/image.jpg

路径部分完全不变,只是换了域名。用 sed 一条命令批量完成,不需要手动逐条修改。


几个值得注意的点

旧版本 App 完全不受影响:OSS 上的文件和原始 URL 永久有效,不会失效。新版本走 CDN,老版本继续走 OSS 直连,两套 URL 并存,互不干扰。

证书续期:acme.sh 安装时会自动注册 cron 任务,每天自动检查并续期。唯一需要手动操作的是:续期后把新证书重新上传到阿里云 CDN 控制台(每 90 天一次,5 分钟的事)。

DNS 免费版够用:阿里云 DNS 免费版没有海外节点,可能有人担心国外用户的域名解析慢。实际上 DNS 解析只在第一次建立连接时发生,耗时是毫秒级,对图片加载速度的影响可以忽略不计,不需要升级付费版。

一个小工具解决Swift Actor重入问题

作者 Aaron_Feng
2026年3月23日 11:18

问题:Actor 不是「一进到底」

Swift 的 Actor 能保证同一时刻只有一个任务在隔离域里执行,但在 await 挂起时,Actor 会释放执行权,其他发往该 Actor 的调用可以继续执行。这就是常见的 Actor 重入(reentrancy):你以为「上一条逻辑还没跑完」,实际上中间已经插入了别的消息处理。

典型后果包括:

  • 生命周期或状态机类 API(初始化 → 运行 → 销毁)被交错执行;
  • 共享资源在「以为仍独占」时被另一条路径修改;
  • 调试时表现为顺序与代码书写顺序不一致

若业务语义要求「前一次异步流程整段结束(包含其内部所有 await)后,再开始下一次」,仅靠 Actor 的默认调度是不够的,需要显式串行化


重入:用打印顺序看清「中间插了一刀」

下面这个 Actor 有两个异步方法,内部都有一次 await Task.yield()(可换成任何真正的异步点):

import Foundation

actor InterleavingDemo {
    func taskA() async {
        print("A: step 1")
        await Task.yield()
        print("A: step 2")
    }

    func taskB() async {
        print("B: only step")
    }
}

从外部几乎同时发起 taskAtaskB

let demo = InterleavingDemo()
await withTaskGroup(of: Void.self) { group in
    group.addTask { await demo.taskA() }
    group.addTask { await demo.taskB() }
}

可能出现的输出之一(重入):

A: step 1
B: only step
A: step 2

含义很具体:taskA 在第一个 await 挂起后,taskB 整段插进来跑完,然后 taskA 才继续。若这里维护的是「会话 / 引擎生命周期」或「依赖连续不变量的状态机」,这种交错往往就是 bug 来源。


思路:用队尾屏障把异步工作连成 FIFO 链

串行异步门闩AASerialAsyncGate)的核心思想是:

  1. 维护一个 tailBarrier:表示「当前队列里,排在队尾之前的那条链何时算全部结束」;
  2. 每次 run 时,新任务必须先 await 前一个屏障,再执行自己的 operation
  3. 执行完毕后更新屏障,让后续任务继续排队。

这样,同一时刻逻辑上只有一条链在执行,新调用不会与前序调用的 await 间隙「插队」到业务语义的前面。


AASerialAsyncGate 实现(源码)

//
//  AASerialAsyncGate.swift
//
//  串行异步任务队列:每次 `run` 将闭包入队到队尾;新任务会等待此前入队的全部任务
//  整段完成(含其内部所有 await)后才开始执行,执行完毕自动出队(屏障前移)。
//  从而避免宿主 Swift Actor 在 await 处重入时,多条生命周期调用交错执行。
//

import Foundation

public final class AASerialAsyncGate: @unchecked Sendable {
    /// 队尾屏障:完成即表示当前队列中此前所有任务均已结束;新任务必须先 `await` 再执行自身逻辑。
    private var tailBarrier: Task<Void, Never> = Task {}

    public init() {}

    /// 将 `operation` 入队;前序任务全部结束后才执行;同一时刻逻辑上仅一条链在执行。
    /// 统一为 `async throws`:`Task.value` 的 Failure 与 `rethrows` 不兼容,故不用 `rethrows`。
    /// 闭包本身不抛错时,宿主侧可用 `try? await gate.run { ... }`。
    public func run<T: Sendable>(
        _ operation: @escaping @Sendable () async throws -> T
    ) async throws -> T {
        let work: Task<T, Error>
        let predecessor = tailBarrier
        work = Task {
            await predecessor.value
            return try await operation()
        }
        tailBarrier = Task {
            _ = try? await work.value
        }
        return try await work.value
    }
}

要点:后来的 run 必须先等「前一个 tailBarrier 代表的整段 operation(含内部所有 await)结束」,因此同一 gate 上的多段逻辑在时间上不会在彼此的 await 缝隙里交错。

为何统一为 async throws

Task.valueFailurerethrows 在类型系统上不易直接对齐,故统一为 async throws;若闭包本身不抛错,调用侧可用 try? await gate.run { ... }


验证示例:对比「仅 Actor」与「Actor + Gate」

1. 仅 Actor:仍可能出现交错

actor EngineWithoutGate {
    private let name: String

    init(name: String) { self.name = name }

    func work(_ label: String) async {
        print("[\(name)] \(label) — before await")
        await Task.yield()
        print("[\(name)] \(label) — after await")
    }
}

func demoActorOnly() async {
    let engine = EngineWithoutGate(name: "E1")
    await withTaskGroup(of: Void.self) { group in
        group.addTask { await engine.work("call-1") }
        group.addTask { await engine.work("call-2") }
    }
}

多次运行或依赖调度,有机会看到 call-2 的整段插在 call-1 的 before/after 之间,这就是要防的重入现象。

2. 加上 AASerialAsyncGate:同一 gate 上严格 FIFO

actor EngineWithGate {
    private let name: String
    private let gate = AASerialAsyncGate()

    init(name: String) { self.name = name }

    func work(_ label: String) async {
        try? await gate.run {
            print("[\(name)] \(label) — before await")
            await Task.yield()
            print("[\(name)] \(label) — after await")
        }
    }
}

func demoWithGate() async {
    let engine = EngineWithGate(name: "E2")
    await withTaskGroup(of: Void.self) { group in
        group.addTask { await engine.work("call-1") }
        group.addTask { await engine.work("call-2") }
    }
}

稳定期望(两段 work 都经同一 gate 排队时):先完整跑完 call-1(before → after),再跑 call-2(before → after),例如:

[E2] call-1 — before await
[E2] call-1 — after await
[E2] call-2 — before await
[E2] call-2 — after await

可将 print 换成收集到 [String] 的回调,在 XCTest 中断言顺序,作为自动化验证。

3. 宿主是 Actor 时的典型用法

Actor 仍会在自己的方法之间重入;门闩只保证「放进 gate.run 里的那几段」彼此不穿插。生命周期 API 可全部包在 lifecycleGate.run 里:

actor Service {
    private let lifecycleGate = AASerialAsyncGate()

    func startSession() async {
        try? await lifecycleGate.run {
            await connect()
            await configure()
        }
    }

    func stopSession() async {
        try? await lifecycleGate.run {
            await teardown()
        }
    }

    private func connect() async { await Task.yield() }
    private func configure() async { await Task.yield() }
    private func teardown() async { await Task.yield() }
}

小结

场景 说明
重入例子 同一 Actor 上两个 async 方法并发调用时,await 后可能打印出 A1 → B → A2
门闩行为 AASerialAsyncGatetailBarrier 链式 Task,保证后一次 run 等前一次整段结束。
代价 额外 Task 调度与内存;只适合「必须严格串行」的路径。

工程内若存在与 AASerialAsyncGate 等价的类型,可直接对照实现。

iOS 学习笔记 - SwiftUI 和 简单布局

2026年3月22日 16:15

前言

通过之前的章节,可以了解到简单的 Swift 语言编程和基于 Swift UI 的项目创建。有了这些基础知识,就可以开始了解具体的项目开发了。

Swift UI 布局

之前的章节中,创建好了一个 iOS App 项目,项目中给出了一个模板代码——一个简单的 Hello World 的 App 页面。

image.png

具体代码为

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

#Preview { // 通过设个代码,可以再右侧看到当前的预览
    ContentView()
}

这里面,出现了 SwiftUI 最核心的布局容器和视图修饰符。

这段简单的代码,包含了 SwiftUI 布局的底层逻辑:所有界面都是由「容器 + 子视图 + 修饰符」组合而成,容器负责排列视图,修饰符负责美化视图。

核心布局容器

SwiftUI 中核心的基础布局容器是三大栈容器,分别为:VStack、HStack、ZStack。接下来进行详细介绍。

1. VStack 垂直栈

正如我们看到的预览一样,这里使用了 VStack,它的作用是内部的子视图垂直从上到下排列。这也是最常见的布局排列方式。这里可以看到,代码中,图片和文字就是上下排列的。

2. HStack 水平栈

HStack 的作用是将内部的子视图水平从左到右排列,修改示例代码的布局代码为 HStack

HStack {
    Image(systemName: "globe")
        .imageScale(.large)
        .foregroundStyle(.tint)
    Text("Hello, world!")
}

可以看到预览的页面样式已经发生了改变,图片在左侧,文字在右侧。变为了横向排列。

image.png

3. ZStack 层叠栈

ZStack的作用为,让子视图层叠叠加,后写的视图覆盖在先写的视图之上。通常可以用作背景、图标叠加、文字覆盖图片等效果。

这里修改代码,使用ZStack方式。

ZStack { 
    Circle() 
        .fill(Color.blue.opacity(0.2)) 
        .frame(width: 100, height: 100) 
    VStack { 
        Image(systemName: "globe")
        Text("Hello")
    }
}

这里,创建了一个圆,作为展示内容的背景,效果如下。

image.png

容器的参数

上一部分了解了 iOS App 界面是由不同容器嵌套构成的,这一点很像 HTML 的不同元素的嵌套,只不过声明的方式不太一样。同样的在 Swift UI 中,对于这些容器,也是由参数可以控制很多内容。

刚刚了解到的容器中,通常都支持两个关键参数:alignment(对齐方式)和 spacing(子视图间距),这也是调整布局的核心工具。

1. spacing:统一设置子视图间距

模板代码中图片和文字是紧贴的,添加 spacing 可以拉开距离:

// 垂直排列,子视图间距 20
VStack(spacing: 20) {
    Image(systemName: "globe")
        .imageScale(.large)
        .foregroundStyle(.tint)
    Text("Hello, world!")
        .font(.title) // 字体放大
}
.padding()

效果如下,可以看出区别,两个元素的距离和之前不一样了。

image.png

2. alignment:设置对齐方式

  • VStack 常用对齐:.leading(左对齐)、.center(居中,默认)、.trailing(右对齐)
  • HStack 常用对齐:.top(顶部对齐)、.center.bottom(底部对齐)

再次修改示例代码,改成全部展示文字,使用左对齐的垂直布局

VStack(alignment: .leading, spacing: 15) {
    Text("标题")
        .font(.headline)
    Text("这是一段说明文字,演示左对齐效果")
        .font(.subheadline)
}
.padding()

可以看到效果,文字现在是作对齐的。

image.png

视图修饰符

从模板代码中,可以看到,.imageScale.foregroundStyle.padding 都是修饰符 ,作用是修改视图的样式、尺寸、位置。在 SwiftUI 中,修饰符是可以通过链式调用的,这是一种设计模式可以实现的,不过这里调用的顺序会影响最终效果,这里可以先结合之前见到过的了解一些最常用的几个:

1. padding:内边距

给视图添加四周的空白间距,让界面不拥挤:

Text("Hello")
    .padding() // 四周默认间距
    .padding(.top, 20) // 单独给顶部加间距

2. frame:设置尺寸

强制设置视图的宽度、高度:

Image(systemName: "globe")
    .frame(width: 50, height: 50) // 固定图片大小

3. foregroundStyle:前景色

修改文字、图标颜色:

.foregroundStyle(.red) // 红色
.foregroundStyle(Color.blue) // 蓝色

4. font:设置字体

Text("Hello")
    .font(.title) // 标题字体
    .font(.system(size: 24, weight: .bold)) // 自定义大小、加粗

四、完整实战:优化 Hello World 页面

把之前了解到的布局知识整合起来,改造模板代码,做出一个更美观的页面:

import SwiftUI

struct ContentView: View {
    var body: some View {
        // 垂直容器:居中、间距20
        VStack(spacing: 20) {
            // 图标
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.blue)
                .font(.system(size: 60)) // 放大图标
            
            // 主标题
            Text("Hello, SwiftUI!")
                .font(.title)
                .fontWeight(.bold)
            
            // 副标题
            Text("这是我的第一个SwiftUI布局页面")
                .font(.subheadline)
                .foregroundColor(.gray)
        }
        // 整个容器添加内边距
        .padding()
        // 设置最大宽度,适配屏幕
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        // 背景色
        .background(Color(.systemGray6))
    }
}

#Preview {
    ContentView()
}

运行预览后,会得到一个居中、有间距、有层级、配色舒适的标准页面,这就是 SwiftUI 布局的魅力 ——用极少的代码,实现清晰、灵活的界面

image.png

Compositional layout in iOS

作者 songgeb
2026年3月20日 17:28

Compositional layout是在2019年为UICollectioinView引入的一个新布局

Compositional layout是什么

  • Compositional layout是一套针对UICollectionView新的布局方法
  • 对应的核心类是UICollectionViewCompositionalLayout(macOS上是NSCollectionViewCompositionalLayout)
  • 其目的是让UICollectionView可以更容易地支持更灵活布局UI的开发

Compositional layout布局的三大设计哲学:

  1. Composable:可组合,强调用简单的组件组合出复杂的内容
  2. Flexible:灵活(官方说,You can write any layout with Compositional layout)
  3. Fast

几个例子感受一下Compositional layout能做什么

  1. 如下示意图展示了一个纵向滚动的UICollectionView,其中有上中下三部分,上部分看上去像两列UITableView,各部分的布局和样式各不相同

image.png

  1. 如下示意图展示了纵向滚动的UICollectionView,其中横向上有多个可以横向滚动的组(App Store应用大量使用该布局)
    • 看到这里我立马想到了:可能再也不用多个UICollectionView嵌套了

IMG_1035.PNG

四个核心概念

Compositional layout由四个最核心的概念组成

Item > Group > Section > Layout

  • ItemLayout,表示的范围依次扩大
  • 任何一个Compositional layout都从左到右组合而成

image.png

如何使用Compositional layout

Compositional layout的核心类是UICollectionViewCompositionalLayout,其初始化方法有两类,如下代码所示:

  • 一类是直接提供section,另一类是通过provider动态的提供section
class UICollectionViewCompositionalLayout : UICollectionViewLayout {

    public init(section: NSCollectionLayoutSection)

    public init(section: NSCollectionLayoutSection, configuration: UICollectionViewCompositionalLayoutConfiguration)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)
}

创建UICollectionViewCompositionalLayout的过程就是上小节提到的

Item > Group > Section > Layout

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                     heightDimension: .fractionalHeight(1.0))

let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalWidth(0.2))

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                 subitems: [item])

let section = NSCollectionLayoutSection(group: group)

let layout = UICollectionViewCompositionalLayout(section: section)

OrthogonalScrolling

再介绍一个官方Demo中提到的稍微复杂一点的Compositional layout案例

我们希望最终效果如下图所示:

Screenshot 2026-03-20 at 16.33.00.png

首先进行设计:

  • 整体纵向滚动,横向上有多行,每一行也是可以滚动的,每一行我们可以看做是一个Section
  • 关注到每一行中的元素,每一行中基本的滚动单元是:左侧的一个大块+右侧两个小块,滚动单元可以认为是Group
  • 具体的大小块则可以认为是Item

关于“左侧的一个大块+右侧两个小块”的示意图如下所示:

image.png

以下是Compositional layout代码,我们对照注释看一下创建过程:

// 1. 左侧大块Item的创建
// - 宽度:希望占容器(group)宽度的70%
// - 高度:希望和容器一样高
let leadingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                      heightDimension: .fractionalHeight(1.0)))
leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 2. 右侧任意的一个小块
// - 宽度:和容器一样宽
// - 高度:占容器高度的一半
let trailingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalHeight(0.5)))
trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 3. 创建一个容器group,这是一个纵向的容器,容纳右侧的两个小块Item。宽度占该group所在容器的30%;高度和容器一致
let trailingGroup = NSCollectionLayoutGroup.vertical(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                       heightDimension: .fractionalHeight(1.0)),
    repeatingSubitem: trailingItem,
    count: 2)
    
// 4. 创建一个横向容器group,容纳1个大块+2个小块。宽度占其容器的85%,高度占40¥
let containerGroup = NSCollectionLayoutGroup.horizontal(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                      heightDimension: .fractionalHeight(0.4)),
    subitems: [leadingItem, trailingGroup])
// 5. 创建section,包含最外层的横向容器group
let section = NSCollectionLayoutSection(group: containerGroup)
section.orthogonalScrollingBehavior = .continuous
// 6. 创建layout,使用默认configuration,默认是纵向滚动
return UICollectionViewCompositionalLayout(section: section)

再看一下数据源代码:

var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
var identifierOffset = 0
let itemsPerSection = 30
for section in 0..<5 {
    snapshot.appendSections([section])
    let maxIdentifier = identifierOffset + itemsPerSection
    snapshot.appendItems(Array(identifierOffset..<maxIdentifier))
    identifierOffset += itemsPerSection
}
  • 整体数据是一个二维数组,类似这样:[[0,1...29], [30...59], [...], [...], [...]]
  • 创建了5个Section,每个Section
  • 每个Section中有30个数字
  • 30个数字拆分成了10个Group,每个Group有三个Item。如果对应着数据源,则依次是[0,1,2], [3,4,5].....
  • 每个数字表示一个Item

其他

UICollectionLayoutListConfiguration.Appearance

orthogonalScrollingBehavior

orthogonal(发音:/ôrˈTHäɡən(ə)l/):正交。但并非数学上的概念,而是指,与指定方向是正交方向的另一个方向。说白了,如果制定的滚动方向是垂直,则orthogonalScrolling(正交滚动方向)就是水平方向

问题

1. 如何横向滚动

Demo中都是纵向滚动的,Compositional layout是否支持横向滚动?

当然,如下所示,不过要注意一下写法

  • 自定义UICollectionViewCompositionalLayoutConfiguration,设置scrollDirection即可

如果尝试修改因UICollectionViewCompositionalLayout(section: section)而自动创建的UICollectionViewCompositionalLayoutConfiguration.scrollDirection可能不起作用

let configuration = UICollectionViewCompositionalLayoutConfiguration()
configuration.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(section: section, configuration: configuration)

1. iOS 26.3中带count参数的Group初始化方法有bug

  • 通过horizontal(layoutSize:repeatingSubitem:count:)创建的group,无法做到按照count对item等分布局
  • 但通过horizontal(layoutSize:subitems:)或者已经废弃的horizontal(layoutSize:subitem:count:)可以正确实现

按照如下代码中所示的:

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)

2. 官方Demo-AdaptiveSections的bug

  • 官方Demo中,AdaptiveSections部分,会根据容器宽度决定一行显示的列数
  • 但在iOS 26.3中测试,旋转到横屏后,列数并没有按照预期增加
  • 未定位到原因

参考

iOS动画浅谈

作者 UTF_8
2026年3月20日 14:51
  1. 从什么开始?

当我开始写这个文档的时候,我就想如何开始这个话题。因为是团队内分享,还是有必要简单介绍一下iOS开发框架内的一些前置知识,或许能让不同背景的同学也能够理解一些基本概念。

客户端开发工程师日常做的大部分工作都是在根据设计的UI稿来写代码完成特定的需求,如果类比成画家的话,你写的代码和Apple提供的基础框架,就是画家手中的笔和颜料。

从最简单的开始,当你要在屏幕画出一个红色背景的矩形。Apple提供了一些最基本的元素的类来做这个事情,它就是 Class UIView,Apple官方对它的描述如下:

UIView
An object that manages the content for a rectangular area on the screen.

那么你可以这样实现创建一个View,创建成功之后,就可以把它添加到你的UI层级种。

UIView *customView = [[UIView alloc] init];
customView.frame = CGRectMake(50, 200, 200, 200);
customView.backgroundColor = UIColor.redColor;

在手机屏幕上的显示如下:

在App界面上,定位一个UI元素需要它的 positionsize,也就是在当前基于点的坐标系下的矩形左上角的点的位置,宽和高的大小。

往下看一点

在上面我们看到使用 UIView 这个 AppKit 框架提供的 class,我们就可以画出一个红色背景的矩形。但其实在底层iOS使用的是 Core Animation 这个框架来实现的,其架构示意图如下:

Core Animation 是 iOS 和 macOS 平台上的图形渲染和动画基础架构,可用于为应用的视图和其他视觉元素添加动画效果。Core Animation 会自动完成绘制动画每一帧所需的绝大部分工作。

Core Animation

Core Animation 提供了一个通用的系统,用于为应用程序中的视图和其他视觉元素添加动画效果。这个功能是由 Class CALayer实现的,Apple官方对它的描述如下:

Class
CALayer
An object that manages image-based content and allows you to perform animations on that content.

An object that manages image-based content and allows you to perform animations on that content. (a layer captures the content your app provides and caches it in a bitmap)

CALayer 和 UIView 之间的关系

在 iOS 中,每个UIView都由一个对应的CALayer对象支持,视图只是图层对象的一个简单封装,因此对图层进行的任何操作通常都能正常工作。但在 macOS 中,您必须决定哪些UIView应该使用CALayer。如果是代码表示的话,可以理解为 Class UIView 有一个类型为 CALayer 的属性。

class UIView {
    CALayer *layer;
}

如果是使用了CALayer支持的View,则称为 layer-backed viewlayer-backed view 则由系统负责创建底层CALayer对象,并保持该CALayer与UIView同步。所有 iOS 视图都是图层支持视图,OS X 中的大多数视图也是如此。在 Mac App 开发中,如果需要使用 layer 作为 backen store,需要做如下设置。

NSView *customView = [[NSView alloc] init];
[customView setWantsLayer:YES];

既然CALayer可以绘制内容,为什么还需要UIView呢。图层不处理事件、不绘制内容、不参与响应链,所以在我们的应用仍然需要一个或多个视图来处理这些类型的交互。

一颗树

在我们的app,每一个View都有其superView 和 subViews(如果有的话)。这样就构成了app的视图层树(view tree)。

class UIView {
    CALayer *layer;
    UIView *superView;
    NSArray<UIView *> subViews;
}

上面我们说到,在iOS上面,每一个UIView有其底层的Layer,所以实际上是由对应的图层树(layer tree)。

三棵树

实际上在使用 Core Animation 的界面中,有三组不同的 Layer Tree。每组图层对象在使应用程序内容显示在屏幕上方面都扮演着不同的角色。分别为:

  • model layer tree :用于存储所有动画的目标值。每当您更改图层的属性时,都会用到这些对象。
  • presentation layer tree:表示当前动画 layer 的实时状态。
  • render tree:用户真正执行动画,并且是私有的。

只有动画在播放时,才能够访问到 presentation tree 中的layer对象,presenter tree 中的layer对象表示的是动画的实时值。这和 model layer tree 上的不同,它上面的对象反应的是代码设置的最后一个值。

从上面我们可以大概勾勒出 Class CALayer的定义:

Class Layer {
    // public
    // Returns a copy of the presentation layer object that 
    // represents the state of the layer as it currently appears onscreen.
    - (instancetype) presentationLayer;
    
    // public
    // Returns the model layer object associated with the receiver, if any.
    - (instancetype) modelLayer;
    
    // private
    - (instance) renderLayer;
}

如下代码展示了当你设置 view 的属性时,其实此时设置的是 model layer 对象的值。此时看如下代码的打印结果:

UIView *customView = [[UIView alloc] init];
customView.frame = CGRectMake(20, 20, 20, 20);

NSLog(@"---------- custom.layer ----------");
    NSLog(@"customView.layer address = %p", customView.layer);
    NSLog(@"customView.layer.frame = %@", @(customView.layer.frame));
    
    NSLog(@"---------- custom.layer.modelLayer ----------");
    NSLog(@"customView.layer.modulLaye.address = %p", customView.layer.modelLayer);
    NSLog(@"customView.layer.modelLayer.frame = %@", @(customView.layer.modelLayer.frame));
    
    NSLog(@"---------- customView.layer.presentationLayer ----------");
    NSLog(@"customView.layer.presentationLayer.address = %p", customView.layer.presentationLayer);
    NSLog(@"customView.layer.presentationLayer.frame = %@", @(customView.layer.presentationLayer.frame));

从上面的打印结果可以看出:

  • 当你设置 view 的 frame 时,实际上就是设置了backed store 的 layer 的属性。
  • modelLayer 返回的对象就是 layer 本身。
  • presentationLayer 为nil,当没有动画被添加到 layer 时,就不会创建它。
  1. 动画

一个简单的动画

Apple 提供了框架能够使得开发人员方便的执行动画。首先介绍一下使用 CABaseAnimation 来创建一个简单的位移动画,并且观察一下 presentationLayer 的状态值。

代码如下:

  • 创建一个动画
- (CABasicAnimation *)basicAnimation {
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position.x"];
       
       // 设置动画属性
    animation.fromValue = @(25.0f);      // 起始值
    animation.toValue = @(300.0f);        // 结束值
    animation.duration = 5.0f;          // 持续时间5秒
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
       
    // 保持动画结束后的状态
    animation.fillMode = kCAFillModeForwards;
    animation.removedOnCompletion = NO;
       
    return animation;
}
  • 创建一个 view,并添加位移动画。
// 创建一个view,设置其frame
 self.customView = [[UIView alloc] init];
 self.customView.frame = CGRectMake(0, 400, 50, 50);
 CABasicAnimation *animation = [self basicAnimation];
 animation.delegate = self;
 [self.customView.layer addAnimation:animation forKey:@"animation"];
  • 创建定时器,打印出状态信息
- (void)startTimer {
    // 创建定时器,每秒打印一次View的值
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f
                                                  target:self
                                                selector: @selector(printViewInfo)
                                                userInfo:nil
                                                 repeats:YES];
}

- (void)printViewInfo {
    NSLog(@"---------- 第%@次开始打印 ----------", @(self.printCount + 1).stringValue);
    NSLog(@"---------- custom.layer ----------");
    NSLog(@"customView.layer address = %p", self.customView.layer);
    NSLog(@"customView.layer.frame = %@", @(self.customView.layer.frame));
    
    NSLog(@"---------- custom.layer.modelLayer ----------");
    NSLog(@"customView.layer.modulLaye.address = %p", self.customView.layer.modelLayer);
    NSLog(@"customView.layer.modelLayer.frame = %@", @(self.customView.layer.modelLayer.frame));
    
    NSLog(@"---------- customView.layer.presentationLayer ----------");
    NSLog(@"customView.layer.presentationLayer.address = %p", self.customView.layer.presentationLayer);
    NSLog(@"customView.layer.presentationLayer.frame = %@", @(self.customView.layer.presentationLayer.frame));
    
    NSLog(@"---------- 第%@次结束打印 ----------", @(self.printCount + 1).stringValue);
    NSLog(@"----------------------------------");
    self.printCount += 1;
}

一个简单的位移动画如下:

modelLayer 和 presentationLayer 的打印结果如下:

中间状态 结束状态
å

从上面可以看出,动画过程中,presentationLayer 的状态就是动画展示的值。因为代码中没有重新设置modelLayer 的状态,所以frame仍然是初始状态。

model layer 还是 presentation layer

一个动画可以分为三个状态,开始,激活和结束。

A表示添加动画到layer (可以设置动画延后执行的时长)

B表示动画真正开始执行

C表示动画执行完成

暂时无法在飞书文档外展示此内容

当使用 CABaseAnimation 设置不同的参数,决定了动画开始和结束画面呈现使用的是 model layer 还是 presenta layer。也就是 fillModelremovedOnCompletion 字段。

重新回到设置 animation 的地方

- (CABasicAnimation *)basicAnimation {
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position.x"];
       
       // 设置动画属性
    animation.fromValue = @(25.0f);      // 起始值
    animation.toValue = @(300.0f);        // 结束值
    animation.duration = 5.0f;          // 持续时间5秒
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    
    // 动画执行的开始时间
    animation.beginTime = CACurrentMediaTime() + 3;
    // 保持动画结束后的状态
    animation.fillMode = kCAFillModeForwards;
    animation.removedOnCompletion = NO;
       
    return animation;
}

参数不同,影响的是A,B,C三个点使用的 layer,以及layer的状态值

当你没有 removeOnCompletion = NO 时(默认为YES),动画结束后的都会恢复到 model layer 值的状态。

动画效果是,开始状态为在屏幕左边缘,动画是从屏幕中间一段位置的x方向上的位移。动画延后3s执行,执行时长为 5 s。

image.png

如果你是Apple开发人员

基于 Core Animation 绘制内容

如何在屏幕上展示出内容,Core Animation 基于客户端开发人员写出的代码,来计算出当前页面layer的状态,最后由硬件处理,渲染在屏幕上。再回到下面这张图:

界面上的UI元素可以看作是Layer Tree,当你要获取Layer Tree所有结点的状态,需要遍历Layer Tree。

- (void)traverseLayer:(CALayer *)root {
    handleLayer(root);
    for (CALayer *subLayer in root.subLayers) {
        handleLayer(subLayer);
        traverseLayer(subLayer);
    }
}

- (void)handleLayer:(CALayer *)layer {
    for (CABaseAnimation *animation in layer.allAnimations) {
        handleLayer(layer, animation);
    }
}

- (void)handleLayer(CALayer *)layer withAnimation:(CABaseAnimation *)animation {
    if (动画未开始执行) {
            根据 fillMode,设置 presentation layer 状态。
        } else if (动画执行中) {
            // 根据 animation keyPath 更新 presentation layer 状态
            // 假设是 position.x,位移动画。
            // 当前动画执行的时间t,线性变换
            rate = (animation.toValue - animation.fromValu) / 动画设定的执行时间;
            x = rate * t + animation.fromValue;
            layer.frame.origin.x = x;
        } else if (动画执行完毕) {
            根据 fillMode,设置 presentation layer 状态。
        } else {
            // 未知状态
        }
}

3. # 优化一点点

history

之前做过一个 PK 动画,如下:

可以看出上面的动画左右两边的组件有一种“突变”的效果。后面又看了下其他app做的PK动画。

具体实现代码是如下:

根据双方投票PK人数,计算出占总人数的比例。

然后根据比例画出对应的图形,使用 UIBezierPath,是iOS 中用于绘制 2D 矢量图形的核心类。

 UIBezierPath *path = [self getPathWithPercent:percent];
 CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"path"];
 animation.fromValue = ( __bridge id)layer.path;
 animation.toValue = ( __bridge id)path.CGPath;
 animation.duration = 0.3;
 animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
 [layer addAnimation:animation forKey:@"animation"];

在我们创建的动画中,keyPath 是 “path”。表示执行的动画可以从一个图形变换到另外一个图形,也就是我们上面看到的PK动效。

但是这种方式是不可控的,比如这篇文章提到使用 path 作为 CABaseAnimation 的 keyPath,会达不到预期的效果。

// Core Animation 内部大致实现:
void displayLinkCallback() {
    // 1. 计算时间进度 (0.0 ~ 1.0)
    CGFloat progress = (currentTime - startTime) / duration;
    
    // 2. 应用时间函数(timingFunction)
    progress = timingFunction(progress);
    
    // 3. 插值计算
    // 这里的插值运算没法精准的计算出当前的 UIBezierPath path 的值
    id currentValue = interpolate(fromValue, toValue, progress);
    
    // 4. 更新表现层
    [presentationLayer setValue:currentValue forKeyPath:keyPath];
    
    // 5. 触发重绘
    [presentationLayer setNeedsDisplay];
}

now

既然使用 path 做动画达不到预期的效果。

可以重写 - (void)drawInContext:(CGContextRef)ctx方法,来自定义Layer的内容。

自定义 CustomPKLayer

 @interface CustomPKLayer : CALayer

@property (nonatomic , assign) CGFloat progress;

@end
 @implementation CustomPKLayer

// 让 Core Animation 的属性动画系统来管理这个属性
@dynamic progress;

// 用于指定哪些键值改变时需要自动重绘视图
// 更改属性值的动画也会触发重新显示
+ (BOOL)needsDisplayForKey:(NSString *)key {
    // 当对某个keyPath,这里使用 progress
    if ([key isEqualToString:@"progress"]) {
        return YES;
    }

    return [super needsDisplayForKey:key];
}

// 自定义图层的内容绘制
- (void)drawInContext:(CGContextRef)ctx {
    CGFloat rate = self.progress;
    CGFloat width = rate == 1 ? 329 : 329 * rate;
    CGFloat height = self.frame.size.height;
    CGFloat radius = 18;
    UIBezierPath *path = [UIBezierPath bezierPath];
    [path addArcWithCenter:CGPointMake(18, 18) radius:radius startAngle:M_PI_2 endAngle:3 * M_PI_2 clockwise:YES];
    
    if (rate == 1) {
        [path addLineToPoint:CGPointMake(width - 18, 0)];
        [path addArcWithCenter:CGPointMake(width - 18, 18) radius:radius startAngle:-M_PI_2 endAngle:M_PI_2 clockwise:YES];
        [path addLineToPoint:CGPointMake(18, height)];
    } else {
        [path addLineToPoint:CGPointMake(width - 3, 0)];
        [path addQuadCurveToPoint:CGPointMake(width - 1.5, 3) controlPoint:CGPointMake(width , 0)];
        [path addLineToPoint:CGPointMake(width - 15, height - 2)];
        [path addQuadCurveToPoint:CGPointMake(width - 19, height) controlPoint:CGPointMake(width - 16, height)];
        [path addLineToPoint:CGPointMake(18, 36)];
    }
    CGContextAddPath(ctx, path.CGPath);
    CGContextStrokePath(ctx);
    CGContextSetFillColorWithColor(ctx, [UIColor redColor].CGColor);
    
    NSDate *now = [NSDate date];
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
    NSString *formattedDate = [formatter stringFromDate:now];
    
    NSLog(@"---------- drawInContex address ----------");
    NSLog(@"[%@]: call drawInContext modelLayer address = %p", formattedDate, self.modelLayer);
    NSLog(@"[%@]: call drawInContext presentationLayer address = %p", formattedDate, self.presentationLayer);
    NSLog(@"---------- drawInContex address ----------");
}

执行动画:

CABasicAnimation *leftAnimation = [CABasicAnimation animationWithKeyPath:@"progress"];
leftAnimation.fromValue = @(0.5f);
leftAnimation.toValue = @(0.7f);
leftAnimation.duration = 3;
leftAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
leftAnimation.removedOnCompletion = NO;
leftAnimation.fillMode = kCAFillModeForwards;
    
[self.leftMaskLayer addAnimation:leftAnimation forKey:@"leftProgress"];
    
CABasicAnimation *rightAnimation = [CABasicAnimation animationWithKeyPath:@"progress"];
rightAnimation.fromValue = @(0.5f);
rightAnimation.toValue = @(0.3f);
rightAnimation.duration = 3;
rightAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
rightAnimation.removedOnCompletion = NO;
rightAnimation.fillMode = kCAFillModeForwards;
    
[self.rightMaskLayer addAnimation:rightAnimation forKey:@"rightProgress"];

这样执行动画,就能精准控制动画的执行,并且画出自定义的Path。这里时间有限,仅仅是测试了一个能正确展示PK动效的路径图形。

打印一些信息

前面说到我们重写了 - (void)drawInContext:(CGContextRef)ctx,在这个方法中,动画结束之后,在其中打印地址是不是可以判断当前是用 presentationLayer 还是 modelLayer 来绘制内容。

动画结束后打印:

- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
    if (anim && flag) {        
        NSDate *now = [NSDate date];
        NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
        [formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
        NSString *formattedDate = [formatter stringFromDate:now];
        
        NSLog(@"---------- animation did stop ----------");
        NSLog(@"[%@]: leftMasklayer.modelLayer address = %p", formattedDate, self.leftMaskLayer.modelLayer);
        NSLog(@"[%@]: leftMasklayer.presentationLayer address = %p", formattedDate, self.leftMaskLayer.presentationLayer);
        NSLog(@"---------- animation did stop ----------");
    }
}

动画结束后:- (void)drawInContext:(CGContextRef)ctx 打印

- (void)drawInContext:(CGContextRef)ctx {
    // 省去绘制代码
    
    NSDate *now = [NSDate date];
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
    NSString *formattedDate = [formatter stringFromDate:now];
    
    NSLog(@"---------- drawInContex address ----------");
    NSLog(@"[%@]: call drawInContext modelLayer address = %p", formattedDate, self.modelLayer);
    NSLog(@"[%@]: call drawInContext presentationLayer address = %p", formattedDate, self.presentationLayer);
    NSLog(@"---------- drawInContex address ----------");
}

animation 的设置不同

animation.removedOnCompletion
animation.fillMode
removedOnCompletion 打印结果
NO
YES
  1. 总结

  • 当没有动画添加到 layer 时,对应的 presentationLayer 不会被创建。
  • 当设置 layer (modelLayer)的相关属性时,如果 presentationLayer 不会空,则其值保持和 modelLayer 一致。
  • 设置的动画参数,决定了动画开始执行前,动画执行中,动画执行完成后,使用的是 presentationLayer 还是 modelLayer。
  • 可以通过自定义 animation keyPath,来绘制 layer 的内容,从而实现更加精准的动画。
  1. 参考

  1. Loading动画外篇:圆的不规则变形
  2. Core Animation Programming Guide

穿透内容审查与阻断:基于 DNS TXT 记录的动态服务发现与客户端安全加固实践

作者 eleven4096
2026年3月20日 13:55

✍️ 引言

在开发面向全球或特定复杂网络环境的 App(如 XXX、跨境电商、海外加速等)时,最大的痛点往往不是业务逻辑,而是服务端的生存能力。为了对抗域名污染 (DNS Poisoning)SNI 阻断 以及 证书审查,我们通常需要一套极其灵活的「备用链路」与「动态发现」机制。

本文将结合在 iOS/Swift 项目中的实际落地经验,深度剖析一套基于 DNS TXT 记录 派发动态入口域名双向 mTLS 证书(p12)基码 以及 原生 TCP 直连 IP 的高可用架构,并详解其间的技术难点与避坑指南。


🛠 一、 核心架构设计

我们的目标是:哪怕主 Base 域名完全死锁,客户端只要能向公用 DNS 发一个查询,就能满血复活。

1. 数据如何藏在 DNS TXT 里?

由于一台域名的 A记录 只能存 IP,且极其容易被封锁,我们选择将配置加密后塞入 DNS 的 TXT 记录。 我们使用了多级子域名来承载不同的模块(由于 TXT 字符长度限制,需要分片):

子域名 (Subdomain) 承载内容特征 安全措施
root.yourbase.com 加密后的后备 HTTPS 业务 API 域名列表 AES-128-ECB 加密 + Base64
1.yourbase.com mTLS 客户端证书 P12 文件的 Base64 前半段 纯文本分片拼装
2.yourbase.com mTLS 客户端证书 P12 文件的 Base64 后半段 纯文本分片拼装
ip.yourbase.com 绕过 SNI 审查的裸 TCP 直连 IP 点对点通道 纯文本

🧠 二、 技术难点与避坑指南

难点 1:iOS 系统 API 无法直接发起原生 UDP DNS 查询

🚨 问题背景:  iOS 的 getaddrinfo 或者 NWHostResolver 是高层级 API,它们往往只返回处理好的 IP 地址(A/AAAA 记录),极难直接读取到 TXT、SRV 记录。如果调用系统的 res_nquery(属于 C 层的 libresolv),在弱网下容易造成线程死锁,且容易触发 iOS 严格的后台审计。

💡 解决方案:使用 Network 框架手工构建 UDP 53 端口查询 我们在 Swift 中封装了一个 DNSResolver,通过 NWConnection(to: 53, using: .udp) 手工下发标准 DNS 报文(RFC 1035)

  1. 构造 DNS 查询帧

    swift
    var data = Data()
    let id = UInt16.random(in: 1...65535)
    data.append(contentsOf: id.bigEndianBytes)
    data.append(contentsOf: UInt16(0x0100).bigEndianBytes) // Flags: 标准查询
    data.append(contentsOf: UInt16(1).bigEndianBytes)      // Question 数量 1
    // ... 拼接子域名 QNAME、QTYPE 为 16 (TXT)
    
  2. 并发查询优化: 由于国内 DNS 偶尔会有运营商后门或缓存污染,我们使用 withTaskGroup 并发地向四个公共 DNS 服务器发送请求 (223.5.5.5114.114.114.1148.8.8.81.1.1.1),谁最快返回合法的 TXT 内容,就直接 cancelAll() 结束任务


难点 2:UDP 的截断陷阱 (Truncated) 与 TCP 回退

🚨 问题背景:  由于拼装了庞大的客户端 p12 证书 Base64 字符串,TXT 记录往往会合在一起超过 512 字节。 在标准的 DNS UDP 查询中,如果响应超过 512 字节,包头部的 TC (Truncated) 标志位会被置为 1,代表数据被截断。

💡 解决方案:标志位侦测与 TCP Fallback 我们在 UDP 接收处做了一层守卫:

swift
if (data[2] & 0x02) != 0 {  // TC Flag is set!
    // UDP 遭遇截断,降级使用 TCP 53 端口进行可靠全量查询
    return await queryTCP(domain: domain, server: server)
}

进入 queryTCP 时,会在帧最前面补上 2 字节的大端序长度头,直接利用 NWConnection.tcp 握手拿到绝对完整的几千字节 TXT 加密串,完美解决大文件丢失问题。


难点 3:防劫持的 “端到端解密” 校验

🚨 问题背景:  如果中间人(Mitm)故意把你的 TXT 记录篡改成钓鱼网站或错误信息,即便配置下发了,APP 也会崩溃或中招。

💡 解决方案:AES + TCP 握手活性测试

  1. 对称加密:对 root 的分流域名进行 AES-128-ECB 加密。中间人即使拿到了,没有客户端的硬编码 Key 也无法篡改。

  2. TCP 通信握手探测活性: 在真正切换配置前,Manager 会多跑一遍 tcpTest。由于有些域名可能已经“挂了”,客户端会在后台静默并发跑:

    swift
    let connection = NWConnection(to: host, using: .tcp)
    connection.stateUpdateHandler = { state in
        if state == .ready { finish(true) } // 代表服务器可通达,不是死域名
    }
    

难点 4:动态 mTLS 证书灌入 (Security Manager)

经过 AES 解密和两片 TXT (1.txt + 2.txt) 拼装后,我们得到了完整的证书 Base64 编码。 我们要实实现本地无感知实例化,不需要把证书文件落地写死到沙盒里(防止反编译静态检查):

  1. 直接在内存中将组合好的 Base64 数据转为 Data
  2. 使用 SecPKCS12Import 函数,并将空密码(或者约定的暗号)传入,从内存里动态吐出 SecIdentity 和关联的 SecCertificate
  3. 把 Identity 灌入全局 SessionDelegate。当走 HTTPS 握手时,若触发 .clientCertificate 的 URLAuthenticationChallenge,直接从 cache 提取该 Identity 给系统使用。

难点 5:SNI 阻断应急方案 —— 18字节头部纯裸 TCP 定制通道

对于国内在极限阻断(如 SNI 嗅探)下的特殊业务,HTTPS 甚至会被阻断。我们追加了 ip.yourbase.com 提取裸 IP:

  • 业务无感降级:当 HTTPS 全灭,NetworkChannelManager 自动引导流量降级到我们自己用原生 NWConnection 敲出来的裸 TCP 直连。
  • 自定义封包协议:由于对端没有 TLS 证书做阻断,我们在应用层通过自研非对称二进制报头([18字节头部][Path][Hdr][Body] 及 响应 14字节头部)在服务端和客户端穿梭自如,极大增强了业务的可达率骨干。

📈 三、 业务安全成效

通过这套机制的上线,我们成功做到了:

  1. 云端无感知脱壳切换:后台可随意增减高防域名、甚至随时全量更替 TLS 的客户端校验私钥,对老版本客户端保持完美兼容。
  2. 零阻断时长:冷启动到成功跑通业务 HTTPS 的时间通过 TaskGroup 的竞赛机制下降到了 平均 0.3 秒以内。

💡 总结

服务高防链路的最佳伴侣不是冗余服务器,而是灵活、弹性的 发现机制。 利用 DNS 53 这个处于网络信任基座的协议,将 分片加密数据 优雅地回传至 iOS 客户端并发解码,不仅安全可靠,更筑起了一道无法轻易折断的强硬长廊。


提示:  在使用 114 / 223 等大陆 DNS 查询时,注意频率控制以心跳避免被运营商拉入恶意解析黑名单。对于更深层的防污染,甚至可搭配 DNS over HTTPS (DoH) 来取代 53 端口查询。

❌
❌