阅读视图

发现新文章,点击刷新页面。

写给设计师:如何设计一份 AI 友好的设计规范

你有没有这种体验:让 AI 帮你写个页面,它生成的代码颜色全是瞎编的、间距全靠猜、按钮样式跟你们产品完全不搭?

然后你甩给它一份设计规范的 PDF,指望它能“学会”你们的设计体系。

结果呢?AI 看 PDF 基本等于盲人摸象——它看到的是一堆碎片化的文字和完全无法理解的截图。那些精心排版的视觉示例,在 AI 眼里跟噪音差不多。

问题不是 AI 不行,而是我们给 AI 的“学习资料”不对。

传统设计规范的问题

传统设计规范长这样:一份精美的 PDF,里面有品牌色卡、组件截图、do/don’t 的对比图、各种排版示例。

这东西给人看,完美。给 AI 看,灾难。

原因很简单:

第一,PDF 是视觉媒介,AI 是文本动物。 PDF 里那些色卡截图,AI 根本“看”不出来里面的色值是什么。它需要的是 #1A73E8 这个字符串,不是一个蓝色方块的图片。

第二,设计规范的“规则”通常是散文式的。 比如“不要在一个页面里放太多主按钮”——这句话人类一看就懂,但 AI 很难把它转化成一个可执行的判断。太多是多少?什么算主按钮?什么算一个页面?

第三,知识是碎片化的。 颜色写在第 3 页,间距写在第 7 页,按钮的规范在第 12 页,而按钮用到的颜色和间距需要 AI 自己去关联。这种跨页面的信息拼装,AI 做起来很吃力。

核心思路:把设计决策变成结构化数据

一句话总结:视觉示例给人看,结构化数据给 AI 读。

具体来说,就是把传统设计规范里的每一个设计决策,都翻译成 AI 能精确解析的格式。

那用什么格式呢?我让 Claude Opus 帮我调研了一下,它推荐的方案是:Markdown + JSON + YAML 的组合。其中:

  • Markdown 负责描述性的内容:设计原则、使用场景、什么时候该用什么不该用
  • JSON 负责精确的数值定义:颜色值、字号、间距、阴影
  • YAML 负责组件级的结构化规范:组件的变体、状态、约束规则

为什么不统一用一种格式?因为各有所长。JSON 适合定义纯数据(Design Token),YAML 适合描述有层次的组件规范(因为可读性更好),Markdown 适合写需要段落和叙事的内容(设计原则、模式指引)。

具体分五步来做

1. Design Token 化:把所有“魔法数字”抽出来

传统规范里,设计师说“主色调是品牌蓝”,然后在 PDF 里放一个色块。

AI 友好的方式是把它变成一个 Token:

1
2
3
4
5
6
7
8
9
10
11
12
{
"color": {
"brand": {
"primary": {
"value": "#1A73E8",
"usage": "主操作按钮、重要链接、选中态",
"contrast_on_white": "4.6:1",
"wcag_aa": true
}
}
}
}

注意这里不只有色值,还有 usage(什么场景用)和 wcag_aa(是否满足无障碍标准)。这些上下文信息对 AI 来说极其重要——它不只要知道“是什么颜色”,还要知道“什么时候用”和“为什么选这个颜色”。

同理,字号、间距、圆角、阴影、动画时长……所有数值类的设计决策,都应该 Token 化。

2. 组件规范用结构化 Schema 描述

传统规范里,一个按钮组件的描述可能是一页截图加几段说明文字。

AI 友好的方式是用 YAML 写一个完整的结构化定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
component: Button

variants:
- name: primary
description: "主操作按钮,页面中最重要的行动号召"
styles:
background: "{color.brand.primary}"
text_color: "#FFFFFF"
border_radius: "{border_radius.md}"

sizes:
- name: md
height: "40px"
padding: "0 {spacing.md}"
font_size: "{typography.scale.body-md.size}"

states: [default, hover, active, focus, disabled, loading]

这里面有几个关键设计:

用花括号引用 Token,比如 {color.brand.primary}。这样 AI 在生成代码时,会自动去 Token 文件里查对应的值,而不是硬编码一个色值。整个系统是关联的。

明确列出所有状态。人类设计师可能觉得“hover 状态不用说大家都知道”,但 AI 需要你把它列出来。缺什么它就不做什么。

有变体(variants)和尺寸(sizes)的穷举。 AI 最擅长在有限集合里做选择,而不是在模糊描述里做推断。

3. 把 do/don’t 改写成可执行的规则

这是最关键的一步。

传统规范里的“Don’t”通常配一张错误示例截图,AI 完全看不懂。

AI 友好的方式是把它写成带 ID、有严重等级、能机器检查的规则:

1
2
3
4
5
6
7
8
9
10
11
12
rules:
- id: btn-001
rule: "同一视图中最多一个 primary 按钮"
severity: error
rationale: "多个 primary 按钮导致用户无法识别主操作"

- id: btn-003
rule: "按钮文案不超过 6 个中文字符"
severity: warning
examples:
correct: ["提交订单", "确认删除", "开始学习"]
incorrect: ["好的", "订单信息", "下一步操作确认提交"]

这种格式有几个好处:

  • 有唯一 ID:AI 审查代码时可以引用“违反了规则 btn-001”
  • 有严重等级:error 是必须修的,warning 是建议修的,AI 可以区分优先级
  • 有原因:rationale 告诉 AI“为什么”,当遇到边缘情况需要取舍时,AI 能做更合理的判断
  • 有正反例:而且是文字形式的,不是截图

4. 提供“AI 入口文件”

你的设计规范可能有几十个文件,AI 不知道该先看哪个。你需要一个 README.md 作为入口,就像给 AI 画一张地图:

1
2
3
4
5
6
7
8
9
10
11
12
## AI 使用指引

### 生成 UI 代码时
1. 先读取 tokens/ 中的变量,禁止硬编码颜色/字号/间距值
2. 查找对应 components/*.yaml 获取组件结构和约束规则
3. 查阅 patterns/*.md 确认页面级布局要求
4. 检查 accessibility.md 确保符合无障碍标准

### 审查 UI 代码时
1. 逐条检查组件 YAML 中的 rules 字段
2. 验证 Token 引用是否正确
3. 检查 severity: error 的规则是否被违反

这个入口文件告诉 AI 三件事:有哪些文件、每个文件是干嘛的、不同任务应该按什么顺序查阅哪些文件。

5. 设计原则要可操作化

传统规范里的设计原则通常很抽象:“我们追求简洁”。

AI 友好的方式是让原则可操作——不只说“是什么”,还说“怎么用”和“冲突时怎么办”:

1
2
3
4
### 清晰优先于美观
- **含义**: 用户能否在 3 秒内理解界面意图,比视觉精致更重要
- **实践**: 信息层次分明,操作路径可预期,文案直白无歧义
- **权衡**: 当装饰性元素影响信息传达时,移除装饰性元素

特别是要提供一个 原则冲突解决矩阵。比如“清晰”和“包容性”冲突时谁优先?“性能”和“一致性”冲突时呢?人类设计师靠直觉判断,AI 需要明确的规则。

推荐的文件结构

说了这么多,最终的目录结构长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
design-system/
├── README.md ← AI 入口,索引整个规范
├── principles.md ← 设计原则 + 冲突解决矩阵
├── accessibility.md ← 无障碍要求 + AI 审查清单
├── tokens/
│ ├── colors.json ← 品牌色、功能色、中性色
│ ├── typography.json ← 字体、字号阶梯、行高
│ ├── spacing.json ← 间距、栅格、断点
│ ├── elevation.json ← 阴影、层级
│ └── motion.json ← 动画时长、缓动函数
├── components/
│ ├── button.yaml ← 按钮规范
│ ├── input.yaml ← 输入框规范
│ ├── modal.yaml ← 弹窗规范
│ └── card.yaml ← 卡片规范
└── patterns/
├── form-layout.md ← 表单布局模式
├── error-handling.md ← 错误处理策略
├── responsive.md ← 响应式断点规则
└── dark-mode.md ← 深色模式适配

每层的分工很清晰:

  • tokens/ 是最底层的原子变量,纯数据,JSON 格式
  • components/ 是组件级规范,结构化描述,YAML 格式
  • patterns/ 是页面级模式,需要叙事和流程说明,Markdown 格式

一些实操建议

不要一步到位。 你不需要一次把整个设计规范都改造完。可以先从 Design Token 开始——把颜色和字号从 PDF 里抽出来做成 JSON 文件,这一步投入产出比最高。

保持两个版本同源。 理想情况下,JSON/YAML 是“源文件”,PDF 版本从源文件自动生成。这样改一处,两边都更新。如果做不到自动生成,至少保证人工同步。

给每个决策加上“为什么”。 这是很多人最容易忽略的。AI 在遇到边缘情况时,rationale 字段就是它做判断的依据。没有 rationale,它只会机械执行规则;有了 rationale,它能理解意图,做出更灵活的判断。

把规范放到代码仓库里。 设计规范不应该是一个飞书文档或者 Figma 链接,而是一个 Git 仓库里的文件夹。这样 AI 工具可以直接读取,开发者可以在 CI/CD 里做自动检查,版本变更有迹可循。

实际测试。 改造完之后,拿你的 AI 工具(Claude、Cursor、Copilot 等)实际跑一遍:让它基于你的设计规范生成一个页面,看看它是不是真的引用了 Token、遵守了规则。不好使就迭代。

最后

AI 时代的设计规范,本质上是一个 API——它不再只是给人“阅读”的文档,而是给机器“调用”的接口。

格式变了,但设计的本质没变。你仍然需要好的设计判断来决定什么颜色、什么间距、什么交互模式。只是表达方式要变一变:从“让人看懂”升级为“人机双读”。

如果你的设计师不知道如何输出上面的文件,没关系,把这篇文章发给你的 AI Agent(推荐使用 Claude Opus 4.6),然后说:我需要按照文章中的方案来产生一套面向 AI 的设计规范,你来帮我完成,现在你告诉我需要哪些文件和资料,我来负责提供。

放心,AI 会一步一步带着你完成这份规范。

希望对你有用。

AI编程时代解决bug的新业态

本文是想通过一个例子来讲述,AI在修复Bug方面令人惊艳的能力。

一、传统方式下

先来看一个Crash日志的堆栈信息:

Termination Reason:<RBSTerminateContext| domain:10 code:0x8BADF00D 
explanation:scene-create watchdog transgression: application<com.xxx.aaa>:
34689 exhausted real (wall clock) time allowance of 3.43 seconds

// 
Thread 0 Crashed:
0      libsystem_pthread.dylib       _pthread_mutex_lock$VARIANT$armv81 + 120
1      libc++.1.dylib                std::__1::mutex::lock() + 12
2      libicucore.A.dylib            icu::Locale::getDefault() + 32
3      libicucore.A.dylib            icu::Locale::init(char const*, signed char) + 1400
4      libicucore.A.dylib            _ures_getLocaleByType + 436
5      libicucore.A.dylib            icu::DecimalFormatSymbols::initialize(icu::Locale const&, UErrorCode&, signed char, icu::NumberingSystem const*) + 256
6      libicucore.A.dylib            icu::DecimalFormatSymbols::DecimalFormatSymbols(icu::Locale const&, icu::NumberingSystem const&, UErrorCode&) + 236
7      libicucore.A.dylib            icu::number::LocalizedNumberFormatter::getDecimalFormatSymbols() const + 4608
8      libicucore.A.dylib            icu::number::LocalizedNumberFormatter::getDecimalFormatSymbols() const + 1632
9      libicucore.A.dylib            icu::number::LocalizedNumberFormatter::formatImpl(icu::number::impl::UFormattedNumberData*, UErrorCode&) const + 128
10     libicucore.A.dylib            icu::SimpleDateFormat::zeroPaddingNumber(icu::NumberFormat const*, icu::UnicodeString&, int, int, int) const + 524
11     libicucore.A.dylib            icu::SimpleDateFormat::subFormat(icu::UnicodeString&, char16_t, int, UDisplayContext, int, char16_t, icu::FieldPositionHandler&, icu::Calendar&, UErrorCode&) const + 904
12     libicucore.A.dylib            icu::SimpleDateFormat::_format(icu::Calendar&, icu::UnicodeString&, icu::FieldPositionHandler&, UErrorCode&) const + 688
13     libicucore.A.dylib            icu::SimpleDateFormat::format(icu::Calendar&, icu::UnicodeString&, icu::FieldPosition&) const + 80
14     libicucore.A.dylib            icu::DateFormat::format(double, icu::UnicodeString&, icu::FieldPosition&) const + 124
15     libicucore.A.dylib            _udat_format + 356
16     CoreFoundation                ___cficu_udat_format + 64
17     CoreFoundation                _CFDateFormatterCreateStringWithAbsoluteTime + 180
18     Foundation                    -[NSDateFormatter stringForObjectValue:] + 160
19     MyAPP                        -[HAMLaunchMonitor startUUID] + 136
...    MYApp                               其它调用函数

传统解决步骤:

  1. 查看HAMLaunchMonitor中startUUID方法里面内容,看代码上有没明显bug;
  2. 查看调用NSDateFormatter的string...方法的方式是否有问题;
  3. 后面是系统堆栈,所以得出结论是系统库发生了长时间等锁;
  4. 查看崩溃说明是0x8BADF00D,确定是被系统WatchDog检测到杀死了进程导致的崩溃。
  5. 解决方式:少量偶现且逻辑不好修改,出临时性修复方案~
  6. 问题可能没有根本解决,只是概率降低了。

可以看到我们人工查看这个问题有几个瓶颈:

  1. 需要手动找到文件查看分析函数调用链,速度慢。
  2. 对于顶部的系统函数,我们一般是缺乏了解的,不知道里面发生了什么导致出了问题。
  3. 弄清楚了上面内容后,我们的解决方法也是基于自己认知的,存在偏差。

而这几个瓶颈,交给AI后那都不是事儿。

二、AI时代下Bug修复

复制堆栈信息交给AI工具(我目前用的是Qoder),AI直接给出了根本原因及有效的方案:

Crash 类型

这是一个 Watchdog 超时 Crash0x8BADF00D = "ate bad food"),发生在 scene-create(应用启动/场景创建) 阶段。系统允许的时间为 3.43 秒,但应用在该时间内未能完成启动。

根因分析

从调用栈来看,崩溃发生在 [-[HAMLaunchMonitor startUUID]] 方法中:

- (NSString *)startUUID { 
    NSDateFormatter *formatter = [NSDateFormatter new];
    formatter.dateFormat = @"yyMMddHHmmssSSS";
    formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; 
    NSString* time = [formatter stringFromDate:[NSDate date]]; 
    // ... 
} 

主线程被阻塞在 pthread_mutex_lock,具体路径是:

  1. startUUID 调用 NSDateFormatter stringForObjectValue: 进行日期格式化
  2. 底层 ICU 库执行 SimpleDateFormat::format 进行实际格式化
  3. ICU 在格式化数字时需要获取 DecimalFormatSymbols
  4. 这触发了 icu::Locale::getDefault() 来获取系统默认 locale
  5. getDefault() 内部需要获取一个 全局互斥锁(std::mutex
  6. 该锁被其他线程持有,导致主线程死等(pthread_mutex_lock 卡住)

核心问题

锁竞争(Lock Contention) —— ICU 库内部有一个全局的 std::mutex 保护 Locale::getDefault()。在应用启动阶段,如果有多个线程同时使用 NSDateFormatterNSNumberFormatter 或其他依赖 ICU 的 API,就会争抢这把锁。主线程被阻塞等锁,累计时间超过 watchdog 限制,系统杀掉了应用。

加剧因素

  1. 启动阶段调用 startUUID:该方法在 [commonParameters] 中被调用,而 commonParameters 在 [appDidFinishLaunch] 流程中执行——正处于 watchdog 监控最严格的时间窗口。
  2. 每次调用都创建新的 NSDateFormatterNSDateFormatter 的初始化本身就是重量级操作,且会触发 ICU locale 的初始化。
  3. ICU 内部的全局锁:这是系统库的已知问题,在 iOS 启动早期阶段锁竞争尤为严重,因为各种系统框架也在并发初始化。

建议修复方案

  1. 缓存 NSDateFormatter(最直接有效):将 NSDateFormatter 创建为静态变量或单例属性,避免每次调用 startUUID 都重新创建:
- (NSString *)startUUID {
 static NSDateFormatter *formatter = nil;
  static dispatch_once_t onceToken; 
  dispatch_once(&onceToken, ^{
   formatter = [NSDateFormatter new]; 
   formatter.dateFormat = @"yyMMddHHmmssSSS"; 
   formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; 
  }); 
   NSString *time = [formatter stringFromDate:[NSDate date]]; 
   NSString *startUuid = [NSString stringWithFormat:@"%@2000000%@", time, [self getRandomNumber]]; 
   return startUuid; 
 } 
  1. 延迟执行:将 startUUID 和上报逻辑延迟到首帧渲染之后,脱离 watchdog 监控的关键时间窗口。
  2. 替代方案:如果仅需要生成唯一标识,可以完全避免 NSDateFormatter,改用时间戳数字拼接: 这完全绕开了 ICU/NSDateFormatter,不存在锁竞争问题。
- (NSString *)startUUID { 
NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; 
long long ms = (long long)(now * 1000); 
NSString *startUuid = [NSString stringWithFormat:@"%lld2000000%@", ms, [self getRandomNumber]]; 
return startUuid;
 } 

推荐方案 3,因为它从根本上消除了 ICU 锁竞争的风险,同时性能开销最小。

三、感想

AI时代,整体APP行业的性能将得到大幅提升

优秀大模型解决Bug的能力很强,且修复速度和质量都发生了颠覆性的变化,意味着如果有规划+APP开发人员有心,大部分问题都能解决;AI编程工具下,目前AI写复杂功能可能还有点问题,但如果是指定AI去写一个特定功能bug率可能会比资深工程师还要好;借助AI来深入了解底层知识也很方便,对于提升工程师认知也有帮助,进一步提升了性能。

AI时代,Bug的解决方式会发生变化

现在的热修复功能集成到APP后,往往需要编写修复后的脚本语言文件,下发到APP,APP动态运行时交换方法实现解决。

AI时代的方式可能是:
-》Crash发生后,自动分析原因,出解决方案,发出通知;
-》人工收到通知后,选择一个方案;
-》自动生成对应的脚本文件,自动下发到对应的APP版本。
-》APP再次打开时,Bug已经自动修复。

【RxSwift】Swift 版 ReactiveX,响应式编程优雅处理异步事件流

【RxSwift】Swift 版 ReactiveX,响应式编程优雅处理异步事件流

iOS三方库精读 · 第 8 期


一、一句话介绍

RxSwift 是 ReactiveX 的 Swift 实现,将异步操作和事件统一为可观察序列(Observable),通过操作符进行声明式组合变换,极大简化复杂异步逻辑。

属性
GitHub Stars 24.5k+
最新版本 6.7.0
License MIT
支持平台 iOS 9+ / macOS 10.10+ / watchOS / tvOS

二、为什么选择它

原生痛点

在没有 RxSwift 之前,处理异步事件流往往面临:

  • 回调地狱:嵌套的网络请求、多层 callback 难以维护
  • 状态同步:UI 与数据模型的双向绑定需要大量 KVO / Notification / Delegate 样板代码
  • 线程切换:GCD 和 OperationQueue 手动管理容易出错
  • 错误处理:分散在各处的 try-catch,遗漏处理导致崩溃
  • 事件取消:Timer、Notification 观察者忘记移除,造成内存泄漏

RxSwift 核心优势

  1. 统一异步模型:网络请求、通知、KVO、Timer、手势统统归为 Observable,一套 API 走天下
  2. 声明式组合:通过 map/filter/flatMap 等操作符链式调用,代码如流水般清晰
  3. 自动资源管理:DisposeBag 机制确保订阅随生命周期自动释放
  4. 丰富的操作符:100+ 操作符覆盖变换、过滤、组合、错误处理、调度等场景
  5. RxCocoa 扩展:UIKit 控件开箱即用的双向绑定(UITextField、UIButton、UITableView 等)

原生 API vs RxSwift

场景 原生方式 RxSwift 方式
网络请求 URLSession + closure 嵌套 Observable + flatMap 链式
输入框实时搜索 addTarget + Timer 去抖 rx.text + debounce
表单验证 多个 KVO / Delegate combineLatest 一行搞定
Timer 管理 Timer.scheduledTimer + invalidate Observable.interval + disposed(by:)
通知监听 NotificationCenter + removeObserver NotificationCenter.rx.notification

三、核心功能速览

基础层 概念解释、环境配置、基础用法

环境配置

Swift Package Manager(推荐)

// Package.swift
dependencies: [
    .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.7.0")
]

CocoaPods

pod 'RxSwift', '~> 6.7'
pod 'RxCocoa', '~> 6.7'  # UIKit 扩展
pod 'RxRelay', '~> 6.7'  # Relay 组件

核心概念:Observable 三部曲

import RxSwift

let disposeBag = DisposeBag()

// 1. 创建 Observable
let observable = Observable<String>.create { observer in
    observer.onNext("Hello")
    observer.onNext("RxSwift")
    observer.onCompleted()
    return Disposables.create()
}

// 2. 订阅
observable
    .subscribe(
        onNext: { print("收到: \($0)") },
        onCompleted: { print("完成") }
    )
    // 3. 管理 disposal
    .disposed(by: disposeBag)

// 输出:
// 收到: Hello
// 收到: RxSwift
// 完成

进阶层 最佳实践、性能优化、线程安全

Subject 与 Relay

// PublishSubject: 只收到订阅后的事件
let publish = PublishSubject<String>()
publish.onNext("A")        // 不会收到
publish.subscribe { print($0) }  // 订阅
publish.onNext("B")        // ✅ 收到

// BehaviorSubject: 保留最新一个值,新订阅者立即收到
let behavior = BehaviorSubject(value: "初始值")
behavior.onNext("新值")
behavior.subscribe { print($0) }  // ✅ 立即收到 "新值"

// BehaviorRelay: UI 安全,永不触发 error/completed
let relay = BehaviorRelay(value: "初始值")
relay.accept("更新值")     // 用 accept 替代 onNext
relay.asObservable().subscribe { print($0) }

常用操作符

// 过滤
Observable.of(1, 2, 3, 4, 5)
    .filter { $0 % 2 == 0 }     // [2, 4]

// 变换
Observable.of(1, 2, 3)
    .map { $0 * 2 }             // [2, 4, 6]

// 去重
Observable.of("a", "b", "a", "c")
    .distinctUntilChanged()     // ["a", "b", "a", "c"] - 相邻去重

// 扁平化(网络请求链式)
searchText
    .flatMapLatest { query in
        return networkAPI.search(query)  // 自动取消上一个请求
    }

// 组合
Observable.combineLatest(
    usernameValid,
    passwordValid
) { $0 && $1 }                   // 表单验证

线程调度

Observable<String>.create { observer in
    // 后台执行耗时任务
    Thread.sleep(forTimeInterval: 1)
    observer.onNext("计算结果")
    observer.onCompleted()
    return Disposables.create()
}
.subscribe(on: ConcurrentDispatchQueueScheduler(qos: .background))  // 订阅线程
.observe(on: MainScheduler.instance)                               // 观察线程
.subscribe(onNext: { value in
    // UI 更新在主线程
    label.text = value
})
.disposed(by: disposeBag)

深入层 源码解析、设计思想、扩展定制

核心协议关系

ObservableType (协议)
    ↓
Observable (class)
    ↓ 创建
AnyObserver (观察者抽象)
    ↓ 订阅
Disposable (取消订阅)
    ↓ 持有
DisposeBag (资源回收容器)

Observer 模式实现

// Observable.subscribe 核心流程
func subscribe(_ observer: Observer) -> Disposable {
    // 1. 包装 observer
    let disposable = Disposables.create()
    
    // 2. 调用核心生产逻辑
    let sink = AnonymousSink(observer: observer, dispose: disposable)
    
    // 3. 返回 disposable 用于取消
    return disposable
}

// dispose 时移除订阅
final class DisposeBag {
    var disposables: [Disposable] = []
    deinit {
        disposables.forEach { $0.dispose() }
    }
}

四、实战演示

场景:实时搜索 + 表单验证 + TableView 绑定

import UIKit
import RxSwift
import RxCocoa

final class LoginViewController: UIViewController {
    private let disposeBag = DisposeBag()
    
    // UI
    private let emailField = UITextField()
    private let passwordField = UITextField()
    private let loginButton = UIButton(type: .system)
    private let tableView = UITableView()
    
    // 数据源
    private let suggestions = BehaviorRelay<[String]>(value: [])
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
    }
    
    private func setupBindings() {
        // 1. 实时搜索(防抖 500ms + 去重)
        emailField.rx.text.orEmpty
            .debounce(.milliseconds(500), scheduler: MainScheduler.instance)
            .distinctUntilChanged()
            .filter { !$0.isEmpty }
            .flatMapLatest { [weak self] query -> Observable<[String]> in
                // 模拟搜索建议 API
                return self?.searchSuggestions(query) ?? .just([])
            }
            .bind(to: suggestions)
            .disposed(by: disposeBag)
        
        // 2. 表单验证(多输入组合)
        let emailValid = emailField.rx.text.orEmpty
            .map { $0.contains("@") && $0.contains(".") }
        
        let passwordValid = passwordField.rx.text.orEmpty
            .map { $0.count >= 6 }
        
        Observable.combineLatest(emailValid, passwordValid) { $0 && $1 }
            .bind(to: loginButton.rx.isEnabled)
            .disposed(by: disposeBag)
        
        // 3. TableView 数据绑定
        suggestions
            .bind(to: tableView.rx.items(cellIdentifier: "Cell")) { _, text, cell in
                cell.textLabel?.text = text
            }
            .disposed(by: disposeBag)
        
        // 4. 点击事件
        loginButton.rx.tap
            .withLatestFrom(Observable.combineLatest(
                emailField.rx.text.orEmpty,
                passwordField.rx.text.orEmpty
            ))
            .subscribe(onNext: { [weak self] email, password in
                self?.performLogin(email: email, password: password)
            })
            .disposed(by: disposeBag)
    }
    
    private func searchSuggestions(_ query: String) -> Observable<[String]> {
        // 模拟 API 请求
        return Observable.just(["\(query)@gmail.com", "\(query)@icloud.com"])
            .delay(.milliseconds(300), scheduler: MainScheduler.instance)
    }
    
    private func performLogin(email: String, password: String) {
        print("登录: \(email), 密码: \(password)")
    }
}

五、源码亮点

进阶层 值得借鉴的用法

操作符链式调用的 Builder 模式

// 每个操作符返回新的 Observable,支持无限链式
observable
    .map { transform($0) }       // 返回 Map<Source>
    .filter { predicate($0) }    // 返回 Filter<Map<Source>>
    .subscribe { ... }           // 返回 Disposable

takeUntil 实现自动取消

// 当 self.deallocated 时自动取消网络请求
networkRequest()
    .takeUntil(self.rx.deallocated)
    .subscribe(onNext: { ... })

深入层 设计思想解析

Producer-Consumer 模式

// Observable 是 Producer,产生事件
class Producer<Element>: Observable<Element> {
    func run(_ observer: Observer, cancel: Cancel) -> Sink {
        // 子类实现具体生产逻辑
    }
}

// Sink 是 Consumer,消费事件并管理生命周期
class Sink<Observer: ObserverType>: Disposable {
    let observer: Observer
    var disposed = false
    
    func dispose() {
        disposed = true
    }
}

操作符的实现模式

map 为例:

final class MapSink<Source, Result>: Sink<Result>, ObserverType {
    typealias Element = Source
    
    private let transform: (Source) -> Result
    
    func on(_ event: Event<Source>) {
        switch event {
        case .next(let element):
            let result = transform(element)  // 变换
            forwardOn(.next(result))         // 传递给下游
        case .error(let error):
            forwardOn(.error(error))
        case .completed:
            forwardOn(.completed)
        }
    }
}

六、踩坑记录

问题 1:订阅未释放导致内存泄漏

问题:在 ViewController 中订阅 Observable,页面释放后订阅仍在执行。

原因:未将 Disposable 加入 DisposeBag,或 DisposeBag 生命周期与 VC 不一致。

解决

// ❌ 错误:未持有 Disposable
observable.subscribe { ... }

// ✅ 正确:加入 DisposeBag
private let disposeBag = DisposeBag()
observable.subscribe { ... }
    .disposed(by: disposeBag)

问题 2:UI 更新不在主线程

问题:后台网络请求回调中更新 UI 导致崩溃。

原因:默认情况下 Observable 继承订阅者的线程上下文。

解决

// ❌ 错误:可能在后台线程
networkRequest()
    .subscribe(onNext: { label.text = $0 })

// ✅ 正确:显式切换到主线程
networkRequest()
    .observe(on: MainScheduler.instance)
    .subscribe(onNext: { label.text = $0 })

问题 3:flatMap 与 flatMapLatest 混淆

问题:快速输入搜索关键词,收到旧的请求结果。

原因flatMap 会保留所有内部 Observable,flatMapLatest 会自动取消上一个。

解决

// ❌ 错误:旧请求可能覆盖新结果
searchText.flatMap { searchAPI($0) }

// ✅ 正确:自动取消上一个请求
searchText.flatMapLatest { searchAPI($0) }

问题 4:Subject 发送 completed 后无法复用

问题:PublishSubject 发送 .completed 后,后续订阅收不到事件。

原因:Subject 一旦 terminated,状态不可逆转。

解决

// 方案 1:使用 Relay(不发送 completed)
let relay = PublishRelay<String>()

// 方案 2:重新创建 Subject
func resetSubject() {
    subject = PublishSubject<String>()
}

问题 5:share(replay:) 重复执行副作用

问题:多个订阅者导致网络请求被执行多次。

原因:默认每个订阅者独立触发 Observable 执行。

解决

// ❌ 错误:每个订阅触发一次请求
let request = api.fetchData()
request.subscribe { ... }  // 请求 1
request.subscribe { ... }  // 请求 2

// ✅ 正确:共享执行结果
let request = api.fetchData()
    .share(replay: 1)       // 缓存最近 1 个结果
request.subscribe { ... }   // 请求 1
request.subscribe { ... }   // 复用结果

问题 6:withUnretained 造成循环引用

问题:使用 withUnretained(self) 仍出现循环引用。

原因:闭包内额外强引用了 self。

解决

// ❌ 错误:闭包内强引用
observable
    .withUnretained(self)
    .subscribe { self, value in
        self.items.append(value)  // 强引用
    }

// ✅ 正确:使用 weak 或确保无循环
observable
    .subscribe(onNext: { [weak self] value in
        self?.items.append(value)
    })

七、延伸思考

RxSwift vs Combine 横向对比

维度 RxSwift Combine
开发商 社区 (ReactiveX) Apple 官方
最低版本 iOS 9+ iOS 13+
操作符数量 100+ 极其丰富 ~50 够用但较少
UI 绑定 RxCocoa 内建 需自行封装或用 SwiftUI
调试支持 RxSwift.Resources / debug() print() / handleEvents()
学习曲线 较陡(概念多) 中等
包体积 ~2MB 系统内建,0 额外
SwiftUI 集成 需桥接 原生支持
维护状态 活跃 Apple 官方维护

选型建议

选 RxSwift:

  • 项目需要兼容 iOS 13 以下
  • 团队有 RxJava / RxJS 经验,希望统一范式
  • 需要大量 UIKit 双向绑定(RxCocoa 非常成熟)
  • 需要 withLatestFrom 等 Combine 缺失的操作符

选 Combine:

  • 纯 SwiftUI 项目,Combine 原生集成最流畅
  • 不想引入第三方依赖,减少包体积
  • 新项目最低版本 ≥ iOS 13

迁移建议

对于已有 RxSwift 项目:

  • 短期内无需迁移,RxSwift 维护状态良好
  • 新增 SwiftUI 页面可用 Combine,与 RxSwift 共存
  • 使用 RxCombine 库实现互相转换

八、参考资源

官方资源

推荐文章

系列 Demo 仓库


本期互动

小作业

尝试用 RxSwift 实现一个「搜索建议」功能:输入框输入时防抖 300ms,发起网络请求获取建议列表,展示在 UITableView 中,并在评论区贴出你的关键代码片段。

思考题

如果让你从零实现 RxSwift 的 debounce 操作符,你会如何设计?需要考虑哪些边界情况?

读者征集

你在使用 RxSwift 时踩过哪些坑?欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ Lottie] [✓ MarkdownUI] [✓ SDWebImage] [✓ SnapKit] [✓ ListDiff] [→ RxSwift] [○ Charts]

uni-app scroll-view 滚动卡死?一行CSS直接复活(iOS必看)

uni-app scroll-view 滚动卡死?一行CSS直接复活(iOS必看)

做uni-app开发的同学,有没有遇到过这种崩溃场景:页面用了scroll-view做滚动容器,点击Tab切换锚点后,整个页面突然不能滑动了,刷新也没用,只有重新进入页面才能恢复?

我最近就踩了这个坑,花了大半天排查,最后发现居然只要一行CSS就能解决,今天把整个排查过程和原理分享出来,帮大家避坑,尤其是做iOS端开发的同学,建议直接收藏备用!

一、问题复现(和我遇到的一模一样)

先给大家还原下我遇到的场景,方便大家对号入座:

  • 页面结构:用 scroll-view 包裹整个页面内容,内部分3个模块(基本信息、买车意向、卖车意向),顶部有Tab切换,点击Tab通过 scroll-into-view 实现锚点定位。

  • 问题现象:进入页面后,点击「卖车意向」Tab,锚点直接定位到模块最底部;此时尝试上下滑动页面,发现整个页面完全卡死,不能向上滑,只能向下滑(甚至向下滑也不流畅),刷新页面也无法恢复。

  • 环境:iOS端(真机+模拟器+Safari浏览器都复现),Android端正常,小程序端正常。

一开始我以为是锚点定位逻辑写错了,反复检查scroll-into-view、锚点高度计算,改了半天还是卡死,直到加上一行CSS,瞬间复活!

二、排查过程(踩坑记录,帮你省时间)

排查过程中,我走了3个弯路,大家可以跳过这些无效操作,直接看解决方案:

弯路1:怀疑锚点高度计算错误

一开始觉得是锚点高度获取有误——页面有「展示完整信息」的折叠/展开功能,初始化时获取的锚点高度是折叠状态的,展开后高度变化,导致定位偏移,进而触发滚动异常。

于是封装了锚点高度重新计算方法,在折叠/展开、Tab切换后重新查询DOM高度,虽然解决了锚点定位到底部的问题,但滚动卡死依然存在

弯路2:怀疑scroll-view滚动逻辑错误

接着检查scroll-view的滚动监听方法(scrollChange),发现里面有复杂的高度判断逻辑,比如用anchor2TopCopy动态计算偏移量,以为是判断条件出错导致滚动锁死。

简化了滚动监听逻辑,改成简单的三段式判断(根据滚动距离切换Tab),锚点定位更精准了,但滚动卡死问题还是没解决

弯路3:怀疑scroll-view样式配置错误

检查scroll-view的样式,确认已经设置了scroll-y="true"、flex:1、height:100%,没有多余的overflow样式冲突,排除了样式配置问题。

关键突破:定位到iOS原生回弹冲突

因为只有iOS端有问题,Android端正常,猜测是iOS原生特性和uni-app scroll-view的冲突。想起iOS有个「橡皮筋回弹」效果(overscroll),当scroll-view滚动到边界时,继续拉拽会出现空白回弹,会不会是这个回弹导致滚动状态错乱?

抱着试试看的心态,加了一行禁止回弹的CSS,没想到——滚动瞬间恢复正常,卡死问题彻底解决!

三、解决方案(一行CSS搞定,直接复制)

就是这行CSS,直接复制到你的页面样式中,iOS端滚动卡死问题瞬间解决:

::v-deep .uni-scroll-view, 
::v-deep .uni-scroll-view-content {
  /* 禁止iOS橡皮筋回弹,解决scroll-view滚动卡死 */
  overscroll-behavior: none;
}

补充说明:

  • ::v-deep 必须加:因为uni-app的scroll-view是组件封装的,需要穿透样式到子组件。

  • 适配范围:同时作用于.uni-scroll-view和.uni-scroll-view-content,确保所有滚动容器都禁止回弹。

  • 不影响其他功能:这行代码只禁止“边界回弹”,不影响正常滚动、锚点定位,Android端不受影响(overscroll-behavior在Android上兼容性有限,不会生效,也不需要生效)。

完美搭配(解决锚点+滚动双重问题)

如果你的页面也有折叠/展开模块,建议搭配锚点高度重新计算方法,实现“锚点精准+滚动流畅”:

// 重新计算所有锚点高度(折叠/展开后调用)
updateAnchorTop() {
  const query = uni.createSelectorQuery().in(this);
  query
    .select('#anchor1') // 基本信息锚点
    .select('#anchor2') // 买车意向锚点
    .select('#anchor3') // 卖车意向锚点
    .boundingClientRect((res) => {
      if (res[0]) this.anchor1Top = res[0].top;
      if (res[1]) this.anchor2Top = res[1].top;
      if (res[2]) this.anchor3Top = res[2].top;
    })
    .exec();
},

// 折叠/展开按钮点击事件
arrowClick() {
  this.arrow = !this.arrow;
  // 等待DOM渲染完成后重新计算锚点高度
  this.$nextTick(() => {
    this.updateAnchorTop();
  });
}

四、问题原理(为什么这行CSS能解决?)

核心原因:iOS的橡皮筋回弹(overscroll)与uni-app的scroll-view锚点定位冲突,导致滚动状态锁死

  1. 当点击Tab触发scroll-into-view锚点定位时,若定位到模块底部,会触发iOS的“越界回弹”(overscroll)。

  2. uni-app的scroll-view组件底层对滚动状态的处理,与iOS原生回弹机制不兼容,回弹后会导致scroll-view的滚动事件被阻塞,出现“卡死”。

  3. overscroll-behavior: none 的作用就是禁止元素的越界回弹行为,从根源上避免了冲突,滚动状态自然恢复正常。

补充:这不是你的代码写错了,而是uni-app在iOS端的一个经典兼容性bug,很多开发者都遇到过,一行CSS就能规避。

五、常见补充场景(避坑延伸)

如果加上这行CSS后,滚动还是有问题,大概率是以下2个原因,对应解决即可:

场景1:scroll-view高度计算错误

确保scroll-view的父容器有明确高度,scroll-view本身设置:

scroll-view {
  flex: 1;
  height: 100%;
  overflow-y: auto; /* 兜底,避免滚动异常 */
}

场景2:Tab切换锚点定位不精准

在Tab点击事件中,等待DOM渲染完成后再赋值锚点,避免异步高度问题:

tabClick(e) {
  this.indexNum = e;
  this.$nextTick(() => {
    this.anchor = e === 0 ? 'anchor1' : e === 1 ? 'anchor2' : 'anchor3';
    // 定位后兜底校准高度
    setTimeout(() => this.updateAnchorTop(), 100);
  });
}

六、总结

如果你在uni-app开发中,遇到iOS端scroll-view滚动卡死、不能滑动的问题,尤其是结合锚点定位、Tab切换时,直接用这行CSS就能解决:

::v-deep .uni-scroll-view, 
::v-deep .uni-scroll-view-content {
  overscroll-behavior: none;
}

本质是规避iOS原生回弹与uni-app scroll-view的兼容性冲突,属于“一招制敌”的解决方案。

另外,结合锚点高度重新计算方法,能同时解决“锚点定位偏移”和“滚动卡死”两个问题,适配更多复杂页面场景。

希望这篇文章能帮你节省排查时间,避免踩坑~ 如果还有其他uni-app滚动相关的问题,欢迎在评论区交流!

最后,求个点赞收藏,你的支持是我分享的动力 😊

WebKit WebPage API 的引入尝试与自研实现

背景

现有架构的问题

工作中的 iOS 应用内浏览器一直使用 WKWebView 直接实现,但存在架构层面的担忧:

  • 基础设施职责load(_:)goBack() 等页面操作
  • UI 职责:作为 UIView 在屏幕上显示

这两种职责都集中在 WKWebView 这一个类型中。

工作中采用 UI / 表现层 / 业务逻辑 / 基础设施 四层架构,强调各层之间不应直接依赖具体实现。直接使用 WKWebView 会导致 UI 显示和网页操作都通过同一个类型完成,与架构理念不符。

WebPage API 的出现

WebKit 在 iOS 26 推出了新的 Swift API WebPage,设计理念与 WKWebView 截然不同:

职责 承担者
Web 内容状态管理 WebPage
导航控制 WebPage
JavaScript 执行 WebPage
UI 显示 WebView(SwiftUI View)

WebPage 遵循 @Observable,在 SwiftUI 中可以自然地订阅状态变化:

@State var webPage = WebPage()

var body: some View {
    WebView(webPage)
        .toolbar {
            Button("Back") {
                webPage.goBack()
            }
            .disabled(!webPage.canGoBack)
        }
}

这种设计有效解决了之前的架构担忧。


为何无法直接引入 WebPage

尽管 WebPage 设计理想,但由于以下原因无法直接在生产环境使用:

1. 操作系统版本限制

flowchart LR
    subgraph 版本对比
        WP[WebPage API<br/>iOS 26+]
        APP[应用支持<br/>iOS 18+]
    end
    WP -->|不兼容| APP

WebPage 需要 iOS 26,而应用目前仍支持 iOS 18,无法直接在生产代码中使用。

2. 与现有 UIKit 实现的兼容性

WebPage 内部持有 WKWebView,但并未将其作为属性公开,应用无法取出使用。

现有浏览器实现大量依赖 WKNavigationDelegate 和 KVO,从质量保证角度,无法一次性全部替换。需要在继续使用 WKWebView 的同时逐步迁移,这成为直接引入 WebPage 的障碍。

3. 与架构的不一致

flowchart TB
    subgraph 目标架构
        UI[UI 层]
        P[表现层]
        BL[业务逻辑层]
        INF[基础设施层]
    end
    
    UI --> P --> BL --> INF
    
    style UI fill:#e1f5fe
    style INF fill:#f3e5f5

四层架构要求业务逻辑层和表现层不直接依赖 UI 层的具体类型。但如果在业务逻辑层 import WebKitWKWebView 等 UI 相关类型也会变得可用。虽然 WebPage 本身是抽象 API,但最终依赖 WebKit 模块,无法在类型层面强制分层边界。

4. 与测试策略的兼容性

大规模应用需要保持 UI 无关逻辑的可测试性。WebPage 并非为替换和模拟而设计,难以融入现有的依赖注入(DI)测试策略。

5. 无法满足现有功能需求

应用内浏览器有一些特殊需求:

URL 变化检测

  • 需要可靠检测 URL 变化并保存历史记录
  • SwiftUI 的 onChange(依赖 UI 渲染周期)或 Observation Framework(依赖事务边界)可能合并短时间内多次变化
  • 传统 UIKit 使用 KVO 在更低层面检测变化
  • WebPage 目前没有提供同等钩子

window.open 处理

  • 需要拦截 JavaScript 的 window.open(本应新开标签页)并在同一页面内打开
  • 当前 WebPage 没有提供实现此行为的机制

自研实现设计

为满足现有应用需求同时实现职责分离,参考 WebKit 官方 WebPage 的设计理念,设计了自定义 API。

核心抽象层

classDiagram
    class WebPageRepresentable {
        <<protocol>>
        +url: URL?
        +canGoBack: Bool
        +estimatedProgress: Double
        +load(request: URLRequest)
        +reload()
        +goBack()
    }
    
    class WebPage {
        <<class>>
        -backingWebView: WKWebView
        +url: URL?
        +canGoBack: Bool
        +estimatedProgress: Double
    }
    
    class WebPageNavigationHandling {
        <<protocol>>
        +handleNavigationCommit()
    }
    
    class InAppBrowserNavigationHandler {
        -owner: InAppBrowserViewModel?
        +handleNavigationCommit()
    }
    
    WebPageRepresentable <|.. WebPage
    WebPageNavigationHandling <|.. InAppBrowserNavigationHandler
    WebPage --> WKNavigationDelegateAdapter

定义最小化的网页操作接口 WebPageRepresentable

@MainActor
protocol WebPageRepresentable: Observable {
    var url: URL? { get }
    var canGoBack: Bool { get }
    var estimatedProgress: Double { get }
    
    func load(_ request: URLRequest)
    func reload()
    func goBack()
    // ...
}

这种抽象实现了:

  • 支持依赖注入和模拟替换
  • 各层无需直接依赖 WebKit

将 WKWebView 封装在实现内部

在 UI 层定义自定义 WebPage,内部持有 WKWebView

import WebKit

@Observable
@MainActor
final class WebPage: WebPageRepresentable {
    let backingWebView: WKWebView
    
    var url: URL? {
        backingWebView.url
    }
    
    func load(_ request: URLRequest) {
        backingWebView.load(request)
    }
    // ...
}

关键约束:只有 UI 层 import WebKit,WebKit 类型不会泄露到其他层。

KVO 与 Observation 的桥接

参考 WebKit 官方实现,构建了 KVO 与 Observation 的桥接机制:

sequenceDiagram
    participant KVO as WKWebView (KVO)
    participant Bridge as Observation Bridge
    participant Obs as Observation Registrar
    
    KVO->>Bridge: 属性变化通知
    Bridge->>Obs: willSet(keyPath)
    Bridge->>KVO: 更新值
    Bridge->>Obs: didSet(keyPath)
    Obs->>SwiftUI: 触发视图更新
private func createObservation<Value, BackingValue>(
    for keyPath: KeyPath<WebPage, Value>,
    backedBy backingKeyPath: KeyPath<WKWebView, BackingValue>
) -> NSKeyValueObservation {
    return backingWebView.observe(
        backingKeyPath,
        options: [.prior, .old, .new]
    ) { [_$observationRegistrar, unowned self] _, change in
        if change.isPrior {
            _$observationRegistrar.willSet(self, keyPath: keyPath)
        } else {
            _$observationRegistrar.didSet(self, keyPath: keyPath)
        }
    }
}

这样 SwiftUI(或使用 Observation 的层)看到的是普通的 Observable 类型,而实际追踪的是 WKWebView 的状态变化。

另外,为 URL 变化添加了专门的通知逻辑,防止历史记录遗漏。

WebKit 类型的重定义

WKFrameInfo 等 WebKit 类型虽然是数据结构却被定义为 class,导致值语义和引用语义模糊。因此重新定义了只包含必要信息的 struct 类型(如 WebPageFrameInfo):

flowchart LR
    subgraph 类型语义明确化
        WK[WKFrameInfo<br/>class - 语义模糊]
        WP[WebPageFrameInfo<br/>struct - 值语义明确]
    end
    WK -->|重定义| WP

收益:

  • 明确可作为值处理
  • 不会意外引入引用语义
  • 不向层外暴露 WebKit 类型

委托类型的隐藏

参考 WebKit 官方实现,在内部持有委托适配器:

业务逻辑层

@MainActor
protocol WebPageNavigationHandling {
    func handleNavigationCommit()
    // ...
}

UI 层

@MainActor
@Observable
final class WebPage: WebPageRepresentable {
    private let backingNavigationDelegate: WKNavigationDelegateAdapter
    
    init(navigationHandler: some WebPageNavigationHandling) {
        backingNavigationDelegate = WKNavigationDelegateAdapter(navigationHandler)
        backingWebView.navigationDelegate = backingNavigationDelegate
    }
    // ...
}

@MainActor
final class WKNavigationDelegateAdapter: NSObject, WKNavigationDelegate {
    private let navigationHandler: any WebPageNavigationHandling
    
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation) {
        navigationHandler.handleNavigationCommit()
    }
    // ...
}
flowchart TB
    subgraph 委托隐藏
        UI[UI 层<br/>WebPage]
        Adapter[WKNavigationDelegateAdapter<br/>内部类]
        Handler[WebPageNavigationHandling<br/>协议]
        VM[业务逻辑层<br/>NavigationHandler]
    end
    
    UI --> Adapter --> Handler
    VM ..> Handler
    
    style Adapter fill:#fff3e0

这样隐藏了 NSObject 等功能过剩的类型和 WebKit 特有类型,只向外部公开必要的职责。

事件处理专用类

传统做法常扩展 UIViewControllerUIView 来遵循各种委托,但这容易导致 ViewController 臃肿,导航和安全判断与 UI 紧密耦合。即使改为扩展 ViewModel,也只是 ViewModel 的扩展,职责边界仍然模糊。

因此参考 WebKit 官方实现,创建了专门处理导航相关事件并操作 ViewModel 的类:

@MainActor
final class InAppBrowserNavigationHandler: WebPageNavigationHandling {
    weak var owner: InAppBrowserViewModel?
    
    func handleNavigationCommit() {
        // 操作 owner
    }
}
flowchart LR
    subgraph 职责分离
        VM[InAppBrowserViewModel]
        Handler[InAppBrowserNavigationHandler]
        WebP[WebPage]
    end
    
    Handler -->|持有弱引用| VM
    Handler -->|处理导航事件| WebP
    VM -->|使用| WebP
    
    style Handler fill:#e8f5e9

这样将网页相关事件处理从 ViewModel 中分离,明确了各自的职责。


未来展望

flowchart TB
    subgraph 演进路线
        A[当前: 功能模块内实现]
        B[中期: 独立 Package]
        C[长期: SwiftUI 化]
    end
    
    A --> B --> C
    
    subgraph Package 结构
        P1[UI 模块<br/>public]
        P2[逻辑模块<br/>package 访问级别]
    end
    
    B --> P1
    B --> P2

目前实现封闭在应用内浏览器功能模块内,未来计划:

  1. 提取为独立 Package:使用 package 访问修饰符分离 UI 和非 UI(逻辑)的库结构
  2. 长期 SwiftUI 化:逐步迁移到 SwiftUI 基础实现

总结

WebKit 作为 Apple 官方开源库,罕见地体现了现代设计理念:

  • SwiftUI 优先的设计
  • Observation 支持
  • 积极隐藏 legacy API

即使由于产品限制无法直接采用最新 API,也可以从中提取设计精髓,根据自研上下文重新构建,为未来的迁移打下基础。

flowchart TB
    subgraph 核心收获
        A[WebPage 设计理念]
        B[职责分离架构]
        C[现代 Swift 特性应用]
        D[可测试性保障]
    end
    
    A --> B --> C --> D

通过这种方式,既能满足当前版本兼容性要求,又能为未来向官方 API 迁移做好准备。

iOS开发-适配XCode26、iOS26

1、核心特性

@Observable Object

UIKit 支持 @Observable 类型;数据(属性值)变更时,UI 自动更新;提升开发效率,减少手动刷新代码。

updateProperties

UIViewController 和 UIView 新增 updateProperties() 方法;通过修改属性值直接更新 UI

2、UI控件

导航栏

UINavigationItem 和 UIBarButtonItem 新增功能;转场效果 zoom 的触发条件扩展至 UIBarButtonItem

tabbbar栏

UITabBarController 新增 tabBarMinimizeBehavior 属性(类型:UITabBarController.MinimizeBehavior),用于设置 TabBar 最小化时的行为

玻璃风格

UIVisualEffectView 新增 UIGlassEffect 和 UIGlassContainerEffect, 符合 Liquid Glass 风格的视觉效果

按钮

新增 Liquid Glass 风格配置方法

UIView

新增 cornerConfiguration 属性(类型:UICornerConfiguration),用于设置圆角并支持动画

UISlider

新增拖拽时的样式设置, 支持在滑轨上添加刻度

UIImageView

Symbol Animations 新增动画效果:drawOn 和 drawOff

通知系统
  • UIKit 引入强类型通知
  • 提供类型安全和并发安全性
  • 不再使用基于字符串的标识符
  • 不再通过 userInfo 字典传递数据
文件

UIScene Open File

  • 应用内可调用系统功能
  • 将不支持的文件格式交给其他 App 打开
  • iOS 26 后可轻松实现跨应用文件打开

编译问题

  • 编译链接错误:ld: Assertion failed: (it != _dylibToOrdinal.end()), function dylibToOrdinal, file OutputFile.cpp, line 5184

解决:

进入 Target 的 Build Settings 标签: 选中 Target → Build Settings → 搜索 Other Linker Flags。 手动修改链接参数: 点击 Other Linker Flags,首先移除:

-ld64 
-ld_classic

添加:

-Xlinker 
-dead_strip
-Xlinker 
-allow_dead_duplicates

HTTPS超文本传输安全协议全面解析与工作原理

计算机网络---https(超文本传输安全协议)

1. HTTPS的定义与核心定位

HTTPS(HyperText Transfer Protocol Secure,超文本传输安全协议)并非独立于HTTP的“新协议”,而是 HTTP协议与TLS/SSL加密层的结合体——它在HTTP的应用层与TCP传输层(或HTTP/3的QUIC协议层)之间,增加了一套标准化的加密、认证与数据校验机制,核心目标是解决HTTP协议的安全缺陷,保障客户端与服务器之间的通信安全。

从技术本质来看,HTTPS的定位可概括为:

  • 基础不变:仍基于HTTP的请求/响应模型(方法、状态码、头字段完全兼容HTTP),底层传输依赖TCP(HTTP/1.x/2)或UDP(HTTP/3+QUIC);
  • 安全增强:通过TLS/SSL协议实现“加密传输+身份认证+数据防篡改”,弥补HTTP明文传输、无身份校验、数据易被劫持的短板;
  • 行业标配:当前所有涉及隐私(如登录、支付)、敏感数据(如政务、医疗)的场景均强制要求使用HTTPS,主流浏览器(Chrome、Safari)已对HTTP明文网站标记“不安全”警示。

2. HTTPS的核心价值:解决HTTP的三大安全缺陷

要理解HTTPS的必要性,需先明确HTTP的安全漏洞——这些漏洞在公网(如WiFi、运营商网络)中极易被利用,引发隐私泄露、财产损失等风险。HTTPS通过三大核心能力,针对性解决这些问题:

2.1 机密性(Confidentiality):防止数据被窃听

HTTP的致命缺陷是 明文传输:请求/响应内容(如密码、银行卡号、搜索记录)在网络中以“可读文本”形式传输,任何处于传输链路中的设备(如路由器、黑客的抓包工具)都能直接窃取数据。

例如,用户在HTTP网站输入“用户名:test,密码:123456”,抓包工具可直接捕获如下明文:

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=test&password=123456

HTTPS通过 对称加密算法(如AES-256-GCM)解决此问题:客户端与服务器协商出一个“会话密钥”,所有数据传输前用该密钥加密,传输后用同一密钥解密——即使数据被截获,攻击者因无会话密钥,也无法还原为可读文本。

在开发或测试过程中,可以使用抓包工具如 Sniffmaster 来监控和分析 HTTPS 流量,以验证加密效果和排查问题,支持全平台操作且无需复杂代理设置。

2.2 完整性(Integrity):防止数据被篡改

HTTP不校验数据完整性:攻击者可通过“中间人攻击”(MITM)拦截HTTP数据包,修改内容后再转发给目标方,且双方均无法察觉。

典型场景:用户在HTTP电商网站下单,支付金额为100元,攻击者拦截请求后将金额改为10000元,服务器接收并处理修改后的请求,导致用户多支付。

HTTPS通过 哈希算法+数字签名 保证完整性:

  1. 发送方(如服务器)对数据计算哈希值(如SHA-256),生成“数据指纹”;
  2. 用私钥对哈希值签名,生成“数字签名”,与数据一同发送;
  3. 接收方(如客户端)用公钥验证签名,若验证通过,再对接收数据重新计算哈希值;
  4. 对比两次哈希值:一致则数据未被篡改,不一致则数据已被修改,直接丢弃。

2.3 身份认证(Authentication):防止身份被伪造

HTTP无身份认证机制:攻击者可伪造服务器(如搭建虚假WiFi热点,伪装成“商场免费WiFi”),诱骗用户访问虚假网站,窃取用户信息(即“钓鱼攻击”)。

例如,用户想访问 http://www.bank.com,但攻击者伪造了一个域名相似的 http://www.bankk.com,页面样式与真实银行完全一致,用户输入账号密码后,数据直接发送给攻击者。

HTTPS通过 CA证书体系 实现身份认证:服务器需向权威的“证书颁发机构(CA)”申请证书,证书中包含服务器的域名、公钥、CA签名等信息。客户端(如浏览器)接收证书后,会通过内置的“根CA证书”验证服务器证书的合法性——只有验证通过,才确认服务器是真实的,而非伪造。

3. HTTPS的关键组件:TLS/SSL协议与CA证书体系

HTTPS的安全能力依赖两大核心组件:TLS/SSL协议(负责加密与握手)和CA证书(负责身份认证),二者协同工作,构成HTTPS的安全基石。

3.1 TLS/SSL协议:HTTPS的“加密引擎”

TLS(Transport Layer Security,传输层安全协议)是SSL(Secure Sockets Layer)的升级版,当前主流版本为 TLS 1.2TLS 1.3(SSLv3因安全漏洞已被禁用)。TLS协议栈分为三层,自上而下分别为:

协议层 核心功能 关键技术/字段
警报层(Alert) 传递TLS会话中的错误信息(如证书过期、加密套件不支持),触发连接关闭或重试 警报级别(Warning/Fatal)、错误代码(如42表示证书过期)
握手层(Handshake) 协商TLS会话参数(加密套件、会话密钥)、交换证书、验证身份 客户端Hello、服务器Hello、证书、密钥交换消息
记录层(Record) 对应用层数据(HTTP请求/响应)进行分片、压缩、加密、添加认证标签 对称加密算法(AES)、哈希算法(SHA-256)、记录长度
TLS版本演进与核心优化

TLS协议的迭代始终围绕“更安全、更高效”展开,各版本的关键差异如下:

版本 发布时间 核心特点 安全性/性能评价
SSLv3 1996年 首个广泛应用的版本,但存在POODLE漏洞(可被破解加密) 已废弃,完全不安全
TLS 1.0 1999年 修复SSLv3漏洞,引入RSA密钥交换、AES加密 安全性不足(如BEAST漏洞),部分浏览器已禁用
TLS 1.1 2006年 修复BEAST漏洞,改进IV(初始化向量)生成方式 安全性一般,逐步被淘汰
TLS 1.2 2008年 支持SHA-2哈希算法、AEAD加密模式(如AES-GCM),增强完整性与机密性 当前主流版本,安全性可靠
TLS 1.3 2018年 1. 简化握手流程(从4次交互减至3次,支持0-RTT);
2. 移除不安全加密套件;
3. 合并密钥交换与服务器Hello
性能最优、安全性最高,逐步普及

关键优化:TLS 1.3的“0-RTT(Round-Trip Time)”握手可实现“首次重连时无需等待握手完成即可发送数据”,大幅降低延迟——例如,用户第二次访问某网站时,可直接用缓存的会话参数发送请求,无需重新协商密钥。

3.2 CA证书体系:HTTPS的“身份身份证”

CA(Certificate Authority,证书颁发机构)是公认的“网络信任第三方”,负责验证服务器身份并颁发证书。CA证书体系采用“层级信任模型”,确保证书的合法性可追溯,核心构成如下:

证书的核心结构(X.509标准)

一份合法的TLS证书包含以下关键信息(可通过浏览器“查看证书”功能查看):

  • 版本:如X.509 v3;
  • 序列号:CA分配的唯一标识,用于吊销证书;
  • 主体(Subject):证书持有者信息(服务器域名、公司名称等);
  • 公钥信息:服务器的公钥(用于加密会话密钥)及算法(如RSA、ECC);
  • 签发者(Issuer):颁发证书的CA名称(如Let’s Encrypt、Symantec);
  • 有效期:证书生效与过期时间(通常为1-2年,需提前续期);
  • 数字签名:CA用自身私钥对证书内容的哈希值签名,用于客户端验证。
证书的信任链验证

客户端(如浏览器)验证服务器证书时,需通过“信任链”逐层校验,确保证书未被伪造,流程如下:

  1. 客户端接收服务器证书,先验证“服务器证书的签名”——用CA的公钥解密签名,得到哈希值,再对证书内容重新计算哈希值,对比一致则证书内容未被篡改;
  2. 若签发服务器证书的是“中间CA”(而非根CA),则需验证“中间CA证书”的签名,直到追溯至“根CA证书”;
  3. 根CA证书是浏览器/操作系统内置的“信任根”(如微软根CA、苹果根CA),无需额外验证——若根CA未被信任(如自制证书),浏览器会弹出“证书风险”警示,阻止用户访问。
证书的类型与应用场景

根据验证严格程度,CA证书分为三类,适用于不同场景:

  • 域名验证型证书(DV证书):仅验证域名所有权(如通过DNS解析、文件上传验证),无公司信息,仅显示“安全锁”图标,适合个人博客、小型网站,免费(如Let’s Encrypt);
  • 组织验证型证书(OV证书):验证域名所有权+公司主体信息(如营业执照),证书中包含公司名称,适合企业官网、普通电商,需付费(年费数百至数千元);
  • 扩展验证型证书(EV证书):最高级别验证,需提交公司资质、法律文件、实地核验,浏览器地址栏会显示“绿色锁+公司名称”,适合金融、支付、政务网站,费用较高(年费数千元至数万元)。

4. HTTPS的核心工作流程:TLS握手与加密通信

HTTPS的通信过程分为两大阶段: TLS握手阶段(协商会话参数、交换证书、生成会话密钥)和 加密通信阶段(用会话密钥传输HTTP数据)。以下以主流的 TLS 1.2 和优化后的 TLS 1.3 为例,详细拆解流程。

4.1 TLS 1.2握手流程(4次交互)

TLS 1.2握手需客户端与服务器进行4次TCP交互(2个RTT),流程如下:

  1. 客户端Hello(Client Hello)
    • 客户端向服务器发送:支持的TLS版本(如TLS 1.2)、支持的加密套件(如TLS_RSA_WITH_AES_256_GCM_SHA384)、客户端随机数(Client Random,用于后续生成会话密钥)、会话ID(若为重连,携带历史会话ID)。
  2. 服务器Hello + 证书 + 服务器Hello Done
    • 服务器响应:确认TLS版本和加密套件(从客户端支持的列表中选择)、服务器随机数(Server Random,与Client Random共同用于生成密钥)、服务器证书(包含公钥)、“服务器Hello Done”消息(表示服务器已完成初始响应)。
  3. 客户端证书验证 + 密钥交换 + 客户端完成
    • 客户端验证服务器证书:通过信任链校验证书合法性,若验证失败,终止连接并提示风险;
    • 生成预主密钥(Pre-Master Secret):客户端生成一个随机数,用服务器证书中的公钥加密,发送给服务器(即“密钥交换消息”);
    • 生成会话密钥:客户端用Client Random + Server Random + Pre-Master Secret,通过PRF(伪随机函数)生成“会话密钥”(用于后续对称加密);
    • 发送“客户端完成”消息:包含对前序所有握手消息的哈希值+数字签名,证明握手过程未被篡改,同时告知服务器后续将用会话密钥加密数据。
  4. 服务器密钥交换 + 服务器完成
    • 服务器用自身私钥解密“预主密钥”,得到Pre-Master Secret;
    • 用与客户端相同的算法(Client Random + Server Random + Pre-Master Secret)生成会话密钥;
    • 发送“服务器完成”消息:包含对前序握手消息的哈希值+数字签名,告知客户端后续将用会话密钥加密数据。

至此,TLS握手完成,客户端与服务器持有相同的会话密钥,进入加密通信阶段。

4.2 TLS 1.3握手流程(3次交互,优化版)

TLS 1.3通过合并消息、减少交互,将握手压缩至3次TCP交互(1个RTT),流程如下:

  1. 客户端Hello + 密钥交换
    • 客户端发送:支持的TLS 1.3版本、加密套件(仅保留安全套件,如TLS_AES_256_GCM_SHA384)、客户端随机数、“密钥共享”消息(提前生成密钥交换所需的公钥参数,替代TLS 1.2的Pre-Master Secret)。
  2. 服务器Hello + 证书 + 密钥交换 + 服务器完成
    • 服务器响应:确认TLS 1.3版本和加密套件、服务器随机数、服务器证书、“密钥共享”消息(用客户端的公钥参数计算出会话密钥)、“服务器完成”消息(包含握手消息的哈希签名)。
  3. 客户端完成
    • 客户端验证证书后,用服务器的密钥共享参数生成会话密钥;
    • 发送“客户端完成”消息(包含握手消息的哈希签名),进入加密通信阶段。

核心优化:TLS 1.3删除了“服务器Hello Done”消息,将密钥交换与服务器响应合并,减少1次交互;同时支持“0-RTT”模式——若客户端缓存了历史会话参数(如会话票证),首次重连时可直接发送加密的HTTP请求,无需等待握手完成,进一步降低延迟。

4.3 加密通信阶段:HTTP数据的安全传输

TLS握手完成后,客户端与服务器开始用“会话密钥”传输HTTP数据,流程如下:

  1. 应用层(HTTP)生成请求/响应数据(如 GET /index.html HTTP/1.1);
  2. TLS记录层对数据进行处理:
    • 分片:将数据分割为最大16KB的记录;
    • 压缩(可选):用指定算法(如DEFLATE)压缩数据;
    • 加密:用会话密钥通过对称加密算法(如AES-GCM)加密数据;
    • 认证:添加“消息认证码(MAC)”或“认证标签(Tag)”,用于验证数据完整性;
  3. 加密后的记录通过TCP(或QUIC)传输给对方;
  4. 接收方的TLS记录层解密数据、验证完整性、解压缩、重组,再传递给应用层(HTTP)处理。

5. HTTPS的性能优化:打破“HTTPS更慢”的误区

早期HTTPS因TLS握手延迟、加密计算开销,确实比HTTP慢,但随着协议优化(如TLS 1.3)、硬件升级(CPU支持AES指令集)、缓存机制改进,HTTPS的性能已接近HTTP,甚至通过优化可超越HTTP。以下是核心优化方向:

5.1 减少TLS握手延迟

  • 升级TLS 1.3:从2个RTT减至1个RTT,重连时支持0-RTT,延迟降低50%以上;
  • 会话复用
    • 会话ID复用:服务器存储会话参数(如会话密钥),客户端重连时携带会话ID,无需重新协商密钥;
    • 会话票证(TLS Ticket)复用:服务器用密钥加密会话参数,生成“会话票证”发送给客户端,客户端重连时携带票证,服务器解密后直接复用会话,无需存储会话状态(适合分布式服务器);
  • OCSP Stapling(证书状态 stapling):客户端验证证书时,无需向CA服务器查询证书状态(如是否吊销),而是由服务器提前获取CA的OCSP响应,“装订”在证书中一同发送,减少1次CA查询的RTT。

5.2 降低加密计算开销

  • 选择高效加密套件:优先使用AEAD模式的加密套件(如AES-GCM、ChaCha20-Poly1305),兼顾安全与性能——ChaCha20在不支持AES指令集的设备(如低端手机)上性能比AES快3倍;
  • 采用ECC椭圆曲线加密:ECC(Elliptic Curve Cryptography)比RSA更高效,相同安全级别下,ECC的密钥长度更短(如256位ECC≈3072位RSA),加密/解密速度更快,适合移动设备和高并发场景。

5.3 优化证书传输

  • 证书链合并:将服务器证书、中间CA证书合并为一个文件,减少证书传输的TCP连接次数;
  • 证书压缩:使用Brotli或GZIP压缩证书(尤其是EV证书,内容较大),减少传输带宽。

5.4 结合HTTP/3与QUIC

HTTP/3基于QUIC协议(UDP传输),QUIC内置TLS 1.3加密,无需单独的TLS握手——QUIC的“0-RTT”握手可同时完成QUIC连接建立与TLS加密协商,进一步降低延迟;同时QUIC支持“连接迁移”(如手机从WiFi切换到4G,无需重新握手),提升移动场景下的HTTPS体验。

6. HTTPS的常见误区与澄清

误区1:“HTTPS绝对安全,不会被攻击”

HTTPS并非“绝对安全”,仍存在潜在风险,但需满足特定条件:

  • 证书私钥泄露:若服务器私钥被窃取,攻击者可伪造证书,拦截加密数据;
  • 弱加密套件:使用TLS 1.0/1.1或不安全套件(如TLS_RSA_WITH_3DES_EDE_CBC_SHA),可能被破解;
  • 中间人攻击(MITM):若用户信任了伪造的根CA证书(如恶意软件植入伪造根CA),攻击者可拦截并解密HTTPS数据。

应对措施:定期轮换私钥、禁用弱TLS版本和套件、通过安全工具(如Qualys SSL Labs)检测证书配置。此外,工具如 Sniffmaster 提供 HTTPS 抓包功能,支持无需代理的设置,便于开发者进行安全审计和调试。

误区2:“免费CA证书不安全,不如付费证书”

免费证书(如Let’s Encrypt)与付费证书的核心安全机制完全一致,均符合TLS标准,差异仅在验证严格程度和品牌信任度:

  • 安全层面:免费DV证书与付费OV/EV证书均采用相同的加密算法(如AES-256),均可实现机密性、完整性、身份认证;
  • 差异层面:付费证书验证公司信息,适合需要展示企业可信度的场景(如金融),免费证书适合个人或无需展示企业信息的场景(如博客)。

误区3:“HTTPS会增加服务器负载,不适合高并发”

早期HTTPS的加密计算确实会增加服务器CPU负载(约10%-20%),但通过优化可大幅缓解:

  • 硬件优化:使用支持AES-NI指令集的CPU(如Intel Xeon、AMD EPYC),加密计算速度提升10倍以上;
  • 软件优化:Nginx、Apache等服务器已优化TLS处理,支持多进程/多线程并发;
  • 缓存与会话复用:通过会话票证、OCSP Stapling减少重复计算,降低负载。

当前主流互联网公司(如阿里、腾讯)的高并发业务(秒杀、直播)均使用HTTPS,证明其可支撑高并发场景。


HTTPS不仅是“安全协议”,更是当前Web生态的“基础设施”——它通过TLS加密解决了HTTP的安全漏洞,保障了用户隐私与数据安全;同时,HTTPS也是SEO(搜索引擎优化)、PWA(渐进式Web应用)、WebSocket等技术的前提条件,无HTTPS则无法使用这些功能。

未来,HTTPS的发展方向将聚焦于:

  1. TLS 1.3的全面普及:浏览器与服务器逐步淘汰TLS 1.2及以下版本,强制使用更安全、更高效的TLS 1.3;
  2. 量子抗性加密(Post-Quantum Cryptography):研发可抵御量子计算攻击的加密算法(如格密码、哈希签名),避免未来量子计算机破解RSA/ECC加密;
  3. 简化证书管理:通过自动化工具(如Certbot)实现证书的自动申请、续期、部署,降低中小企业使用HTTPS的门槛。

iOS Runtime 深度解析

iOS Runtime 深度解析:原理、实战与前沿趋势

在 iOS 开发中,Runtime(运行时)是 Objective-C(以下简称 OC)语言的灵魂,也是区分 iOS 初级开发者与中高级开发者的核心门槛。它赋予 OC 动态特性,让代码在编译期无法确定的逻辑,能在运行时灵活调整、动态扩展。随着 Swift 生态的完善和 Apple 技术的迭代,Runtime 并未过时,反而在组件化、性能优化、逆向开发等场景中发挥着不可替代的作用。本文将从原理、实战、前沿三个维度,带你全面吃透 iOS Runtime,结合代码示例拆解核心用法,助力你在实际开发中灵活运用这门“黑魔法”。

一、Runtime 核心基础:是什么与为什么

1.1 什么是 Runtime

Runtime 本质上是一套用 C 和汇编语言编写的 API 集合,是 OC 语言与底层系统之间的桥梁,负责将 OC 代码转换为底层可执行的机器指令,实现动态类型、动态绑定、动态加载等核心特性。简单来说,OC 是“动态语言”,核心就在于 Runtime——编译期我们写的 OC 方法调用、属性访问,最终都会被转换为 Runtime 的 C 函数调用,直到运行时才真正确定具体执行逻辑。

举个直观的例子:我们调用 [object method] 时,编译器并不会直接确定 method 方法的具体实现,而是在运行时通过 Runtime 查找该方法的实现并执行,这也是 Runtime 与静态语言(如 C++)的核心区别。

1.2 Runtime 的核心价值

  • 动态扩展:无需修改类的源码,即可为类添加方法、属性,突破 OC 语法限制;
  • 解耦优化:在组件化、插件化开发中,通过 Runtime 实现组件间通信,降低耦合度;
  • 底层适配:解决系统 API 兼容、私有方法调用、逆向开发等场景的核心问题;
  • 性能优化:通过方法缓存、动态解析等机制,提升 App 运行效率。

1.3 核心数据结构

Runtime 的所有功能,都围绕以下几个核心结构体展开,理解它们是掌握 Runtime 的基础:

(1)objc_object:对象的本质

OC 中所有对象的底层都是 objc_object 结构体,核心字段是 isa 指针,用于指向对象所属的类。

// objc 对象的底层结构体
struct objc_object {
    Class isa; // 指向类对象的指针,核心字段
};

// OC 对象的本质就是 objc_object 的指针
typedef struct objc_object *id;

(2)objc_class:类的本质

类对象(Class)的底层是 objc_class 结构体,存储着类的元信息(方法列表、属性列表、协议列表等)。

struct objc_class {
    Class isa; // 指向元类(Meta Class),用于存储类方法
    Class super_class; // 指向父类
    const char *name; // 类名
    long instance_size; // 实例对象的内存大小
    struct objc_ivar_list *ivars; // 实例变量列表
    struct objc_method_list **methodLists; // 方法列表(可动态修改)
    struct objc_cache *cache; // 方法缓存(提升查找效率)
    struct objc_protocol_list *protocols; // 协议列表
};

(3)Method、SEL、IMP:方法的三要素

  • SEL:方法选择器,本质是字符串,用于唯一标识一个方法(如 @selector(method:));
  • IMP:函数指针,指向方法的具体实现,是方法执行的核心;
  • Method:方法结构体,封装了 SELIMP 的对应关系。
// 方法结构体
struct objc_method {
    SEL method_name; // 方法选择器
    char *method_types; // 方法类型编码(返回值、参数类型)
    IMP method_imp; // 方法实现的函数指针
};

二、Runtime 核心机制:从原理到实战

Runtime 的核心机制包括消息传递、方法缓存、动态解析、消息转发、方法交换等,其中消息传递是基础,其他机制都是基于消息传递的扩展。以下结合实战代码,拆解每个机制的原理与用法。

2.1 消息传递:OC 方法调用的本质

OC 中所有方法调用,本质上都是 Runtime 的 objc_msgSend 函数调用。当我们写下 [object method:arg] 时,编译器会自动转换为:

objc_msgSend(object, @selector(method:), arg);

消息传递的完整流程

  1. 通过对象的 isa 指针,找到对象所属的类;
  2. 优先在类的 cache(方法缓存)中查找对应 SELIMP
  3. 若缓存未命中,遍历类的 methodLists 查找方法;
  4. 若当前类未找到,沿着 super_class 父类链向上查找,直到找到 NSObject;
  5. 若找到方法,执行 IMP 并将方法加入缓存(提升下次查找效率);
  6. 若未找到方法,进入消息转发流程(后续详解)。

实战:手动调用 objc_msgSend

需导入 Runtime 头文件 #import <objc/runtime.h>,手动调用消息传递函数:

#import <objc/runtime.h>

@interface Person : NSObject
- (void)sayHello:(NSString *)name;
@end

@implementation Person
- (void)sayHello:(NSString *)name {
    NSLog(@"Hello, %@", name);
}
@end

// 调用方式
Person *person = [[Person alloc] init];
// 1. 常规调用
[person sayHello:@"Runtime"];
// 2. 手动调用 objc_msgSend
SEL sel = @selector(sayHello:);
objc_msgSend(person, sel, @"Runtime"); // 输出:Hello, Runtime

2.2 方法缓存:提升消息传递效率

Runtime 为每个类维护了一个 objc_cache(方法缓存),用于存储最近调用过的方法(SEL + IMP)。缓存采用哈希表实现,查找速度远快于遍历方法列表,这是 Runtime 优化性能的核心手段之一。

核心特点:

  • 缓存只存储“最近调用”的方法,避免缓存过大;
  • 每次调用方法后,若缓存未命中,找到 IMP 后会自动加入缓存;
  • 类的缓存会随着方法调用动态更新,优先保留高频调用的方法。

2.3 动态解析与消息转发:方法未找到的“补救机制”

当消息传递流程中未找到方法时,Runtime 不会直接崩溃,而是提供了三层“补救机制”,让我们有机会动态补充方法实现,避免 App 闪退。

(1)动态方法解析(第一层补救)

通过重写 +resolveInstanceMethod:(实例方法)或 +resolveClassMethod:(类方法),动态为未实现的方法添加实现。

@implementation Person
// 动态解析实例方法
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(sayHello:)) {
        // 为 sel 动态添加实现:参数1=类,参数2=SEL,参数3=IMP,参数4=方法类型编码
        class_addMethod(self, sel, (IMP)dynamicSayHello, "v@:@");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

// 动态添加的方法实现(C语言函数)
void dynamicSayHello(id self, SEL _cmd, NSString *name) {
    NSLog(@"动态解析:Hello, %@", name);
}
@end

// 调用未声明的方法(不会崩溃)
Person *person = [[Person alloc] init];
[person sayHello:@"Dynamic Resolve"]; // 输出:动态解析:Hello, Dynamic Resolve

(2)消息转发(第二层+第三层补救)

若动态解析未处理(返回 NO),则进入消息转发流程,分为两步:

  1. 快速转发:通过 -forwardingTargetForSelector:,将消息转发给另一个对象处理;
  2. 完整转发:若快速转发未处理,通过 -methodSignatureForSelector: 获取方法签名,再通过 -forwardInvocation: 手动处理消息。
实战:快速转发
@interface Student : NSObject
- (void)sayHello:(NSString *)name;
@end

@implementation Student
- (void)sayHello:(NSString *)name {
    NSLog(@"Student 打招呼:Hello, %@", name);
}
@end

@implementation Person
// 快速转发:将消息转发给 Student 对象
- (id)forwardingTargetForSelector:(SEL)aSelector {
    if (aSelector == @selector(sayHello:)) {
        return [[Student alloc] init];
    }
    return [super forwardingTargetForSelector:aSelector];
}
@end

// 调用方法,消息会转发给 Student
Person *person = [[Person alloc] init];
[person sayHello:@"Forward"]; // 输出:Student 打招呼:Hello, Forward
实战:完整转发
@implementation Person
// 1. 获取方法签名(必须实现,否则崩溃)
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (aSelector == @selector(sayHello:)) {
        // 方法签名:返回值void(v),参数id(@)、SEL(:)、NSString(@)
        return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
    }
    return [super methodSignatureForSelector:aSelector];
}

// 2. 手动处理消息
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL sel = anInvocation.selector;
    Student *student = [[Student alloc] init];
    if ([student respondsToSelector:sel]) {
        [anInvocation invokeWithTarget:student]; // 转发给 Student
    } else {
        [super forwardInvocation:anInvocation];
    }
}
@end

2.4 方法交换(Method Swizzling):Runtime 黑魔法

Method Swizzling(方法交换)是 Runtime 最常用的实战技巧,通过交换两个方法的 IMP,实现“hook”效果,无需修改原方法源码,即可拦截、扩展原方法的功能(如埋点、日志、性能监控)。

核心原理:交换两个 Method 结构体中的 IMP 指针,让原 SEL 指向新的实现,新 SEL 指向原实现。

实战:拦截 UIViewController 的 viewDidLoad 方法

#import <objc/runtime.h>
#import <UIKit/UIKit.h>

@implementation UIViewController (Swizzling)
// 在 +load 方法中执行方法交换(+load 方法会在类加载时自动调用,且只调用一次)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 1. 获取两个方法
        Class cls = [self class];
        SEL originalSel = @selector(viewDidLoad);
        SEL swizzledSel = @selector(swizzled_viewDidLoad);
        
        Method originalMethod = class_getInstanceMethod(cls, originalSel);
        Method swizzledMethod = class_getInstanceMethod(cls, swizzledSel);
        
        // 2. 交换方法实现
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

// 新的方法实现(拦截 viewDidLoad)
- (void)swizzled_viewDidLoad {
    // 1. 执行原 viewDidLoad 方法(此时 swizzled_viewDidLoad 指向原实现)
    [self swizzled_viewDidLoad];
    
    // 2. 扩展功能(如埋点、日志)
    NSLog(@"拦截到 %@ 的 viewDidLoad", self.class);
}
@end

方法交换的注意事项

  • dispatch_once_t 保证方法交换只执行一次,避免多次交换导致逻辑错乱;
  • 优先在 +load 方法中执行交换(类加载时执行,时机最早),避免在 +initialize 中执行(可能被多次调用);
  • 交换类方法时,需使用 class_getClassMethod 获取方法,而非 class_getInstanceMethod
  • 避免交换系统私有方法,可能导致 App 审核失败或系统崩溃。

2.5 动态添加属性与关联对象

OC 中,分类(Category)默认不能添加实例变量(ivar),但通过 Runtime 的关联对象(Associated Object),可以间接为分类添加“属性”,本质是将属性值存储在外部哈希表中,与对象关联起来。

实战:为 UIButton 分类添加属性

#import <objc/runtime.h>
#import <UIKit/UIKit.h>

@interface UIButton (Extension)
// 声明属性
@property (nonatomic, copy) NSString *customName;
@end

@implementation UIButton (Extension)
// 定义关联对象的 key(唯一标识)
static const void *CustomNameKey = &CustomNameKey;

// 重写 setter 方法
- (void)setCustomName:(NSString *)customName {
    // 关联对象:参数1=对象,参数2=key,参数3=值,参数4=内存管理策略
    objc_setAssociatedObject(self, CustomNameKey, customName, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

// 重写 getter 方法
- (NSString *)customName {
    // 获取关联对象
    return objc_getAssociatedObject(self, CustomNameKey);
}
@end

// 使用
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
button.customName = @"我的按钮";
NSLog(@"按钮名称:%@", button.customName); // 输出:按钮名称:我的按钮

关联对象的内存管理策略

// 对应 OC 属性的内存修饰符
OBJC_ASSOCIATION_ASSIGN; // assign
OBJC_ASSOCIATION_RETAIN_NONATOMIC; // strong, nonatomic
OBJC_ASSOCIATION_COPY_NONATOMIC; // copy, nonatomic
OBJC_ASSOCIATION_RETAIN; // strong, atomic
OBJC_ASSOCIATION_COPY; // copy, atomic

三、Runtime 前沿趋势:适配 Swift 与 Apple 新生态

随着 Swift 成为 iOS 开发的主流语言,以及 Apple 推出的新工具、新框架(如 Xcode 26、基础模型框架),Runtime 的应用场景也在不断扩展,不再局限于 OC 开发,而是与 Swift 生态深度融合,呈现出全新的发展趋势。

3.1 Runtime 与 Swift 的协同发展

Swift 是静态语言,编译期会进行类型检查,但其底层仍然依赖 Runtime(尤其是与 OC 交互时),同时 Swift 也提供了自己的动态特性(如 @dynamicMemberLookup@objc 关键字),与 OC Runtime 形成互补。

  • Swift 中使用 @objc 修饰的方法、属性,会被暴露给 Runtime,可通过 OC Runtime API 调用;
  • Swift 5.0+ 引入的 @dynamicMemberLookup,允许动态访问属性,本质是 Runtime 动态特性的 Swift 封装;
  • 在 Swift 组件化开发中,通过 Runtime 实现跨模块调用(如通过类名字符串创建对象),解决 Swift 静态编译的限制。

实战:Swift 中调用 Runtime API

import ObjectiveC

class Person: NSObject {
    @objc func sayHello(_ name: String) {
        print("Hello, (name)")
    }
}

// 1. 动态创建对象
let className = "RuntimeDemo.Person"
guard let cls = NSClassFromString(className) as? Person.Type else { return }
let person = cls.init()

// 2. 动态调用方法
let sel = NSSelectorFromString("sayHello:")
person.perform(sel, with: "Swift Runtime") // 输出:Hello, Swift Runtime

// 3. 动态添加关联对象
extension UIButton {
    private static let customKey = UnsafeRawPointer(bitPattern: 0x123456)!
    var customName: String? {
        get {
            objc_getAssociatedObject(self, UIButton.customKey) as? String
        }
        set {
            objc_setAssociatedObject(self, UIButton.customKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }
    }
}

3.2 Runtime 在 Apple 新生态中的应用

随着 Apple 发布 Xcode 26、基础模型框架等新工具,Runtime 的应用场景进一步扩展,尤其在智能开发、性能优化、跨平台适配等方面发挥着重要作用:

  1. 智能开发辅助:Xcode 26 集成了大语言模型,可通过 Runtime 分析类的结构、方法列表,自动生成代码、修复错误,提升开发效率;
  2. 隐私保护与性能优化:基础模型框架支持设备端 AI 推理,Runtime 可动态管理模型调用的生命周期,避免敏感数据泄露,同时通过方法缓存优化 AI 推理的响应速度;
  3. 跨平台适配:Swift 6.2 支持 WebAssembly,Runtime 可帮助开发者实现 OC/Swift 代码与 Web 端的交互,动态适配不同平台的 API 差异;
  4. 逆向开发与安全防护:在 App 安全领域,通过 Runtime Hook 系统方法,可拦截敏感操作(如密码输入、网络请求),防止数据泄露;同时,也可通过 Runtime 混淆方法名、类名,提升 App 反逆向能力。

3.3 Runtime 的未来展望

尽管 Swift 生态日益完善,但 Runtime 作为 iOS 底层核心技术,短期内不会被替代,反而会随着 Apple 技术的迭代不断升级:

  • 更高效的方法缓存机制:Apple 可能进一步优化 objc_cache 的哈希算法,提升消息传递效率;
  • 更安全的动态扩展:加强 Runtime API 的权限管理,避免恶意代码通过 Runtime 篡改 App 逻辑;
  • 与 AI 深度融合:通过 Runtime 动态适配 AI 模型的调用,实现更智能的代码生成、性能优化。

四、结语

iOS Runtime 是 OC 语言的灵魂,也是 iOS 开发的“内功”。它不仅能帮助我们理解 iOS 底层原理,更能在实际开发中解决很多常规语法无法解决的问题——从组件化解耦、性能优化,到逆向开发、安全防护,Runtime 都发挥着不可替代的作用。

随着 Swift 与 Apple 新生态的发展,Runtime 的应用场景不断扩展,它不再是“小众黑魔法”,而是中高级 iOS 开发者必须掌握的核心技能。学习 Runtime,不仅是学习一套 API,更是培养一种“底层思维”——跳出上层语法的限制,从底层理解代码的执行逻辑,才能写出更高效、更健壮、更具扩展性的 iOS 应用。

最后,希望本文能帮助你快速吃透 Runtime 的核心原理与实战用法,在实际开发中灵活运用这门技术,突破自身开发瓶颈,成为更优秀的 iOS 开发者。未来,Runtime 还会不断进化,期待我们一起探索它的更多可能性。

【Lottie】让设计稿上的动效直接"活"在 App 里

【Lottie】让设计稿上的动效直接"活"在 App 里

iOS三方库精读 · 第 5 期


一、一句话介绍

Lottie 是由 Airbnb 开源的跨平台动画库,它让 Adobe After Effects 导出的 JSON 动效文件在 iOS / Android / Web 上以矢量方式实时渲染,彻底消灭"设计交付 → 开发还原"之间的信息损耗。

属性 详情
⭐ Stars 25k+(GitHub)
最新稳定版 4.5.x
License Apache 2.0
支持平台 iOS 14+ / macOS 11+ / tvOS 14+ / visionOS 1+
SwiftUI 原生支持 ✅(4.0 起)

二、为什么选择它

原始痛点

在没有 Lottie 之前,设计师在 After Effects 中做好一个 3 秒的加载动画,开发要干这些事:

  1. 看着动效视频,逐帧拆解关键帧参数
  2. CAKeyframeAnimation / CAAnimationGroup 手写每一条时间曲线
  3. 颜色稍有偏差,回去对着设计稿截图像素级校准
  4. 动效改版?从头重写

这套流程不仅耗时(一个中等复杂动效需 1~3 天还原),还存在不可避免的还原偏差

核心优势

  • 零还原成本:AE 安装 bodymovin 插件,导出 JSON,开发侧 LottieView(animation: .named("xxx")) 一行接入,100% 还原
  • 矢量渲染,无损缩放:JSON 中存储的是贝塞尔曲线参数,任意分辨率下锐利清晰,不像 GIF 有马赛克
  • 极小体积:同等视觉效果的动效,Lottie JSON 通常比 GIF 小 80%~90%
  • 运行时动态换色:通过 DynamicProperty API,在不修改 JSON 文件的前提下替换任意图层的颜色、图片、文字,支持深色模式适配
  • 精细帧控制:可播放任意帧区间、设置播放速度、绑定手势进度,实现交互式动画

三、核心功能速览

基础层 新手必读:环境配置与基础播放

集成方式

Swift Package Manager(推荐)

project.yml(XcodeGen)中添加:

packages:
  lottie-ios:
    url: https://github.com/airbnb/lottie-ios.git
    minorVersion: 4.5.0

dependencies:
  - package: lottie-ios
    product: Lottie

或在 Xcode → File → Add Package Dependencies 搜索:

https://github.com/airbnb/lottie-ios

CocoaPods

pod 'lottie-ios', '~> 4.5'

准备动画 JSON

  1. 在 After Effects 中安装 bodymovin 插件
  2. 渲染导出 → 选择 JSON 格式
  3. xxx.json 拖入 Xcode 工程(确保勾选 Target Membership)
  4. 或从 LottieFiles.com 下载社区免费素材

SwiftUI 基础用法

// Swift 5.9+ / iOS 17+
import SwiftUI
import Lottie

struct ContentView: View {
    var body: some View {
        LottieView(animation: .named("loading"))  // 对应 loading.json
            .playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
            .resizable()
            .scaledToFit()
            .frame(height: 200)
    }
}

UIKit 基础用法

import Lottie

let animationView = LottieAnimationView(name: "loading")
animationView.loopMode = .loop
animationView.contentMode = .scaleAspectFit
animationView.play()

view.addSubview(animationView)

进阶层 最佳实践:常用 API 与核心配置

LottieView 常用修饰符(SwiftUI)

LottieView(animation: .named("confetti"))
    // 播放控制
    .playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce))
    .animationSpeed(1.5)                    // 1.5 倍速
    // 完成回调
    .animationDidFinish { completed in
        print("播放完成: \(completed)")
    }
    // 动态换色
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 0.8, b: 0, a: 1)),
        for: AnimationKeypath(keypath: "**.Color")
    )

LottieAnimationView 常用 API(UIKit)

let av = LottieAnimationView(name: "success")

// 播放控制
av.play()                           // 从当前进度播放到末尾
av.pause()                          // 暂停
av.stop()                           // 停止并重置到开头

// 帧区间播放
av.play(fromFrame: 0, toFrame: 60, loopMode: .playOnce) { finished in
    // finished = true 表示正常播放完毕,false 表示被中断
}

// 手动控制进度(绑定手势)
av.currentProgress = 0.5           // 跳到 50% 位置

// 速度与循环
av.animationSpeed = 2.0
av.loopMode = .loop                 // .playOnce / .loop / .autoReverse / .repeat(3)

播放状态枚举速查

// SwiftUI LottieView.play() 参数
.playing()                                              // 无限循环
.playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
.playing(.paused)                                       // 暂停
.playing(.paused(at: .progress(0.5)))                   // 暂停在 50%
.playing(.paused(at: .frame(30)))                       // 暂停在第 30 帧

深入层 源码视角:渲染架构与关键模块

渲染器选择

Lottie 4.x 提供三种渲染器,可在初始化时指定:

// 默认:Core Animation 渲染器,GPU 友好,支持大部分 AE 特性
LottieConfiguration.shared.renderingEngine = .automatic

// 强制 Core Animation(推荐生产环境)
LottieConfiguration.shared.renderingEngine = .coreAnimation

// 主线程渲染器(兼容性最佳,性能较差,4.x 已逐步弃用)
LottieConfiguration.shared.renderingEngine = .mainThread

关键模块职责

模块 职责
LottieAnimation JSON 解析,将 bodymovin 数据映射为内存模型
AnimationLayer CALayer 树构建器,将动画模型转为 Core Animation 结构
AnimationContext 时间线管理,处理帧率、时间缩放、循环逻辑
ValueProvider 动态属性注入点,DynamicProperty 系统的核心抽象

四、实战演示

场景:电商 App 加载页 + 下单成功动效,含动态换色适配品牌主色

import SwiftUI
import Lottie

// MARK: - 加载页动效(带品牌色动态换色)

struct BrandLoadingView: View {
    @Environment(\.colorScheme) var colorScheme

    /// 品牌主色(浅色/深色模式自适应)
    var brandColor: LottieColor {
        colorScheme == .dark
            ? LottieColor(r: 1.0, g: 0.85, b: 0.0, a: 1.0)   // 深色:亮黄
            : LottieColor(r: 0.9, g: 0.6, b: 0.0, a: 1.0)     // 浅色:金黄
    }

    var body: some View {
        ZStack {
            Color(.systemBackground).ignoresSafeArea()

            LottieView(animation: .named("loading_ring"))
                .playing(.fromProgress(0, toProgress: 1, loopMode: .loop))
                .animationSpeed(0.8)
                // 替换 JSON 中所有名为 "Primary Color" 图层的颜色
                .valueProvider(
                    ColorValueProvider(brandColor),
                    for: AnimationKeypath(keypath: "**.Primary Color.Color")
                )
                .resizable()
                .scaledToFit()
                .frame(width: 120, height: 120)

            Text("加载中...")
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .offset(y: 80)
        }
    }
}

// MARK: - 下单成功动效(单次播放 + 完成回调)

struct OrderSuccessView: View {
    @State private var showContent = false
    @Binding var isPresented: Bool

    var body: some View {
        VStack(spacing: 20) {
            LottieView(animation: .named("success_checkmark"))
                .playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce))
                .animationDidFinish { _ in
                    // 动效播放完毕后展示订单详情
                    withAnimation(.easeIn(duration: 0.3)) { showContent = true }
                }
                .resizable()
                .scaledToFit()
                .frame(height: 160)

            if showContent {
                VStack(spacing: 8) {
                    Text("下单成功!")
                        .font(.title2).bold()
                    Text("预计 3~5 个工作日送达")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    Button("查看订单") { isPresented = false }
                        .buttonStyle(.borderedProminent)
                }
                .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
        .padding(32)
    }
}

要点说明:

  • AnimationKeypath(keypath: "**.Primary Color.Color")** 是通配符,匹配任意深度路径
  • 图层名称需与 AE 中一致,设计师导出前应统一命名规范
  • animationDidFinish 在 SwiftUI 中是 View 修饰符,回调在主线程执行

五、源码亮点

进阶层 值得借鉴的用法

链式 ValueProvider 叠加

多个 valueProvider 可链式叠加,分别控制不同图层:

LottieView(animation: .named("badge"))
    .playing()
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 0.3, b: 0.3, a: 1)),
        for: AnimationKeypath(keypath: "Background.Color")
    )
    .valueProvider(
        ColorValueProvider(LottieColor(r: 1, g: 1, b: 1, a: 1)),
        for: AnimationKeypath(keypath: "Icon.**.Color")
    )
    .valueProvider(
        TextValueProvider("99+"),
        for: AnimationKeypath(keypath: "Badge.Text")
    )

手势驱动进度(类似 pull-to-refresh)

struct GestureDrivenAnimation: View {
    @GestureState private var dragOffset: CGFloat = 0
    @State private var progress: Double = 0

    var body: some View {
        LottieView(animation: .named("pull_refresh"))
            .playing(.paused(at: .progress(progress)))
            .resizable().scaledToFit().frame(height: 80)
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in state = value.translation.height }
                    .onChange(of: dragOffset) { _, new in
                        progress = Double(min(max(new / 120, 0), 1))
                    }
            )
    }
}

深入层 设计思想解析

1. Protocol-Oriented ValueProvider

ValueProvider 是一个协议,内部通过 AnyValueProvider 做类型擦除,使得颜色、数值、文字、图片等完全不同的类型可以共享一套注入 API:

// 库内抽象(简化版)
public protocol AnyValueProvider {
    var valueType: Any.Type { get }
    func hasUpdate(frame: AnimationFrameTime) -> Bool
    func value(frame: AnimationFrameTime) -> Any
}

// 使用侧无感知具体类型
animationView.setValueProvider(colorProvider, keypath: keypath)
animationView.setValueProvider(textProvider, keypath: keypath)

2. Keypath 通配符系统

类似 KVC,但专为 AE 图层树设计,支持 **(任意路径深度)和 *(单层通配):

"Button.Background.Color"      → 精确匹配
"**.Color"                     → 所有名为 Color 的属性
"Button.*.Color"               → Button 子级的任意图层的 Color

3. Core Animation 渲染器的零主线程原则

Lottie 4.x 的 Core Animation 渲染器将所有动画帧的计算预烘焙为 CAAnimation 关键帧,提交给 Render Server 后完全在主线程之外运行,即使主线程卡顿也不会影响动效流畅度——这正是它相比 mainThread 渲染器的核心优势。


六、踩坑记录

问题 1:JSON 加载返回 nil,动效不显示

原因:JSON 文件未加入 Target Membership,或文件名拼写错误(大小写敏感)。

解决:选中 JSON 文件 → Xcode 右侧 File Inspector → 勾选对应 Target;或用 URL 方式加载并捕获错误:

let animation = LottieAnimation.named("loading")  // 返回 Optional
// 或
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
    let animation = try? LottieAnimation.loadedFrom(url: url)
}

问题 2:DynamicProperty 换色不生效

原因:Keypath 中的图层名称与 AE 中不一致(导出时 bodymovin 会对图层名做 URL 编码);或使用了 Core Animation 渲染器但该属性不支持动态修改。

解决:启用调试日志查看所有可用 Keypath:

// 打印动画内所有可被覆盖的属性路径
if let animation = LottieAnimation.named("badge") {
    let paths = animation.keypaths(for: .init(keypath: "**"))
    paths.forEach { print($0) }
}

问题 3:Swift 6 / Sendable 编译报错

原因:Lottie 4.4 以前部分类型未标注 @MainActor,在 Swift 6 严格并发检查下会报 Sendable 违规。

解决:升级到 Lottie 4.5+(已系统性修复 Swift 6 合规问题);或临时在模块级别关闭严格检查:

// 临时方案(不推荐长期保留)
import Lottie
nonisolated(unsafe) let sharedAnimation = LottieAnimation.named("loading")

问题 4:在 List / ScrollView 中大量 LottieView 导致卡顿

原因:每个 LottieView 初始化时都会同步解析 JSON 并构建 Layer 树,Cell 复用时重复创建开销大。

解决:预加载并缓存 LottieAnimation 对象,复用时只更新播放状态:

// 在 ViewModel / 缓存层提前加载
final class AnimationCache {
    static let shared = AnimationCache()
    private var cache: [String: LottieAnimation] = [:]

    func animation(named name: String) -> LottieAnimation? {
        if let cached = cache[name] { return cached }
        let anim = LottieAnimation.named(name)
        cache[name] = anim
        return anim
    }
}

// 使用
LottieView(animation: AnimationCache.shared.animation(named: "like_button"))
    .playing()

问题 5:autoReverse 循环模式在 SwiftUI 中反向播放后卡住

原因.autoReverse 在部分版本的 LottieView 中有已知 Bug,正向→反向后停在第 0 帧不再循环。

解决:用 .loop 替代,并在 animationDidFinish 中手动反转进度,或升级到最新 Lottie 版本。


问题 6:从网络 URL 加载动效时闪烁

原因:网络请求完成前 LottieView 已渲染了空状态,数据到来后重新布局导致闪烁。

解决:使用 LottieView 的 URL 加载重载 + showPlaceholder 搭配:

LottieView {
    try await LottieAnimation.loadedFrom(
        url: URL(string: "https://example.com/fireworks.json")!
    )
}
.playing()
.background { ProgressView() }  // 加载中占位

七、延伸思考

Lottie vs 主流动画方案横向对比

维度 Lottie Rive SwiftUI 原生动画 CAAnimation
文件格式 JSON (bodymovin) .riv (专有) 代码 代码
设计协作 AE 直出,零交接 Rive 编辑器 开发手写 开发手写
交互状态机 ⚠️ 有限 ✅ 内建 ⚠️ 有限
渲染性能 ✅ GPU 加速 ✅ 极佳
动态换色 ✅ DynamicProperty ✅ 输入驱动
包体积(库本身) ~4 MB ~2 MB 0 0
社区素材库 ✅ LottieFiles 海量 ⚠️ 较少
维护状态 活跃 活跃 Apple 官方 Apple 官方
学习曲线 低~中

推荐使用 Lottie 的场景

  • Splash Screen / 启动动画
  • Loading / 空状态 / 错误状态插画动效
  • 点赞、收藏、成功等一次性触发的微交互动效
  • 设计团队已有 AE 工作流,动效资产丰富
  • 需要在 LottieFiles 快速取用社区素材

不推荐使用 Lottie 的场景

  • 动效极简(仅 opacity / scale / translate)→ SwiftUI .animation() 即可
  • 需要复杂交互状态机(手势联动多个状态跳转)→ 考虑 Rive
  • 需要 3D 变换效果 → SceneKitRealityKit
  • 超大 JSON(> 2MB)在列表中大量实例化 → 需谨慎评估性能

八、参考资源


九、本期互动


小作业

在你自己的项目(或 Demo 工程)中实现一个点赞按钮

  1. 点击前显示灰色心形(未点赞状态,可用 SF Symbol 或 Lottie JSON)
  2. 点击时播放 Lottie 爱心爆炸动效(建议从 LottieFiles 搜索 "like" 下载)
  3. 播放完成后停留在点赞完成帧
  4. 再次点击恢复未点赞状态

完成标准:能在真机或模拟器上稳定运行,按钮不会出现状态错乱。欢迎在评论区贴出实现思路或截图!


思考题

Lottie 的 DynamicProperty 机制允许在运行时"注入"新的值覆盖 JSON 中预设的属性。这种控制反转(IoC)的设计思路,在 iOS 开发中还有哪些类似的应用?你会如何把这种思路用在自己的业务组件设计上?


读者征集

下一期选题投票正在进行!同时:你在使用 Lottie 时踩过哪些坑? 欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新

✅ 第1期:Alamofire · ✅ 第2期:Kingfisher · ✅ 第3期:MarkdownUI · ✅ 第4期:SnapKit · ➡️ 第5期:Lottie · ○ 第6期:待定

OpenClaw Memory Wiki 技术文档

OpenClaw Memory Wiki 技术文档

基于 OpenClaw v2026.4.7 最新版本整理,更新日期:2026-04-08

目录


概述

OpenClaw 是一个开源的个人 AI 代理框架,其记忆系统采用 基于文件的记忆模型——所有持久化信息以 Markdown 文件形式存储在代理工作空间中(默认路径:~/.openclaw/workspace)。系统不维护任何隐藏状态,只有显式写入磁盘的内容才计入记忆。

Memory Wiki 是 OpenClaw 记忆体系中的高级层,作为可选的伴生插件(memory-wiki),将持久化记忆编译为一个具有溯源能力的知识库(vault),支持确定性页面布局、结构化声明(claims)、矛盾追踪和机器可读摘要。


核心架构

OpenClaw 的记忆系统由三层文件构成:

文件 作用 加载时机
MEMORY.md 长期持久存储:事实、偏好、决策 每次会话开始自动加载
memory/YYYY-MM-DD.md 每日笔记:运行中的上下文与观察 当日及前一日自动加载
DREAMS.md 实验性:梦境日记与巩固摘要 可选,供人工审阅

核心记忆工具:

  • **memory_search**:语义搜索,匹配概念含义而非精确措辞
  • **memory_get**:检索特定的记忆文件或指定行范围

Memory Wiki 作为补充层叠加在核心记忆之上,不替换核心记忆插件。


Memory Wiki 插件

Vault 模式

Memory Wiki 支持两种运行模式:

1. Isolated(隔离)模式

1
2
3
4
5
memory-wiki:
vaultMode: "isolated"
vault:
path: "~/.openclaw/wiki/main"
renderMode: "obsidian"
  • Wiki 拥有独立的 vault 和数据源
  • 不依赖 memory-core
  • 适用于:希望 wiki 作为独立的、经过策展的知识库

2. Bridge(桥接)模式

1
2
memory-wiki:
vaultMode: "bridge"
  • 通过公共插件 SDK 接口读取活跃记忆插件的公开记忆 artifacts 和事件
  • 不直接访问私有插件内部实现
  • 适用于:希望 wiki 编译和组织核心记忆插件导出的 artifacts

建议:除非明确需要桥接模式,否则优先选择 isolated 模式。

页面组织结构

Wiki vault 采用确定性目录布局:

1
2
3
4
5
6
7
8
9
10
11
~/.openclaw/wiki/main/
├── sources/ # 导入的原始材料、桥接页面
├── entities/ # 持久对象:人物、系统、项目
├── concepts/ # 观念、抽象、模式、策略
├── syntheses/ # 编译摘要、维护性汇总
├── reports/ # 生成的报告
├── _attachments/ # 附件资源
├── _views/ # 视图定义
└── .openclaw-wiki/ # 托管内容与缓存
└── cache/
└── claims.jsonl # 编译后的声明摘要

关键目录说明

目录 内容 示例
sources/ 原始导入材料与桥接页面 论文摘录、会议纪要
entities/ 持久对象——人、系统、项目 entity.kubernetesentity.alice
concepts/ 抽象概念与模式 concept.event-sourcing
syntheses/ 编译摘要与汇总 synthesis.q1-review

结构化 Claim/Evidence 模型

Memory Wiki 的核心创新是将知识从自由文本升级为 结构化声明。每个页面可在 frontmatter 中携带结构化的 claims:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---
id: entity.kubernetes
claims:
- claim: "Kubernetes 默认调度器使用 bin-packing 策略"
confidence: 0.85
source: "sources/k8s-scheduler-doc"
updated: 2026-03-15
status: active
- claim: "Helm v4 已移除 Tiller 依赖"
confidence: 0.95
source: "sources/helm-release-notes"
updated: 2026-04-01
status: active
---

# Kubernetes

正文内容...

Claim 字段说明

字段 类型 说明
claim string 声明内容
confidence float 置信度(0-1)
source string 溯源引用(指向 sources/ 下的页面)
updated date 最后更新日期
status enum active / contested / resolved / stale

Claims 可被追踪、评分、质疑和溯源,使 wiki 的行为更像一个 信念层(belief layer) 而非被动的笔记堆。


关键能力

矛盾检测与聚类

wiki_lint 工具能自动扫描 vault 中的结构性问题:

  • 矛盾检测:发现语义上互相冲突的 claims
  • 矛盾聚类(Contradiction Clustering):将相关的矛盾声明分组,便于集中解决
  • 溯源缺口:标记缺少 source 引用的 claims
  • 开放问题:识别尚未解决的疑问

新鲜度加权搜索

wiki_search 的搜索排序综合考虑:

  • 语义相关性:基于向量相似度的概念匹配
  • 关键词匹配:精确标识符和代码符号的 BM25 匹配
  • 新鲜度权重(Freshness Weighting):最近更新的 claims 获得更高排名
  • 置信度得分:高置信度的声明优先展示

编译摘要(Compiled Digests)

为避免代理和运行时代码在查询时解析 Markdown 页面,Memory Wiki 维护编译后的摘要:

1
.openclaw-wiki/cache/claims.jsonl

每行为一个 JSON 对象,包含 claim 的完整元数据。代理可直接读取此文件进行高效查询,无需遍历页面。

过时性仪表盘

Memory Wiki 内置 Staleness Dashboard,可视化展示:

  • 各 claim 的最后更新时间
  • 过时(stale)声明的数量与分布
  • 需要审查的知识区域

Wiki 工具集

Memory Wiki 插件注册以下工具供代理使用:

工具 功能
wiki_status 显示当前 vault 模式、健康状态、Obsidian CLI 可用性
wiki_search 搜索 wiki 页面,支持共享记忆语料库
wiki_get 按 id/path 读取 wiki 页面,可回退至共享记忆语料库
wiki_apply 执行窄范围的综合/元数据变更,无需全页编辑
wiki_lint 结构检查:溯源缺口、矛盾、开放问题

使用建议

  • 当溯源(provenance)重要时,使用 wiki_search / wiki_get 而非通用 memory_search
  • 对元数据更新使用 wiki_apply,避免自由编辑页面
  • 有意义的变更后运行 wiki_lint

CLI 命令参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 状态与诊断
openclaw wiki status # 查看 vault 状态
openclaw wiki doctor # 诊断 vault 健康问题

# 初始化与数据导入
openclaw wiki init # 初始化新 vault
openclaw wiki ingest ./notes/alpha.md # 导入外部文档

# 编译与质量检查
openclaw wiki compile # 重新编译 claims 摘要
openclaw wiki lint # 结构检查与矛盾检测

# 搜索与检索
openclaw wiki search "kubernetes" # 搜索 wiki 内容
openclaw wiki get entity.alpha # 获取指定页面

# 综合与应用
openclaw wiki apply synthesis # 应用综合更新

# Obsidian 集成
openclaw wiki obsidian status # 检查 Obsidian 集成状态

Obsidian 集成

Memory Wiki 支持与 Obsidian 笔记软件深度集成:

1
2
3
4
5
6
memory-wiki:
obsidian:
enabled: true
useOfficialCli: true # 使用 Obsidian 官方 CLI (v1.12+)
vaultName: "openclaw-wiki"
openAfterWrite: false

官方 Obsidian CLI(v1.12+)提供完整的 vault 自动化能力,包括:文件管理、每日笔记、搜索、任务、标签、属性、链接、书签、模板、主题、插件、同步与发布。

renderMode 设为 "obsidian" 时,Wiki 页面输出为 Obsidian 兼容格式,可直接在 Obsidian 中浏览和编辑。


Dreaming 系统(实验性)

Dreaming 是一个可选的后台巩固流程,与 Memory Wiki 配合工作:

  1. 收集(Collect):从每日笔记中提取短期信号
  2. 评分(Score):基于阈值(得分、召回频率、查询多样性)筛选候选项
  3. 晋升(Promote):将合格项目提升至长期记忆(MEMORY.md
  4. 记录(Document):在 DREAMS.md 中写入阶段性摘要

v2026.4.7 中 Dreaming 系统的改进:

  • 支持将脱敏的会话转录导入 dreaming 语料库
  • 按天生成 session-corpus 笔记
  • 游标检查点与晋升/诊断支持
  • 在每日笔记导入前剥离托管的 Light Sleep 和 REM 块

搜索后端与混合检索

Memory Wiki 的搜索依托 OpenClaw 的混合检索架构:

后端 特点
Builtin(默认) 基于 SQLite,支持关键词、向量和混合搜索
QMD 本地优先,支持 reranking 和外部目录索引
Honcho AI 原生跨会话记忆,支持用户建模

当配置了 embedding provider 时(支持 OpenAI、Gemini、Voyage、Mistral),wiki_search 采用 混合搜索 策略:

  • 向量相似度:语义理解层面的概念匹配
  • BM25 关键词匹配:精确标识符与代码符号匹配
  • 新鲜度加权:近期更新的内容获得排名提升

v2026.4.7 新增了当 sqlite-vec 不可用或向量写入降级时的显式警告。


配置参考

完整的 Memory Wiki 插件配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
plugins:
memory-wiki:
enabled: true
vaultMode: "isolated" # "isolated" | "bridge"
vault:
path: "~/.openclaw/wiki/main"
renderMode: "obsidian" # "obsidian" | "plain"
obsidian:
enabled: true
useOfficialCli: true
vaultName: "openclaw-wiki"
openAfterWrite: false
ingest:
autoIndex: true
search:
backend: "builtin" # "builtin" | "qmd" | "honcho"
freshnessWeight: 0.3 # 新鲜度权重系数
lint:
contradictionClustering: true
stalenessThresholdDays: 30
dashboard:
enabled: true

v2026.4.7 更新要点

OpenClaw v2026.4.7 是 Memory Wiki 的重要里程碑版本,恢复了完整的 memory-wiki 栈:

Memory Wiki 核心恢复

  • 插件 + CLI + sync/query/apply 工具链
  • Memory-host 集成
  • 结构化 claim/evidence 字段
  • 编译摘要检索
  • Claim 健康度 linting
  • 矛盾聚类
  • 过时性仪表盘
  • 新鲜度加权搜索

其他相关更新

  • 推理中心:新增 openclaw infer hub,支持跨 model/media/web/embedding 的 provider 推理工作流
  • 媒体生成:工具/媒体生成支持跨 provider 自动降级,保留意图
  • Webhook 集成:内置 webhook ingress 插件,支持外部自动化创建和驱动 TaskFlow
  • 向量召回警告sqlite-vec 不可用时显式提醒
  • Dreams 配置感知:Dreams 配置读写现在尊重选定的 memory slot 插件

参考资料

赛博探案集:用 Vision 框架在像素迷宫中“揪”出文字真凶

在这里插入图片描述

这里是后厂村阴影中最神秘的“全栈侦探事务所”。当你的 if-else 走到尽头,当你的 Bug 堆积如山,资深探长“老司机”就是你最后的救命稻草。本期案卷记录了一次关于“像素与文字”的离奇遭遇:实习生阿强因“人肉 OCR”识别截图密码失败,险些引发上线事故。面对这起“视力危机”,我们拒绝蛮力,祭出了 Apple 强大的 Vision 框架。这不仅是一篇关于如何用 Swift 实现 OCR(文字识别)的硬核教程,更是一场从构建“文字捕手”到破解“坐标迷宫”的技术探险。准备好了吗?泡好你的枸杞咖啡,跟随老司机的代码,一起揭开隐藏在图片像素背后的真相。

🕵️‍♂️ 引子

在一个雷雨交加的周五深夜,位于后厂村的“全栈侦探事务所”依然灯火通明。传说中,这里有一位代号为“老司机”的资深工程师,他不仅能用汇编语言写情书,还能在没有任何文档的遗留代码(Legacy Code)中自由穿梭。

就在刚刚,事务所的大门被撞开了。实习生阿强跌跌撞撞地跑进来,手里挥舞着一张模糊不清的截图,脸上写满了被产品经理折磨后的绝望。“老大!出大事了!这图片里藏着服务器的 Root 密码,但我手抄了三次都提示错误!现在上线倒计时只剩 30 分钟了!”

在这里插入图片描述

老司机缓缓放下手中早已凉透的黑咖啡,推了推鼻梁上那副防蓝光眼镜,嘴角勾起一抹神秘的微笑。“阿强,把你的‘人肉 OCR’停一停吧。在 Apple 的地盘上,我们有更优雅的武器——Vision 框架。”

在本次探案之旅中,您将学到如下内容:

  • 🕵️‍♂️ 引子
  • 🤖 第一章:不仅是扫码工具人的 Vision
  • 🛠️ 第二章:打造“文字捕手” (The Text Recognizer)
  • ⚠️ 老司机的技术批注:
  • 🎯 第三章:给真相画个圈 (Highlighting Found Text)
  • 🤝 终章:真相大白

他指尖在机械键盘上飞舞,屏幕上开始跳动起绿色的代码符文。“坐好,今晚带你见识一下,如何用机器学习的‘天眼’,让图片里的文字自己‘招供’。”

在这里插入图片描述


🤖 第一章:不仅是扫码工具人的 Vision

听好了,阿强。大多数人对 Apple Vision 框架的印象,还停留在扫个二维码或者条形码这种“小儿科”的阶段。这就好比你拿着一把激光剑去切西瓜——简直是暴殄天物!

实际上,Vision 就像是给你的 App 装上了一双“写轮眼”。它不仅能从图片中识别并定位文字(Text Detection),还能把图片里的特定区域剥离出来、在连续的视频帧里追踪物体、甚至检测你那僵硬的手势和坐姿!

在这里插入图片描述

我第一次跟 Vision 打交道的时候,是写了一个 Swift 命令行工具来移除图片背景 ✂️。那时候我就意识到,这玩意儿简直是修图师的噩梦,程序员的福音。但今天,我们要用它来做点更硬核的——文字识别

在这里插入图片描述


🛠️ 第二章:打造“文字捕手” (The Text Recognizer)

要在茫茫像素中提取文字,我们得先组装一个名为 TextRecognizer 的“审讯室”。在这个环节,我们要用到 Vision 的核心组件:RecognizeTextRequest

这就好比我们向系统提交一份“搜查令”,告诉它:“嘿,帮我把这张图里的字儿都给我找出来,而且要准(Accurate)!”

在这里插入图片描述

来看看这段代码,这可是我们的核心武器:

import Foundation
import SwiftUI
import Vision
 
struct TextRecognizer {
    var recognizedText = ""
    // 保存识别到的所有“线索”(观察结果)
    var observations: [RecognizedTextObservation] = []
 
    // 这个初始化器是异步的,因为查案需要时间,急不得
    init(imageResource: ImageResource) async {
        // 1. 创建搜查令:RecognizeTextRequest
        var request = RecognizeTextRequest()
        // 2. 将识别精度设置为 .accurate(我们要的是精准打击,不是瞎猜)
        request.recognitionLevel = .accurate
        
        // 3. 将 ImageResource 转换为 UIImage
        let image = UIImage(resource: imageResource)
        
        // 4. 重点来了!Vision 不吃 UIImage 这一套,它只认二进制数据 Data
        // 所以我们必须把图片“粉碎”成 PNG 数据
        if let imageData = image.pngData(),
           // 执行搜查任务(perform)。这一步可能会失败,所以用了 try? 来“掩耳盗铃”
           // 注意:这里是异步等待结果
           let results = try? await request.perform(on: imageData) {
            
            // 5. 将抓获的嫌疑人(观察结果)关进 observations 数组
            observations = results
        }
 
        // 6. 审讯环节:遍历每一个观察结果
        for observation in observations {
            // 获取可能性最高的那个“候选词”(topCandidates(1))
            // 就像指认现场,我们通常只信最像的那个
            let candidate = observation.topCandidates(1)
            if let observedText = candidate.first?.string {
                // 把招供的文字拼接到结果字符串里
                recognizedText += "\n\(observedText) "
            }
        }
    }
}

在这里插入图片描述

⚠️ 老司机的技术批注:

这里有个坑你要注意,阿强。RecognizeTextRequest 是个挑剔的家伙,它不能直接处理 Swift 的 ImageUIImage 对象,它需要生肉——也就是 Image Data

在这里插入图片描述

所以我们必须先把图片转成 Data 格式。另外,整个过程是 async(异步)的,毕竟机器学习这玩意儿虽然快,但也没快到能超越光速,我们得给 CPU 一点“思考”的时间。

在这里插入图片描述

接下来,我们把这个“文字捕手”集成到 SwiftUI 的视图里,让你亲眼看看效果:

import SwiftUI
 
struct TextRecognitionView: View {
    let imageResource: ImageResource
    // 状态变量,一旦侦探有了结果,界面就会刷新
    @State private var textRecognizer: TextRecognizer?
 
    var body: some View {
        List {
            // 展示嫌疑图片
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
            }
            .listRowBackground(Color.clear)
 
            // 展示审讯结果(识别出的文字)
            Section {
                // 如果 textRecognizer 还没初始化好,就先显示空字符串
                Text(textRecognizer?.recognizedText ?? "")
            } header: {
                Text("从图片中提取的证词")
            }
        }
        .navigationTitle("文字侦探")
        .task {
            // 重点:在 .task 修饰符里调用异步初始化器
            // 就像在后台偷偷干活,不阻塞主线程 UI 的渲染
            textRecognizer = await TextRecognizer(imageResource: imageResource)
        }
    }
}

这时候,阿强凑过来看着模拟器屏幕,只见原本模糊的截图下方,整整齐齐地列出了识别出来的文字。“卧槽,神了!连那个像‘1’又像‘l’的字符都分清了!”

在这里插入图片描述


🎯 第三章:给真相画个圈 (Highlighting Found Text)

“别急着庆祝,阿强。”我敲了敲桌子,“光把字认出来还不够,我们要做到按图索骥。既然 Vision 已经告诉了我们文字在哪里,我们就得在图片上把它们圈出来,就像犯罪现场的粉笔线一样。”

在这里插入图片描述

这里涉及到一个让很多新手头秃的概念:坐标系转换

Vision 返回的坐标是归一化的(Normalized),也就是说,它的 x 和 y 都在 0.0 到 1.0 之间。左下角是 (0,0),右上角是 (1,1)。但我们的屏幕图片是按像素画的,而且 UIKit/SwiftUI 的坐标原点通常在左上角。这就好比火星人给地球人指路,如果不好好翻译一下坐标,你画的框可能会飞到姥姥家去。

我们需要定义一个 Shape,专门用来画框:

import Foundation
import SwiftUI
import Vision
 
struct BoundsRect: Shape {
    // 这里存的是 Vision 给我们的“火星坐标”(归一化矩形)
    let normalizedRect: NormalizedRect
 
    func path(in rect: CGRect) -> Path {
        // 关键时刻!将归一化坐标转换为图片的实际像素坐标
        // origin: .upperLeft 是为了适配 SwiftUI 的坐标系习惯
        let imageCoordinatesRect = normalizedRect
            .toImageCoordinates(rect.size, origin: .upperLeft)
        return Path(imageCoordinatesRect)
    }
}

在这里插入图片描述

🔍 技术扩展: toImageCoordinates 这个方法虽然原文没细说,但它大概率是一个扩展方法(Extension),用于把 0~1 的小数映射到图片的 widthheight 上,并处理坐标原点的翻转。这一步至关重要,不做这一步,你的框框就会像没头苍蝇一样乱撞。

在这里插入图片描述


在这里插入图片描述

现在,我们把这个“现形符”贴到图片上:

struct TextRecognitionView: View {
    // ... 前面的代码 ...
    
    // 定义一个深红色的框,充满了悬疑感
    let boundingColor = Color(red: 0.31, green: 0.11, blue: 0.11)
 
    var body: some View {
        List {
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
                    .overlay {
                        // 如果侦探已经有了观察结果
                        if let observations = textRecognizer?.observations {
                            ForEach(observations, id: \.uuid) { observation in
                                // 遍历每一个观察点,画个圈圈诅咒...啊不,标记它
                                // observation.boundingBox 就是那个归一化的坐标
                                BoundsRect(normalizedRect: observation.boundingBox)
                                    .stroke(boundingColor, lineWidth: 3) // 描边
                            }
                        }
                    }
            }
            // ... 后面的代码 ...
        }
    }
}

在这里插入图片描述

随着代码重新编译运行,屏幕上的截图发生了变化。每一个单词周围都被套上了一个暗红色的方框,就像是被狙击手锁定的目标。

在这里插入图片描述


在这里插入图片描述

🤝 终章:真相大白

“看到了吗?”我指着屏幕上被红框圈出的一串字符,“那根本不是 Root 密码。”

阿强瞪大了眼睛,盯着那行被 Vision 精准识别出的文字:WIFI_PASSWORD: 12345678

“这……这就是隔壁会议室的 WiFi 密码?”阿强瘫软在椅子上,“我为了这个通宵了两天?”

在这里插入图片描述

我拍了拍他的肩膀,语重心长地说道:“虽然你是个笨蛋,但好在 Vision 框架足够聪明。记住,Vision 不仅仅能找字,它还能做更多事情——从视频里追踪隔壁老王的身影,到检测你是不是在偷偷抠脚(Body Pose Detection)。今天我们学的只是冰山一角,但也足够你在这个充满像素迷雾的开发世界里防身了。”

就这样,Vision 框架再次拯救了一个无知的灵魂(虽然并没有拯救他的加班费)。

在这里插入图片描述

希望宝子们喜欢这个故事,以及它背后的技术,但对于小伙伴们来说,利用 Apple 强大的 ML 能力去探索未知的旅程,才刚刚开始。

在这里插入图片描述

保持好奇,保持代码整洁,我们下个案子见。👋🙂 8-)

目前中国大陆唯一可以免费在 Xcode 中使用顶级大模型智能编程的方法

在这里插入图片描述

0.引子

现今,在中国大陆想要使用最强编程大模型在 Xcode 中实时交互的方法不多。

为了体验 Vibe Coding 的“畅快”打击感(或许还有等待间隙时的些许失落感),我们往往需要在 Cursor 和 Xcode 间无限切换,这多少有点让秃头小码农们有些不爽快!

在这里插入图片描述

况且第三方智能编程 IDE 与 Xcode 联合开发还有一个问题:就是从 Xcode 外部无法精确的感知和处理 Xcode 中的细枝末节。举个例子:宝子们见过 Cursor 为了修复 1 个 bug 却新产出 10 个 bug 的蛋疼壮观场面吗?

在这里插入图片描述

幸运的是,在 Xcode 最新正式版 26.4 中: 在这里插入图片描述

我们找到一种免费且非常简单就可以辅以超强编程大模型(gpt-5.4 或 gpt-5.3-codex 家族)的方法:

在这里插入图片描述

操作起来也非常简单,目前(2026.4.7号)并不需要付费 OpenAI 账号或绑定任何国际银行卡。

在这里插入图片描述

这样宝子们“足不出户”就可以在 Xcode 里享受氛围编程的乐趣了哦。

在这里插入图片描述

废话少叙,心动不如行动!

让我们马上开始操练起来,将 Xcode 打造为丝毫不输于 Cursor 的智能 IDE 吧!8-)


1.工欲善其事,必先利其器

首先,大家需要下载和安装 Xcode 26.4 正式版。

同时,必须保证我们可以访问到 ChatGPT 官网,否则还扯什么呢?

在这里插入图片描述

2.启用 Xcode 智能 Agent

运行 Xcode ,打开设置,进入 Intelligence 页面:

在这里插入图片描述

Xcode 26.4 支持先进最强的 2 个编程大模型智能体(Agents):ChatGPT Codex 和 Claude,不过目前后者在大陆无法登录,会提示:当前区域的服务不可用。

在这里插入图片描述

所以,我们只有“稍微”退而求其次一丢丢,来使用 gpt-codex 了。

点击 Codex 右侧的 Get 按钮,下载并安装 Agent 到本地,我们能看到只有 77MB,可谓相当“小鸟依人”:

在这里插入图片描述

接下来的一步就是进入 Codex 智能体(Agent)页面,登录 ChatGPT 账户即可:

在这里插入图片描述

如图所示,在登录了 gpt 账号之后,我们可以就可以恣意选择自己喜爱的 gpt 大模型啦:

在这里插入图片描述

不过据我观察,Xcode 智能 Agent 中的 gpt 编程大模型貌似有点缩水,少了不少强力模型哦(比如 GPT-5.3 Codex High 和 GPT-5.3 Codex Extra High 等):

在这里插入图片描述

但话又说回来,对于这免费的“飞来横福”,我们还要什么自行车呢?


注意:正如之前所说的,目前只需免费的 ChatGPT 账号即可,且不需要绑定任何银行卡。

但是,未来还能不能享用这“免费的午餐”,就有点世事难料了。


在这里插入图片描述

3. 测试

在上面各步骤都就绪之后,我们就可以找一个项目实际在 Xcode 中小试身手了。

下面,打开宝子们最爱的项目,先让 Xcode Agent 为我们总结一番吧:

在这里插入图片描述

当然,在 Xcode 里编程智能体做的不仅仅是做个总结那么“弱智”,我们还可以让它直接分析 Xcode 中拥有的一切:

在这里插入图片描述

现在,直接在 Xcode 中用 AI 来修正编译错误不再是梦想了:

在这里插入图片描述在这里插入图片描述

这样做可以最大化利用 Xcode 丰富的上下文来让 AI 充分考虑和修正问题,避免了外部智能 IDE(比如 Cursor、Qoder 等)无必要的切换和折腾。


想用 Xcode 与本地大模型“双剑合璧”来协同编程的宝子们,请移步如下链接观赏精彩的内容:


看到这,不知宝子们心动了吗?

在这里插入图片描述

要不要一起来借助 Coding Intelligence 来试试 Xcode 的氛围编程呢?8-)

若有任何与本文相关的配置问题,请宝子们毫不犹豫的私我哦!

感谢观赏,下次再会吧!

在这里插入图片描述

【SnapKit】优雅的 Swift Auto Layout DSL 库

【SnapKit】优雅的 Swift Auto Layout DSL 库

iOS三方库精读 · 第 4 期


一、一句话介绍

SnapKit 是一个用于 iOS/macOS/tvOS 的 Swift Auto Layout DSL 库,它让繁琐的界面约束编写变得简洁优雅,是 UIKit 开发中最受欢迎的布局解决方案之一。

  • Stars: 19k+ ⭐
  • 最新版本: 5.7.0
  • License: MIT
  • 支持平台: iOS 12.0+ / macOS 10.13+ / tvOS 12.0+

二、为什么选择它

原生 NSLayoutConstraint 的痛点

在 UIKit 中,使用原生 API 创建约束通常是这样的:

// 原生方式 - 需要 4 行代码创建一个约束
let constraint = NSLayoutConstraint(
    item: view,
    attribute: .leading,
    relatedBy: .equal,
    toItem: superview,
    attribute: .leading,
    multiplier: 1.0,
    constant: 16
)
constraint.isActive = true
view.translatesAutoresizingMaskIntoConstraints = false

SnapKit 的核心优势

  1. 链式 DSL 语法:一行代码表达一个约束意图,代码可读性大幅提升
  2. 类型安全:编译期检查约束目标,避免运行时因字符串 API 导致的崩溃
  3. 自动管理:自动设置 translatesAutoresizingMaskIntoConstraints = false
  4. 动态更新:支持 updateConstraintsremakeConstraints,轻松应对动态布局
  5. 优先级支持:链式设置约束优先级,优雅处理约束冲突

三、核心功能速览

基础层 概念解释、环境配置、基础用法

环境要求与集成

SPM 集成:

// Package.swift
dependencies: [
    .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.0")
]

CocoaPods 集成:

pod 'SnapKit', '~> 5.7.0'

最简单的使用示例

import SnapKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let box = UIView()
        box.backgroundColor = .systemBlue
        view.addSubview(box)
        
        // 使用 SnapKit 创建约束
        box.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.width.height.equalTo(100)
        }
    }
}

进阶层 最佳实践、性能优化、线程安全

常用 API 一览

API 作用
makeConstraints 创建并激活约束
updateConstraints 更新已有约束(保持其他不变)
remakeConstraints 移除旧约束,创建新约束
removeConstraints 移除所有约束
prepareConstraints 预创建约束(不激活),用于条件判断

常见用法组合

// 1. 边距控制
view.snp.makeConstraints { make in
    make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16))
}

// 2. 相对布局
view1.snp.makeConstraints { make in
    make.top.left.equalToSuperview().offset(16)
    make.right.equalTo(view2.snp.left).offset(-8)
    make.height.equalTo(44)
}

// 3. 倍数与偏移
imageView.snp.makeConstraints { make in
    make.width.equalToSuperview().multipliedBy(0.5).offset(-16)
    make.height.equalTo(imageView.snp.width).multipliedBy(9.0/16.0)
}

// 4. 优先级设置
label.snp.makeConstraints { make in
    make.left.right.equalToSuperview().inset(16)
    make.top.equalToSuperview().offset(20)
    make.height.greaterThanOrEqualTo(20).priority(.required)
    make.height.lessThanOrEqualTo(100).priority(.high)
}

深入层 源码解析、设计思想、扩展定制

核心模块介绍

SnapKit 的架构设计非常精巧,主要包含以下几个核心组件:

  1. ConstraintMaker:DSL 的入口,提供链式调用接口
  2. ConstraintItem:封装约束的目标视图和属性
  3. Constraint:内部表示单个约束的数据结构
  4. ConstraintAttributes:约束属性的枚举封装

关键协议 ConstraintRelatableTarget 允许约束目标可以是:

  • 另一个视图 (UIView)
  • 数值 (CGFloat, Int)
  • 另一个约束项 (ConstraintItem)

这种设计使得 SnapKit 的 API 非常灵活,可以写出如 make.width.equalTo(100)make.width.equalTo(otherView) 这样自然的代码。


四、实战演示

下面是一个完整的登录界面布局示例,展示了 SnapKit 在实际业务场景中的应用:

import UIKit
import SnapKit

class LoginViewController: UIViewController {
    
    private let logoImageView = UIImageView()
    private let usernameTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton(type: .system)
    private let forgotPasswordButton = UIButton(type: .system)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        setupConstraints()
    }
    
    private func setupViews() {
        view.backgroundColor = .systemBackground
        
        // Logo
        logoImageView.image = UIImage(systemName: "person.circle.fill")
        logoImageView.tintColor = .systemBlue
        logoImageView.contentMode = .scaleAspectFit
        view.addSubview(logoImageView)
        
        // Username
        usernameTextField.placeholder = "用户名"
        usernameTextField.borderStyle = .roundedRect
        usernameTextField.autocapitalizationType = .none
        view.addSubview(usernameTextField)
        
        // Password
        passwordTextField.placeholder = "密码"
        passwordTextField.borderStyle = .roundedRect
        passwordTextField.isSecureTextEntry = true
        view.addSubview(passwordTextField)
        
        // Login Button
        loginButton.setTitle("登录", for: .normal)
        loginButton.backgroundColor = .systemBlue
        loginButton.setTitleColor(.white, for: .normal)
        loginButton.layer.cornerRadius = 8
        view.addSubview(loginButton)
        
        // Forgot Password
        forgotPasswordButton.setTitle("忘记密码?", for: .normal)
        view.addSubview(forgotPasswordButton)
    }
    
    private func setupConstraints() {
        logoImageView.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide).offset(40)
            make.centerX.equalToSuperview()
            make.width.height.equalTo(80)
        }
        
        usernameTextField.snp.makeConstraints { make in
            make.top.equalTo(logoImageView.snp.bottom).offset(40)
            make.left.right.equalToSuperview().inset(32)
            make.height.equalTo(44)
        }
        
        passwordTextField.snp.makeConstraints { make in
            make.top.equalTo(usernameTextField.snp.bottom).offset(16)
            make.left.right.height.equalTo(usernameTextField)
        }
        
        loginButton.snp.makeConstraints { make in
            make.top.equalTo(passwordTextField.snp.bottom).offset(24)
            make.left.right.equalTo(usernameTextField)
            make.height.equalTo(48)
        }
        
        forgotPasswordButton.snp.makeConstraints { make in
            make.top.equalTo(loginButton.snp.bottom).offset(16)
            make.centerX.equalToSuperview()
        }
    }
}

关键要点:

  • 使用 view.safeAreaLayoutGuide 适配刘海屏
  • 通过 equalTo 复用约束,保持代码 DRY
  • 合理的间距和尺寸,确保界面美观

五、源码亮点

进阶层:值得借鉴的用法

链式调用的实现技巧

SnapKit 通过 @discardableResult 和返回 self 实现链式调用:

// 简化示意
struct ConstraintMaker {
    @discardableResult
    func equalTo(_ other: ConstraintRelatableTarget) -> ConstraintMaker {
        // 设置约束关系
        return self
    }
    
    @discardableResult
    func offset(_ amount: CGFloat) -> ConstraintMaker {
        // 设置偏移量
        return self
    }
}

类型安全的约束目标

使用协议和泛型确保编译期类型检查:

protocol ConstraintRelatableTarget {}
extension UIView: ConstraintRelatableTarget {}
extension CGFloat: ConstraintRelatableTarget {}
extension Int: ConstraintRelatableTarget {}

深入层:设计思想解析

Builder 模式的应用

ConstraintMaker 是 Builder 模式的典型应用:

  1. 分离构建与表示:DSL 描述意图,内部 Builder 构建实际约束
  2. 精细控制构建过程:支持 make/update/remake 不同构建策略
  3. 延迟执行:约束在闭包执行完毕后才真正创建和激活

Protocol-Oriented Programming

SnapKit 大量使用协议扩展实现功能:

// 所有视图自动获得 snp 属性
extension UIView {
    var snp: ConstraintDSL {
        return ConstraintDSL(view: self)
    }
}

这种设计让 SnapKit 可以无缝接入任何 UIView 子类,无需继承或修改原有类。


六、踩坑记录

问题 1:约束冲突导致界面异常

症状:控制台输出 "Unable to simultaneously satisfy constraints",界面布局错乱。

原因:SnapKit 自动设置 translatesAutoresizingMaskIntoConstraints = false,但如果视图在 Storyboard 或 Xib 中已有约束,会导致重复约束。

解决:确保代码创建的视图没有在其他地方添加约束,或使用 remakeConstraints 完全重制约束。

// 使用 remakeConstraints 清除旧约束
view.snp.remakeConstraints { make in
    make.edges.equalToSuperview()
}

问题 2:updateConstraints 找不到要更新的约束

症状:调用 updateConstraints 时约束没有变化,或控制台报错。

原因updateConstraints 只能更新已存在的约束。如果约束类型不同(如从 equalTo 改为 lessThanOrEqualTo),需要先用 remakeConstraints

解决:检查约束类型是否一致,不一致时使用 remakeConstraints

// ❌ 错误:尝试将 equalTo 更新为 lessThanOrEqualTo
view.snp.makeConstraints { make in
    make.width.equalTo(100)
}
view.snp.updateConstraints { make in
    make.width.lessThanOrEqualTo(200) // 不会生效
}

// ✅ 正确:使用 remakeConstraints
view.snp.remakeConstraints { make in
    make.width.lessThanOrEqualTo(200)
}

问题 3:在 UITableViewCell 中布局问题

症状:Cell 高度计算不正确,或复用时布局错乱。

原因:Cell 的 contentView 是实际容器,约束应该添加到 contentView 而非 Cell 本身。

解决:始终将子视图添加到 contentView,约束也相对于 contentView

class MyCell: UITableViewCell {
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        
        let label = UILabel()
        contentView.addSubview(label) // 注意是 contentView
        
        label.snp.makeConstraints { make in
            make.edges.equalTo(contentView).inset(16) // 相对于 contentView
        }
    }
}

问题 4:动画更新约束时闪烁

症状:使用 UIView.animate 更新 SnapKit 约束时出现闪烁或跳动。

原因:约束更新和布局刷新时机不正确。

解决:在动画块中先更新约束,然后调用 layoutIfNeeded()

// ✅ 正确的动画方式
view.snp.updateConstraints { make in
    make.width.equalTo(200)
}

UIView.animate(withDuration: 0.3) {
    self.view.layoutIfNeeded()
}

问题 5:与 SwiftUI 混用时的注意事项

症状:在 UIViewRepresentable 中使用 SnapKit 时约束不生效。

原因:SwiftUI 的生命周期和布局系统与 UIKit 不同。

解决:确保在 makeUIView 中创建约束,在 updateUIView 中使用 updateConstraintsremakeConstraints

struct MyView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let subview = UIView()
        view.addSubview(subview)
        
        subview.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        // 更新约束
    }
}

七、延伸思考

与同类库的横向对比

维度 SnapKit PureLayout Cartography
语言 Swift Objective-C/Swift Swift
Stars 19k+ 7k+ 3k+
维护状态 ✅ 活跃 ⚠️ 维护较少 ⚠️ 已归档
API 风格 链式 DSL 方法调用 运算符重载
学习曲线
SwiftUI 支持 需桥接 需桥接 需桥接

推荐使用场景

推荐使用:

  • 纯 Swift UIKit 项目
  • 需要频繁动态更新布局的场景
  • 复杂界面,约束关系较多的页面
  • 团队已熟悉 Masonry(OC 版 SnapKit)

不推荐使用:

  • 纯 SwiftUI 项目(直接使用 SwiftUI 布局)
  • 零依赖要求的 SDK/框架开发
  • 极其简单的固定布局(原生代码量差异不大)

关于 Cartography 的说明

Cartography 是另一个流行的 Swift 布局 DSL,使用运算符重载(==>=<=)实现约束。虽然 API 非常优雅,但该项目目前已归档不再维护,不建议在新项目中使用。


八、参考资源


本期互动

小作业

尝试用 SnapKit 实现一个自适应高度的评论区 Cell,要求:

  1. 头像在左侧,固定 40x40
  2. 用户名在头像右侧,单行显示
  3. 评论内容在用户名下方,多行自适应高度
  4. 整体边距 16pt

完成后在评论区贴出你的 setupConstraints 方法代码。

思考题

如果你要自己实现一个类似的布局 DSL 库,你会如何设计 API 接口?是像 SnapKit 这样使用闭包和链式调用,还是像 Cartography 那样使用运算符重载?为什么?

读者征集

你在使用 SnapKit 时踩过哪些坑?或者有什么高级用法想分享?欢迎在评论区留言,优质回答会收录进下一期《踩坑记录》。

下一期选题投票:

  • A. RxSwift - 响应式编程库
  • B. Realm - 移动端数据库
  • C. Lottie - 动画渲染库

📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ MarkdownUI] [→ 本期 SnapKit] [○ 第5期]

苹果的罕见妥协:当高危漏洞遇上“拒升”潮 -- 肘子的 Swift 周报 #130

issue130.webp

苹果的罕见妥协:当高危漏洞遇上“拒升”潮

对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截至目前,已有上亿用户受到影响。

Coruna 主要针对 iOS 13 至 iOS 17 的设备,在过去几个月间,苹果已为这些系统推送了多次安全更新。DarkSword 则主要针对 iOS 18.4 至 18.7 的设备。尽管这部分设备均具备升级至 iOS 26 的硬件条件,但由于种种原因,仍有不少 iOS 18 用户选择按兵不动。

在很长一段时间里,苹果用户对于系统更新的态度都相当积极,这也是苹果生态的一大特色。但这一趋势在去年出现了变化——Liquid Glass 带来的巨大视觉冲击,让苹果用户中第一次出现了相当比例主动拒绝升级到 iOS 26 的现象。与此同时,为遵守英国《网络安全法》(Online Safety Act)的要求,苹果在 iOS 26.4 中为英国用户引入了强制年龄验证机制,由于验证条件严苛,不少成年用户甚至被系统强行锁入‘儿童模式’,进一步推动了英国用户停留在 iOS 18 或 iOS 26.3 的风潮。而拒绝安装新版本,意味着这部分用户同时放弃了后续所有安全补丁,让设备进一步暴露在潜在风险之下。

面对这一局面,苹果承受了明显的舆论压力与品牌风险。特别是在 3 月下旬,DarkSword 的完整攻击代码被泄露到了 GitHub 上,让这一国家级黑客工具瞬间平民化,直接迫使苹果必须采取紧急行动。最终,苹果罕见地为 iOS 18 单独推出了安全补丁 iOS 18.7.7,将原本仅用于 iOS 26 的防护机制回移植到旧系统。至此,苹果完成了针对本次高危漏洞的全部官方安全响应。

无论是苹果还是生态中的开发者,大多希望用户能积极跟进系统更新——既能减少多版本适配的维护负担,也能让用户尽快享受到新 API 带来的便利。但现实是,始终有一部分用户出于性能、续航、使用习惯乃至隐私等方面的考量,有意将设备锁定在某个版本。

本次事件或许会带来两个方向上的变化:苹果在压力下调整了长期坚守的更新策略,为刻意留守旧系统的用户做出了妥协;而事件本身的广泛传播,也可能促使更多用户从安全角度重新审视“能不更新就不更新”的惯性,回到积极更新的轨道。这种双向的改变,或许正是这场风波意料之外的收获。

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

近期推荐

通过 Animatable 深入 SwiftUI 动画 (Animatable in SwiftUI Explained - Complete Guide with Examples & Deep Dive)

网络上并不缺少探讨 SwiftUI 动画机制的文章,但 Sagar Unagar的这篇仍然提供了一个颇具启发性的切入点。他没有从隐式或显式动画入手,而是围绕 Animatable 协议做了一次系统梳理:从 animatableData 的作用,到 AnimatablePair 如何承载多个插值参数,再到通过自定义 VectorArithmetic 让更复杂的数据结构参与动画。文章最值得注意的一点在于其核心视角:SwiftUI 实际上是在“动画数据”,而非直接对视图进行动画处理。


在 Swift Package 中共享本地化资源 (Localization in Swift Packages)

Xcode 能为 .xcstrings 文件自动生成类型安全的 Swift 符号,但这些符号仅在资源所在的 module 内可见——一旦将本地化资源抽离为独立的 Localization 包,其他 feature 包便无法享受编译期检查的优势。Khan Winter 的解决方案相当直接:通过一个 bash 脚本解析 .xcstrings 的 JSON 结构,生成 public extension LocalizedStringResource 扩展,使所有模块都能以 .l10n.helloWorld 的形式访问翻译键。

其中一个颇具参考价值的细节是 Debug 模式下的 @dynamicMemberLookup 设计——访问不存在的键时仅记录日志而不崩溃,而在 Release 构建中仍保留完整的编译期校验。相比基于 Swift 可执行文件的方案,这种实现更加轻量,复制脚本即可使用。


Coordinator 全局导航模式 (SwiftUI Coordinator Pattern: Navigation Without NavigationLink)

尽管 SwiftUI 一直在丰富基于状态驱动的导航 API,但管理全局导航一直是 SwiftUI 中的一个“痛点”。Wesley Matlock 以一个五 Tab 的音乐收藏应用为例,展示了如何通过 Coordinator 模式将导航决策从 View 中抽离:用一个 Route 枚举统一描述所有目的地,由单一的 Coordinator 对象持有导航状态并执行跳转,View 只需声明“去哪”而无需关心“怎么去”。文章没有回避 NavigationPath 不透明、路由携带模型对象导致的 Hashable 困境等实际问题。对于大多数中等规模的 SwiftUI 应用来说,这是一个务实且易于落地的导航治理方案。


把 Hacking with Swift 的编程风格写进 AI (Teach your AI to write Swift the Hacking with Swift way)

Paul Hudson 和他的 Hacking with Swift 让很多开发者走上了 Swift 与 SwiftUI 的学习之路。在 AI 时代,Paul 不仅推出了面向苹果开发生态的各类专业 Skill,也开始尝试在与 AI 的协作中注入更具个人特质的编程风格。

在本文中,他分享了一份极具辨识度(且充满他标志性幽默)的 AGENTS.md 配置。这套规则不仅约束了 AI 的技术选型,还为 AI 注入了 Paul 的灵魂:强调先展示结果再解释原理、偏好清晰而非炫技、甚至包括在代码写得漂亮时适时地喊出一句 "Boom!"。与其说这是一份用于 AI 的“系统提示词”,不如说是在为 AI 定义一种编码哲学——某种程度上,这种方式正在将冷冰冰的“代码生成”推向带有人情味的“风格迁移”。


AI Agent 的道与术

在刚过去的 Let's Vision 2026 中,王巍(Onevcat) 发表了关于在大型开发团队中应用 AI Agent 的演讲。整场分享讨论的重点,并不是某个具体工具有多强,而是当代码实现成本被迅速压低后,团队该如何重新组织开发流程,以及工程师的价值该如何重新定位。

作为 LINE 应用开发团队的一员,Onevcat 在过去几个月中的工作重心也已明显发生变化。用他自己的话说,他正在逐步从传统意义上的 iOS 工程师,转向探索如何将 AI 应用于服务大型产品研发团队的实践者。这种角色上的变化,也让这场分享比一般的工具介绍更有说服力。

演讲围绕三个关键问题展开:如何控制上下文污染,如何把个人经验沉淀为团队可复用的 memory 与 skill,以及如何让协作模式从“人指挥多个 Agent”逐步走向更自动化的闭环。里面有不少相当接地气的实践建议,例如将 AGENTS.md 控制在精简范围内、为 Agent 提供模块定位与架构速查脚本、鼓励 Claude Code、Codex、OpenCode 等多种 harness 并存,以及通过 webhook、cron、pipeline 和自动验收机制让 Agent 真正进入团队流程。

演讲稿仓库 中不仅包含完整的 Slidev 源码,也保留了不少演讲配套材料,包括原始资料收集和与 AI 协作的完整 trace,值得一并阅读。


从零开始:用 AI 开发一个 iOS 原生 APP 完整指南

我经常会在社交媒体上看到一些零基础的“开发者”通过 AI 构建了自己的产品或服务。尽管我使用 AI 的时间也不短,但我仍然比较困惑:这条路径真的像大家描述的那样有效吗?Zachary Zhang 分享了他完全借助 AI 工具,从零构建并上架一款纯原生 iOS 应用(SwiftUI + Cloudflare 后端)的实战全过程。这篇文章最让我印象深刻的,是他严谨的“工程化管线”:在让 AI 写代码前,必须先生成结构化的 PRD 和 HTML 格式的视觉参考;而在工具选择上,他在项目“从 0 到 1”的冷启动阶段,极力推荐 Claude Code 等终端工具,以便更好地统览全局,一次性构建出合理的多文件项目架构。

或许你和我一样,对于 100% 基于 AI 的开发方式仍存疑惑。但在代码生成越来越廉价的今天,开发者的核心壁垒,正在加速向“需求精准拆解”、“系统架构把控”以及“面向报错的全局调度能力”转移。

工具

Slots:提高自定义 SwiftUI 组件设计效率的宏

将多个视图组合封装成可复用组件,是 SwiftUI 开发中的常见需求,对团队内部开发者或第三方库作者来说更是如此。但当组件包 title、icon、image、action 等多个泛型 View 插槽后,初始化器的组合数量往往会迅速膨胀。Kyle Bashour 创建的 Slots 宏,正是为了解决这类多 slot 组件的样板代码问题。

开发者只需声明组件的 slot 属性,宏便会按组合自动生成所需的初始化器,无需手写大量 init 重载。对于需要支持文本便捷写法的 slot,还可以通过 @Slot(.text) 自动获得 LocalizedStringKeyString 版本的初始化方式。 Slots 很适合用于构建设计系统中的 Card、Row、Banner、Toolbar 这类既要支持简单调用、又要保留高度定制能力的组件。


Explore SwiftUI:纯原生组件与修饰符的视觉速查图库

尽管 Apple 官方文档的质量在逐年改善,但对于以声明式和视觉驱动为主的 SwiftUI 来说,官方文档中依然缺乏足够直观的代码与 UI 效果对照,尤其是同一组件在 iOS、macOS 和 visionOS 等多平台上的表现差异。很多时候,开发者为了实现某个特定的 UI 细节,往往会去求助于复杂的第三方库或手写冗长的自定义视图,却忽略了 SwiftUI 本身可能已经提供了绝佳的原生解决方案。Florian 建立的 Explore SwiftUI 站点,正是一个为了解决这一痛点而生的“视觉速查字典”。它摒弃了任何第三方封装,纯粹以展示 Apple 官方内置组件的原生能力为核心。所有的代码示例都被剥离了无关的业务逻辑,保持极简,配以高质量的视觉预览,开发者只需“复制、粘贴、运行”即可直接验证效果。

书籍

SwiftUI Architecture: Patterns and Practices for Building Scalable Applications

这是一本 Mohammad Azam 在不久前出版的新书。它不是一本教你如何使用 VStack 或编写动画的入门书,而是一本纯粹探讨 SwiftUI 应用架构、数据流和现代工程化实践的进阶读物。

书中提供了大量直击生产环境痛点的解决方案,例如:如何构建全局的 Sheets 和 Toasts、如何利用 NavigationPath 设计解耦的多 Tab 编程式路由、以及如何使用 Property Wrapper 编写优雅的表单验证。尤为重要的是,作者并不是要向你灌输某种死板的架构模式,而是旨在帮助你建立真正的声明式心智模型。

或许有人觉得,在 AI 辅助编程盛行的时代,这类探讨架构的书籍还重要吗?借用 Mohammad Azam 在书中的观点:AI 让代码生成变得廉价,但也正因如此,系统架构的设计(边界的划分和状态所有权的明确)变得比以往任何时候都更加重要。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

苹果的罕见妥协:当高危漏洞遇上“拒升”潮 - 肘子的 Swift 周报 #130

对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截止目前,已有上亿用户受到影响。

利用 AI Agent, 将域名从 Godaddy 迁移到 Cloudflare

背景和问题

我有一个老的域名:devtang.com,上面利用 GitHub Pages 搭了我的 博客。这个域名注册很多年了,一直在 Godaddy 上续费,并且用 DNSPod (后来被阿里收购) 做解析。

我一直想迁移到 Cloudflare,但是域名转移的操作很繁琐,所以一直没有下决心推进。

这次,我想试试用 Claude Cowork 功能帮我做这个事儿。整个流程下来,感觉还挺顺畅的,所以给大家分享一下。

我觉得 AI 时代这些工作的工作流都有变化,所以说分享这样的工作流,有助于大家建立这种基于 AI Agent 的工作模式迁移。

操作流程

在使用前需要先安装好 Claude in Chrome 插件,然后执行如下操作:

1、我首先打开 Godaddy 和 Cloudflare 官网,登录上去。然后打开 Claude in Chrome 的浏览器面板。输入如下提示词:

1
我要将域名 devtang.com 从 godaddy 转移到 cloudflare,帮我继续转移。

Claude 给出了如下的操作步骤,点击 Approve Plan。

2、Claude 开始在 Godaddy 和 Cloudflare 上操作,有两次它停下来了,需要我给它发邮箱里面的授权码。于是我打开邮箱把授权码发给它。

3、操作继续,在操作过程中,我可以随意切换 Tab 看它的操作过程,也可以看它的 thinking 的过程。它其实每一步都是通过截图确认操作,也会中间停留 3-5 秒(可能是为了防止被误别成机器人)。

因为它也会停留,所以我有时候会帮它直接点击了,让操作更快一点。这也丝毫不会影响后续的工作,因为它每一步都会截图确认。

最后我看到了操作确认信息,告诉我转移成功。

迁移 GitHub Pages 到 Cloudflare Pages

Cloudflare Pages 支持无限流量,并且全球有多处结点,速度比 GitHub Pages 快。我把域名迁移过去之后,又进一步使用 Cloudflare 的 Pages 功能,将博客重新部署到了 Cloudflare 上。

具体步骤如下:

  • https://dash.cloudflare.com/,选择 “Build”->”Compute”->”Workers & Pages”,进入 Workers & Pages 页面。
  • 选择页面上的 “Create Application” 按钮
  • 在新的页面,点击最底部的小字:Looking to deploy Pages? Get started。这个字特别不起眼,如下图:
  • 在页面上配置相关信息,我配置的内容如下:
    • 选择 “Import an existing Git repository”,选择你在 GitHub 上的博客仓库。
    • Production branch: source 分支。
    • Framework preset: None
    • Build command:npx hexo generate
    • Build output directory: public
    • 环境变量:
      • NODE_VERSION 设置为 24
      • NPM_VERSION 设置为 11

以上设置好就可以测试了,测试遇到问题的话,把 error log 复制发给 claude,claude 会告诉你怎么改。

配置完之后,它默认的域名是 https://tangqiaoboy.pages.dev, 你可以用刚刚迁移好的域名给它设置一个新的域名,像我就设置成了 https://www.devtang.com/。如下图:

利用这个 Pages 可以干很多事情,比如我看到一个人就拿它发布了一个 巴菲特致股东的信 网站。不需要买服务器,也不需要买域名,也不用担心流量不够。

小结

  • 借助 Claude Cowork, 我们可以把复杂的工作流程全部交给 AI。
  • 在 Claude in Chrome 工作的时候,我们也可以随时接管网页操作,帮他把中间的步骤给衔接上。
  • 操作过程中如果一直没有推进,可以查看 claude thinking 的过程,可以发现一些问题,帮他解决。
  • Cloudflare Pages 提供无限流量,推荐大家部署过去。

Swift Actor 为什么选择可重入设计?——一道让人深思的并发题

Swift Actor 为什么选择可重入设计?——一道让人深思的并发题

iOS 进阶必修 · Swift 并发编程系列 第 2 期


面试官问你:"Swift 的 actor 是可重入的,你觉得这个设计合理吗?"

很多人第一反应是:可重入?那不是有 bug 风险吗?为什么不做成传统锁那样不可重入?

这篇文章就来彻底说清楚这件事。


先把概念说明白

可重入(Reentrant):actor 在 await 挂起时会释放自身的"访问权",其他任务可以趁机进入 actor 执行别的方法。

不可重入(Non-reentrant):actor 一旦被某任务占用,其他任务必须排队等待,直到当前任务彻底执行完(包括所有 await)。

用一句话概括差异:

可重入:await 是"暂时离开",锁被放开
不可重入:await 是"原地等待",锁被一直握着


如果 Actor 是不可重入的,会发生什么?

死锁:跨 actor 调用的必然结局

actor ServiceA {
    let b: ServiceB
    func doWork() async {
        await b.help()   // A 持锁,等待 B
    }
}

actor ServiceB {
    let a: ServiceA
    func help() async {
        await a.check()  // B 持锁,等待 A ← 死锁!
    }
}

两个 actor 互相持锁等待对方,经典死锁。

在真实业务里这种结构比比皆是——网络层调用缓存层,缓存层调用配置层,配置层又依赖某个共享状态…只要存在环形调用,就必然死锁。

而且这种死锁极难排查:没有崩溃日志,没有报错,App 就静静地卡在那里。

Actor 内部 async 调用,自己等自己

actor Logger {
    func log(_ msg: String) async {
        await writeToFile(msg)   // 不可重入 → 自己等自己 → 死锁
    }

    func writeToFile(_ msg: String) async {
        // 磁盘写入…
    }
}

这意味着什么?actor 内部完全不能出现 await。但现实中 actor 管理的资源(网络、磁盘、数据库)几乎无一例外需要异步操作。

不可重入 + async/await 生态,在逻辑上根本无法自洽。


那可重入会带来哪些坑?

可重入把死锁风险消除了,但代价是:await 挂起期间,actor 的状态可能被其他任务修改

坑 1:状态假设在 await 前后失效

这是最经典的重入陷阱,银行转账场景:

actor BankAccount {
    var balance: Double = 1000

    func withdraw(_ amount: Double) async throws {
        // ① 检查余额:1000 >= 800,通过
        guard balance >= amount else { throw InsufficientFundsError() }

        // ② await 挂起,actor 释放访问权
        //    另一个 withdraw(800) 趁机进来,也通过了 guard
        //    它先执行,balance 变成 200
        await logTransaction(amount)

        // ③ 回来继续执行:800 > 200,但已经没有再次检查!
        balance -= amount   // balance = 200 - 800 = -600,超支!
    }
}

// 并发:两个任务同时取 800
Task { try await account.withdraw(800) }
Task { try await account.withdraw(800) }
// 最终 balance = -600,资损!

问题的根源:guard 检查到 balance -= amount 之间夹着一个 await,整个操作不是原子的。

坑 2:不变量(Invariant)在 await 期间被破坏

actor DataPipeline {
    var isProcessing = false
    var buffer: [Data] = []

    func process() async {
        guard !isProcessing else { return }
        isProcessing = true   // 设置标志

        // await 挂起,另一个 process() 调用进来
        // 它看到 isProcessing = true,直接 return
        // 看起来没问题…但如果两个调用"同时"通过 guard 呢?
        // → 取决于调度时序,存在 TOCTOU(检查-使用时差)窗口
        await doHeavyWork()

        isProcessing = false
    }
}

正确应对可重入的三个模式

模式一:await 之前完成所有关键状态变更

actor BankAccount {
    var balance: Double = 1000

    // ✅ 正确写法
    func withdraw(_ amount: Double) async throws {
        guard balance >= amount else { throw InsufficientFundsError() }
        balance -= amount          // ← 先改状态(无 await,绝对原子)
        await logTransaction(amount)  // 再异步处理(状态已一致)
    }
}

规则guard 检查通过后,立刻完成状态变更,然后才 awaitawait 之后不再依赖之前检查过的条件。


模式二:原子卫兵——同步方法作为临界区

actor SafeQueue {
    private var items: [WorkItem] = []
    private var isRunning = false

    // 同步方法:无 await,绝对原子
    private func takeNext() -> WorkItem? {
        guard let item = items.first else { return nil }
        items.removeFirst()  // 取出即删除,不会被重入影响
        return item
    }

    func drainAll() async {
        guard !isRunning else { return }
        isRunning = true
        while let item = takeNext() {
            await item.execute()   // await 时 item 已从队列移除,安全
        }
        isRunning = false
    }
}

思路:把"检查 + 修改"合并进一个不含 await 的同步方法,让它成为原子操作。


模式三:状态机保护并发入口

actor TaskScheduler {
    private enum Phase { case idle, running, draining }
    private var phase: Phase = .idle

    func schedule(_ task: Task<Void, Never>) async {
        guard phase == .idle else { return }
        phase = .running           // ← await 之前切状态,拿到"令牌"
        await task.value           // 其他调用看到 .running,直接 return
        phase = .idle
    }
}

用状态机枚举而非 Bool 标志,让每种状态的含义更清晰,也更难被误用。


设计对比:可重入 vs 不可重入

维度 不可重入(传统锁语义) 可重入(Swift actor)
跨 actor 调用 ❌ 极易死锁 ✅ 安全
actor 内部 await ❌ 自己等自己,死锁 ✅ 正常工作
状态一致性 await 前后一致 ⚠️ 开发者自行保证
死锁风险 ❌ 高,且难排查 ✅ 无
正确性复杂度 低(锁语义直觉) 中(需理解挂起语义)
与 async/await 生态兼容性 ❌ 根本无法自洽 ✅ 天然融合

Apple 为什么必须选可重入

这是一道"两害取其轻"的工程决策题:

  • 死锁:不可预测,运行时无日志,难以复现,线上问题几乎无法定位
  • 重入陷阱:有规律可循(await 前完成状态变更),编码期可发现,有明确的防御模式

Apple 把不确定性更高、危害更大的风险消除了,把相对可控的复杂性留给开发者。

从语言设计角度看,这也与 Swift 的一贯哲学吻合:编译器负责能静态验证的安全,开发者负责剩下的语义正确性

Swift 6 的严格并发检查(-strict-concurrency=complete)正在把越来越多的重入问题提升为编译器警告,方向是对的。


实际项目中的选择建议

优先用可重入,配合以下纪律:

  1. 黄金法则await 之前必须完成所有关键状态变更,await 之后不再信任之前读取的条件
  2. 原子临界区:把"检查 + 修改"封装进无 await 的同步方法
  3. 状态机优先:用枚举状态机而非 Bool 标志管理并发入口
  4. 最小化 await 范围:需要保护的临界操作不要夹带 await
// 完整示例:安全的资源管理 actor
actor ResourceManager {
    private enum State { case idle, acquired, releasing }
    private var state: State = .idle
    private var resource: Resource?

    // ✅ 获取资源:先拿到"凭证"再 await
    func acquire() async throws -> Resource {
        guard state == .idle else { throw ResourceError.busy }
        state = .acquired            // 改状态在 await 之前
        let res = try await fetchResource()
        resource = res
        return res
    }

    // ✅ 释放资源:先清理状态再 await
    func release() async {
        guard state == .acquired else { return }
        let res = resource
        resource = nil               // 先清空
        state = .releasing
        await cleanupResource(res)
        state = .idle
    }
}

总结

问题 答案
可重入设计合理吗? 合理,是工程必要性决定的,不是妥协
不可重入的最大问题? 跨 actor 死锁 + 内部 async 调用死锁,且难排查
可重入最大的坑? await 前后状态假设失效,经典场景是 guard 通过后 await,回来状态已变
实际项目怎么用? 拥抱可重入,用"await 前完成状态变更"作为硬性编码纪律

可重入的坑有规律可循,死锁没有。选可重入,然后学会驾驭它。


延伸思考

Kotlin 协程的 Mutex 提供了不可重入的互斥锁,但它是手动使用的工具,而不是语言默认行为——与 Swift actor 的定位完全不同。Java 的 synchronized 则是可重入的(同一线程可以重复进入),与 Swift actor 的可重入语义有些类似,但实现机制不同。

Swift actor 的可重入设计,本质上是结构化并发思想的延伸:任务在 await 时让出资源,让其他任务有机会推进,整个系统的吞吐量更高,而不是让一个任务独占 actor 直到它的所有 await 全部完成。


如果你在项目里遇到过 actor 重入导致的 bug,欢迎评论区分享——是什么场景、如何发现、怎么修复的?优质案例会收录进下一期。


📅 本系列持续更新 ✅ 第 1 期:Swift Concurrency 基础精讲 · ➡️ 第 2 期:Actor 可重入设计深析(本期)· ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

iOS 进阶必修 · Swift 并发编程系列 第 1 期


一、一句话介绍

Swift Concurrency 是 Apple 在 Swift 5.5(iOS 15+)正式引入的原生并发框架,它让异步代码的编写、错误处理、线程安全变得声明式、结构化、且编译器可静态验证。

属性 信息
引入版本 Swift 5.5 / Xcode 13
运行时最低要求 iOS 13+(back-deploy)/ iOS 15+ 全功能
核心特性 async/await · Task · Actor · AsyncStream
与 Combine 关系 互补共存,AsyncSequence 可与 Combine 互转
官方文档 Swift Concurrency

二、为什么选择它

原生异步方案的痛点

在 Swift Concurrency 出现之前,iOS 异步编程长期面临这些问题:

旧方案 Swift Concurrency
回调嵌套(Callback Hell),可读性极差 async/await 线性写法,与同步代码几乎一致
DispatchQueue + 锁保护共享状态,极易出错 actor 编译器静态保证线程安全
DispatchGroup 聚合多个并行任务,样板代码多 async let / withTaskGroup 声明式并行
任务取消需要自行维护 flag,容易遗漏 结构化取消,父取消子自动跟随
线程切换 DispatchQueue.main.async {} 到处散落 @MainActor 注解,编译器强制保证主线程
Combine 学习曲线陡,操作符多 AsyncStream 原生支持,与 for await 天然融合

核心优势:

  • 可读性:async/await 让异步代码读起来像同步,减少 80% 认知负担
  • 安全性:actor 让数据竞争成为编译错误而非运行时崩溃
  • 结构化:父子任务形成树形结构,取消/错误自动传播
  • 可组合:AsyncSequence 统一了事件流、定时器、网络流的消费模型
  • 零依赖:语言内置,无需引入任何第三方库

三、核心功能速览

基础层(新手必读)

无需配置,开箱即用

Swift Concurrency 是语言特性,直接在 Xcode 13+ 的任意 Swift 文件中使用:

// Swift 5.5+ · iOS 13+ (back-deploy) / iOS 15+ (全功能)
import Foundation  // 仅需标准库

async/await:异步函数的声明与调用

// ✅ 声明异步函数:加 async 关键字
func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

// ✅ 调用:必须在 async 上下文中,用 await 挂起
Task {
    do {
        let user = try await fetchUser(id: 1)
        print(user.name)
    } catch {
        print("加载失败:\(error)")
    }
}

await挂起点而非阻塞点:挂起时线程被释放,恢复后可能在不同线程继续执行。这是 Swift Concurrency 高效的根本原因。


深入理解:await 挂起 vs 传统回调的线程行为

这是理解 Swift Concurrency 为何高效的关键,也是很多人初学时最容易混淆的地方。

传统 GCD 回调的线程行为

// 传统方式:调用线程不阻塞,但"上下文"从此丢失
func fetchData(completion: @escaping (Data) -> Void) {
    URLSession.shared.dataTask(with: url) { data, _, _ in
        // 回调所在线程:URLSession 内部线程(不确定,通常是子线程)
        completion(data!)
    }.resume()
}

// 调用方
fetchData { data in
    // ⚠️ 线程已改变,需要手动切回主线程
    DispatchQueue.main.async {
        self.label.text = "done"   // 上下文全靠开发者自己管理
    }
}
// 调用方线程立即继续往下跑(不等待,也不挂起)
print("这行代码立即执行,不等 fetchData 完成")

async/await 的线程行为

// async/await:await 是挂起点,调用线程被释放回线程池
func fetchData() async -> Data { ... }

func loadPage() async {
    print("当前线程:\(Thread.current)")  // 线程 A
    
    let data = await fetchData()          // ← 挂起点:线程 A 被释放,可去执行其他任务
    
    // 恢复后:可能是不同线程,但 Actor 上下文(如 @MainActor)被自动还原
    print("恢复线程:\(Thread.current)")  // 可能是线程 B,但上下文依然正确
    updateUI(data)                        // 如果在 @MainActor 中,编译器保证这里一定在主线程
}

两者最本质的区别:线程是否被"占用"

维度 传统 GCD 回调 async/await
调用方线程 继续运行(不等待,不挂起) 挂起,线程释放回线程池
等待期间 调用线程去干别的事(但无连接) 线程被其他任务复用
回调/恢复线程 由 GCD 队列决定,不确定 由调度器决定,保留 Actor 上下文
代码连续性 回调嵌套,逻辑分散 线性代码,逻辑连续
线程安全 手动管理,容易出错 编译器 + Actor 静态保证

⚠️ 注意:传统回调的调用方线程确实不阻塞,这点和 await 一样。但两者的关键区别在于:传统回调是"断开连接"继续跑,而 await 是"挂起等待"并能恢复连续执行上下文

为什么 async/await 不会导致线程爆炸

传统 GCD 的一个隐患:当你用 semaphore.wait()DispatchGroup.wait() 真正"等"结果时,线程被阻塞(占着资源啥也不干)。系统发现线程不够用时会持续创建新线程,最终导致线程爆炸。

// ❌ 危险:阻塞线程(传统方式的隐患)
let sema = DispatchSemaphore(value: 0)
fetchData { data in sema.signal() }
sema.wait()  // 线程在此阻塞,占着系统资源却无法被复用
             // 并发请求多时,可能导致线程数量爆炸

Swift Concurrency 的协作式线程池解决了这个问题:

// ✅ 协作式挂起:线程释放回池子,完全不阻塞
let data = await fetchData()
// 线程池始终维持在约等于 CPU 核数的小规模,高效复用

Swift Concurrency 的线程池设计

传统 GCD 线程池(可能爆炸)          Swift Concurrency 协作式线程池
┌──────────────────────────┐         ┌──────────────────────────┐
│ 线程1(等待网络,阻塞)     │         │ 线程1(执行 Task A)        │
│ 线程2(等待数据库,阻塞)   │         │ 线程2(执行 Task B)        │
│ 线程3(等待文件,阻塞)     │  vs     │ 线程3(执行 Task C)        │
│ 线程4(新建中...)         │         │ ← 线程数 ≈ CPU 核数         │
│ 线程N(继续新建...)  💥   │         │ Task 挂起时释放线程,不占用  │
└──────────────────────────┘         └──────────────────────────┘

一句话总结:

  • 传统回调:调用线程不等待,但回调上下文断开,线程安全靠自己保证,用 wait() 等待时会阻塞线程
  • async/await:挂起点释放线程,调度器恢复时还原上下文,Actor 保证线程安全,系统始终保持小规模线程池

这就是为什么同样是"异步",Swift Concurrency 在高并发场景下比传统 GCD 回调效率更高、更安全。


SwiftUI 中使用 .task 修饰符(推荐)

struct UserView: View {
    @State private var user: User?

    var body: some View {
        Text(user?.name ?? "加载中...")
            .task {
                // 视图消失时任务自动取消,无需手动管理
                user = try? await fetchUser(id: 1)
            }
    }
}

进阶层(最佳实践)

async let:并行执行多个任务

// ❌ 顺序执行:总耗时 = 500ms + 300ms + 200ms = 1000ms
let user    = try await fetchUser(id: 1)
let orders  = try await fetchOrders(uid: 1)
let profile = try await fetchProfile(uid: 1)

// ✅ async let 并行:总耗时 = max(500ms, 300ms, 200ms) = 500ms
async let user    = fetchUser(id: 1)
async let orders  = fetchOrders(uid: 1)
async let profile = fetchProfile(uid: 1)
let (u, o, p) = try await (user, orders, profile)
// 三行代码实现并行,耗时减半

withTaskGroup:动态数量的并行任务

// 并行下载数量不固定的图片列表
func downloadImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask { try await fetchImage(from: url) }
        }
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Task:非结构化任务与取消

// 创建任务(继承当前 actor 上下文)
let task = Task(priority: .userInitiated) {
    for i in 1...100 {
        try Task.checkCancellation()   // 取消时自动 throw CancellationError
        await processItem(i)
    }
}

// 取消(协作式,不会强制停止)
task.cancel()

// Task.detached:不继承 actor 上下文,完全独立
Task.detached(priority: .background) {
    let result = await heavyComputation()
    await MainActor.run { updateUI(result) }
}

Continuation:桥接旧式回调 API

// 将旧式 completion block API 包装为 async 函数
func requestLocation() async throws -> CLLocation {
    try await withCheckedThrowingContinuation { continuation in
        locationManager.requestLocation { location, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let location {
                continuation.resume(returning: location)
            }
        }
    }
}
// ⚠️ resume 只能调用一次,多次调用会 crash

深入层(源码视角)

核心模块职责划分

特性 职责 适用场景
async/await 异步函数声明与挂起 任何异步 IO 操作
async let 静态数量并行任务 首页多接口聚合
Task 非结构化任务单元 按钮触发的独立操作
withTaskGroup 动态数量结构化并发 批量下载/处理
actor 数据竞争保护 共享状态管理
@MainActor 主线程强制约束 UI 更新
Sendable 跨边界类型安全 actor 参数/返回值
AsyncStream 自定义异步序列 事件流/实时数据

四、实战演示

场景:AI 流式问答 + 打字机渲染

这是目前最热门的应用场景之一,完整演示了 AsyncStream + Task + @MainActor 的协同工作。

// Swift 5.5+

// MARK: - 1. 流式 AI 服务层(可替换为真实 SSE 接口)

enum AIStreamService {
    /// Mock:逐字符推送,实际项目替换为 URLSession.bytes 读取 SSE
    static func stream(prompt: String) -> AsyncStream<String> {
        let response = "Swift Concurrency 让并发编程如行云流水," +
            "async/await 消除回调地狱,Actor 守护数据安全," +
            "AsyncStream 带来流式体验。🚀"

        return AsyncStream { continuation in
            Task {
                for char in response {
                    guard !Task.isCancelled else {
                        continuation.finish()
                        return
                    }
                    continuation.yield(String(char))
                    try? await Task.sleep(nanoseconds: 60_000_000) // 60ms/字
                }
                continuation.finish()
            }
        }
    }

    /// 接入真实 SSE 接口(生产参考)
    static func streamFromSSE(url: URL) -> AsyncStream<String> {
        AsyncStream { continuation in
            Task {
                let (bytes, _) = try await URLSession.shared.bytes(from: url)
                for try await line in bytes.lines {
                    guard line.hasPrefix("data: "),
                          let data = line.dropFirst(6).data(using: .utf8),
                          let json = try? JSONDecoder().decode(TokenResponse.self, from: data)
                    else { continue }
                    continuation.yield(json.token)
                }
                continuation.finish()
            }
        }
    }
}

// MARK: - 2. SwiftUI 打字机视图

struct TypewriterView: View {
    @State private var prompt = "Swift 并发编程"
    @State private var output = ""
    @State private var isStreaming = false
    @State private var streamTask: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            TextField("输入问题…", text: $prompt)
                .textFieldStyle(.roundedBorder)

            // 打字机光标效果
            Text(output + (isStreaming ? "▌" : ""))
                .font(.body)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
                .background(Color(.secondarySystemBackground))
                .cornerRadius(10)
                .animation(.none, value: output)

            HStack(spacing: 12) {
                Button(isStreaming ? "生成中…" : "开始生成") {
                    startStream()
                }
                .buttonStyle(.borderedProminent)
                .disabled(isStreaming)

                Button("停止") {
                    streamTask?.cancel()
                    isStreaming = false
                }
                .buttonStyle(.bordered)
                .tint(.red)
                .disabled(!isStreaming)
            }
        }
        .padding()
        .onDisappear { streamTask?.cancel() } // ✅ 离开页面时取消
    }

    private func startStream() {
        streamTask?.cancel()
        output = ""
        isStreaming = true
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: prompt) {
                output += token  // SwiftUI 自动感知变化实时渲染
            }
            isStreaming = false
        }
    }
}

// MARK: - 3. UIKit 打字机控制器(@MainActor 保证 UI 安全)

@MainActor
class TypewriterViewController: UIViewController {
    private let textView = UITextView()
    private var streamTask: Task<Void, Never>?

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        streamTask?.cancel()  // ✅ 离开页面时取消,防止内存泄漏
    }

    @objc func startStream() {
        streamTask?.cancel()
        textView.text = ""
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: "UIKit") {
                guard !Task.isCancelled else { break }
                textView.text += token
                // 自动滚到底部
                let range = NSRange(location: textView.text.count - 1, length: 1)
                textView.scrollRangeToVisible(range)
            }
        }
    }
}

这个示例完整演示了:AsyncStream 的创建与消费、Task 的取消管理、@MainActor 的 UI 安全保证、SwiftUI 和 UIKit 的两套接入方式。


五、源码亮点

进阶层:值得借鉴的设计

Actor 并发计数器(告别 DispatchQueue + 锁)

// ❌ 传统写法:容易因忘记加锁而出现数据竞争
class Counter {
    var value = 0
    let queue = DispatchQueue(label: "counter.queue")
    func increment() { queue.sync { value += 1 } }
}

// ✅ actor:编译器静态保证,忘加 await 直接报错
actor SafeCounter {
    private(set) var value = 0
    func increment() { value += 1 }
}

// 并发使用:1000 个任务同时递增,结果一定是 1000
let counter = SafeCounter()
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<1000 {
        group.addTask { await counter.increment() }
    }
}
print(await counter.value)  // 1000,绝无数据竞争

AsyncStream 资源安全回收

// 定时器流:onTermination 防止 timer 泄漏
func timerStream(interval: Double) -> AsyncStream<Int> {
    AsyncStream { continuation in
        var tick = 0
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
            tick += 1
            continuation.yield(tick)
        }
        // ✅ 流取消/结束时自动调用,清理外部资源
        continuation.onTermination = { _ in
            timer.invalidate()
        }
    }
}

深入层:设计思想解析

结构化并发的思想来源

结构化并发的核心理念来自结构化编程的类比:就像 if/for/while 让控制流有明确的进入和退出点,结构化并发让并发任务的生命周期也有明确的边界

// 传统 GCD:任务生命周期不受控
func fetchData() {
    DispatchQueue.global().async {
        // 这个任务完全脱离 fetchData 的控制
        // fetchData 返回后,任务仍在跑
    }
}

// 结构化并发:任务生命周期受作用域约束
func fetchData() async {
    async let result = networkCall()   // 任务在这里创建
    let data = await result            // 函数返回前,任务必须完成
}                                      // ← 离开作用域,所有子任务保证已结束

三大核心约束

约束 含义
父子关系 子任务归属于父任务,父任务取消时子任务自动取消
生命周期包含 父任务不能在子任务完成之前结束
错误传播 子任务的错误必须传递给父任务处理

非结构化 vs 结构化对比

// ❌ 非结构化(Task.detached)—— 孤儿任务,生命周期不受控
Task.detached {
    await riskyOperation()  // 即使调用方已取消,这里仍然在跑
}

// ✅ 结构化(async let / TaskGroup)—— 任务有明确的父子关系
await withTaskGroup(of: String.self) { group in
    group.addTask { await fetch("A") }
    group.addTask { await fetch("B") }
    // 离开 withTaskGroup 之前,所有子任务保证结束
}

这套思想由 Nathaniel J. Smith 的 Notes on structured concurrency 奠基,Swift 从 5.5 开始通过 async letTaskGroupactor 全面落地。与 Kotlin 协程的 StructuredConcurrency 一脉相承,但 Swift 通过编译器强制实施,更难写错。


结构化并发:任务树模型

Swift Concurrency 引入了"结构化并发"概念——任务形成父子树形结构:

父任务(Task)
 ├── 子任务 A(async let)
 ├── 子任务 B(async let)
 └── TaskGroup
      ├── 子任务 C(addTask)
      └── 子任务 D(addTask)

关键特性:

  • 父取消 → 子自动取消:无需手动遍历
  • 子抛出错误 → 父捕获:错误自动冒泡
  • 父作用域结束 → 等待所有子完成:无任务泄漏

这与 Kotlin 协程的 StructuredConcurrency 思想一脉相承,但 Swift 通过编译器强制实施,更难写错。


Actor 的可重入设计

Actor 内部通过隐式串行队列保证数据安全,但它是可重入的:

actor BankAccount {
    var balance: Double = 1000

    // ⚠️ 重入陷阱:await 挂起期间,其他任务可进入 actor 修改 balance
    func withdrawUnsafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        await logTransaction(amount)  // 挂起!balance 可能被别的 withdraw 修改
        balance -= amount             // 此时 balance 可能已不足!
    }

    // ✅ 正确:先修改状态再 await
    func withdrawSafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        balance -= amount          // 先扣,在 await 之前完成关键状态变更
        await logTransaction(amount)
    }
}

规则:actor 中,await 之前必须完成所有关键状态变更。


六、踩坑记录

问题 1:Continuation.resume 调用了多次导致 crash

  • 原因:某些旧 SDK 的 completion block 可能被调用多次(如进度回调)
  • 解决:用 bool flag 保护,确保 resume 只执行一次
func safeContinuation<T>(_ block: (@escaping (T) -> Void) -> Void) async -> T {
    await withCheckedContinuation { continuation in
        var resumed = false
        block { value in
            guard !resumed else { return }
            resumed = true
            continuation.resume(returning: value)
        }
    }
}

问题 2:Task.detached 中直接更新 UI 导致崩溃

  • 原因Task.detached 不继承当前 actor 上下文,不在主线程
  • 解决:显式切回主线程
// ❌ 危险
Task.detached { self.label.text = "done" }

// ✅ 正确
Task.detached {
    let result = await process()
    await MainActor.run { self.label.text = result }
}

问题 3:视图消失后 Task 仍在运行,导致内存泄漏

  • 原因:Task 生命周期独立于视图,视图销毁后任务仍持有 self
  • 解决:SwiftUI 用 .task {} 修饰符(自动管理),UIKit 在 viewWillDisappear 中 cancel
// UIKit
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    loadTask?.cancel()
}

问题 4:Actor 重入性导致余额多扣

  • 原因:await 挂起期间其他任务进入 actor 修改共享状态
  • 解决:遵守"先修改状态,再 await"原则(见第五章深入层)

问题 5:AsyncStream 中 timer / 监听器未释放,持续运行

  • 原因:忘记实现 continuation.onTermination
  • 解决:每个 AsyncStream 必须实现 onTermination,清理外部资源
continuation.onTermination = { reason in
    timer.invalidate()
    notificationCenter.removeObserver(observer)
}

问题 6:withTaskGroup 中子任务抛出错误没有被感知

  • 原因:使用了 withTaskGroup(不抛出版),错误被吞掉
  • 解决:需要错误传播时,使用 withThrowingTaskGroup
// ✅ 任意子任务失败,整个 group 取消并抛出错误
try await withThrowingTaskGroup(of: Data.self) { group in
    for url in urls { group.addTask { try await fetch(url) } }
    for try await data in group { process(data) }
}

问题 7:在 iOS 13 / 14 上使用 actor 报链接错误

  • 原因:actor 运行时需要 iOS 15+ 的系统库支持;Xcode back-deploy 支持 async/await 但不完全支持 actor
  • 解决:确认最低 Deployment Target,或对 actor 用 @available(iOS 15, *) 包裹

七、延伸思考

与同类方案横向对比

方案 简介 学习曲线 线程安全 取消支持 适用场景
Swift Concurrency Swift 原生,语言级别支持 编译器保证(actor) 结构化取消 新项目首选
GCD + DispatchQueue 苹果传统并发方案 手动加锁,容易出错 无原生支持 老项目维护
Combine 响应式框架,操作符丰富 需手动 receive(on:) AnyCancellable 复杂数据流转换
PromiseKit 基于 Promise 的链式回调 无特殊支持 有限支持 OC/早期 Swift 项目
RxSwift 响应式编程全家桶 很高 需配置 scheduler Disposable 重度响应式架构

推荐使用场景

  • ✅ iOS 13+ 新项目,全面拥抱 Swift Concurrency
  • ✅ 需要并行聚合多个接口的页面(async let / TaskGroup)
  • ✅ 共享状态管理,替代 DispatchQueue + 锁(actor)
  • ✅ 实时数据流、WebSocket、AI 流式响应(AsyncStream)
  • ✅ 需要优雅取消的长时任务(下载、文件处理)

不推荐场景

  • ❌ 项目最低支持 iOS 12 及以下,部分特性无法使用
  • ❌ 已有大量 Combine 代码,短期内迁移成本过高
  • ❌ 需要复杂响应式操作符链(merge、combineLatest 等),Combine 更合适

迁移策略建议

  1. 新功能优先用 async/await,不强制改旧代码
  2. 旧接口Continuation 包装,对调用方透明
  3. Combine Pipeline 可通过 .values 属性转为 AsyncSequence 互通
  4. Swift 6 开启严格并发检查(-strict-concurrency=complete),提前消灭隐患

八、参考资源


九、本期互动

小作业

基于本文的 AsyncStream 示例,实现一个实时心跳检测器

  1. AsyncStream 每隔 1 秒 yield 一次当前时间戳
  2. 连续 5 次 yield 后,主动调用 continuation.finish() 结束流
  3. 在 SwiftUI 中用 .task {} 消费流,将每次时间戳展示在列表中
  4. 点击「停止」按钮时,通过 task.cancel() 终止流,并验证 onTermination 被调用

完成后在评论区贴出你的 AsyncStream 创建代码和 onTermination 实现,说明你是如何验证资源正确释放的。


思考题

Swift Concurrency 的 actor 选择了可重入设计——即 await 挂起时允许其他任务进入 actor 执行。你认为这个设计决策是合理的吗?

如果设计成不可重入(像传统锁一样),会带来哪些问题?可重入又会引发哪些坑?在你的实际项目中,你更希望哪种行为?


读者征集

下一期预计深入讲解 Swift Concurrency 进阶:自定义 AsyncSequence、结构化并发原理、Swift 6 严格并发检查实战

如果你在项目中迁移到 Swift Concurrency 时踩过坑(特别是 actor 重入、Sendable 编译报错、Task 泄漏等),欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列持续更新 ➡️ 第 1 期:Swift Concurrency 基础精讲(本期)· ○ 第 2 期:Actor 可重入设计深析 · ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

iOS三方库精读 · 第 1 期


一、一句话介绍

Alamofire 是一个用于 iOS / macOS / watchOS / tvOS 的 Swift HTTP 网络库,它让发起网络请求、处理响应、上传/下载文件变得声明式、可组合、且极易阅读。

属性 信息
⭐ GitHub Stars ~41k
最新版本 5.x(当前 5.9+)
License MIT
支持平台 iOS 10+ / macOS 10.12+ / tvOS 10+ / watchOS 3+
Swift 最低版本 Swift 5.7+

二、为什么选择它

原生 URLSession 的痛点

苹果的 URLSession 功能完整,但在工程实践中会遇到这些问题:

原生 URLSession Alamofire
需要手动构建 URLRequest 链式 API,一行发起请求
响应解析需要大量样板代码 内建 Decodable 自动解析
上传/下载进度管理繁琐 原生支持进度回调
拦截器/重试需要自行实现 内建 RequestInterceptor
错误处理分散、不统一 统一的 AFError 体系

核心优势:

  • 声明式链式调用,代码意图一目了然
  • 内建 JSON/Decodable 解析,减少胶水代码
  • RequestInterceptor:拦截、重试、Token 刷新统一处理
  • EventMonitor:全链路可观测,调试/日志非常方便
  • async/await 原生支持(5.5 起)

三、核心功能速览

基础层(新手必读)

集成方式(SPM 推荐)

Package.swift 或 Xcode 的 Package Dependencies 中添加:

https://github.com/Alamofire/Alamofire.git

最简单的 GET 请求

// Swift 5.7+
import Alamofire

AF.request("https://httpbin.org/get").responseJSON { response in
    print(response.value ?? "No data")
}

使用 async/await(推荐)

let response = await AF.request("https://httpbin.org/get")
    .serializingDecodable(MyModel.self)
    .response

switch response.result {
case .success(let model): print(model)
case .failure(let error): print(error)
}

进阶层(最佳实践)

带参数的 POST 请求

let parameters: [String: Any] = ["username": "swift", "password": "123456"]

AF.request(
    "https://httpbin.org/post",
    method: .post,
    parameters: parameters,
    encoding: JSONEncoding.default,
    headers: ["Authorization": "Bearer your_token"]
)
.validate(statusCode: 200..<300)   // 自动校验状态码
.responseDecodable(of: LoginResponse.self) { response in
    // 直接拿到强类型 Model
}

文件上传(带进度)

AF.upload(
    multipartFormData: { form in
        form.append(fileData, withName: "file", fileName: "photo.jpg", mimeType: "image/jpeg")
    },
    to: "https://example.com/upload"
)
.uploadProgress { progress in
    print("上传进度:\(progress.fractionCompleted)")
}
.responseDecodable(of: UploadResult.self) { response in
    print(response.value)
}

文件下载

let destination = DownloadRequest.suggestedDownloadDestination()
AF.download("https://example.com/file.zip", to: destination)
    .downloadProgress { progress in
        print("下载进度:\(Int(progress.fractionCompleted * 100))%")
    }
    .responseURL { response in
        print("保存路径:\(response.fileURL)")
    }

深入层(源码视角)

Alamofire 5 的核心模块职责:

模块 职责
Session URLSession 的封装,全局入口(AF 是默认单例)
Request 体系 DataRequest / UploadRequest / DownloadRequest 三条请求链路
RequestInterceptor adapt 修改请求 + retry 重试逻辑分离
ResponseSerializer Data 转换为目标类型,可自定义扩展
EventMonitor 全链路事件监听,用于日志/埋点

四、实战演示

场景:带 Token 自动刷新的 API 客户端

这是工程中最常见的场景——Token 过期后自动刷新并重试原始请求。

// Swift 5.7+

// 1. 定义拦截器
final class AuthInterceptor: RequestInterceptor {
    private var accessToken: String = KeychainHelper.accessToken
    private var isRefreshing = false
    private var requestsToRetry: [RetryCompletion] = []

    // adapt:每次请求前注入 Token
    func adapt(_ urlRequest: URLRequest,
               for session: Session,
               completion: @escaping (Result<URLRequest, Error>) -> Void) {
        var request = urlRequest
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        completion(.success(request))
    }

    // retry:401 时触发刷新
    func retry(_ request: Request,
               for session: Session,
               dueTo error: Error,
               completion: @escaping RetryCompletion) {
        guard let response = request.task?.response as? HTTPURLResponse,
              response.statusCode == 401 else {
            completion(.doNotRetry)
            return
        }
        requestsToRetry.append(completion)
        guard !isRefreshing else { return }
        refreshToken { [weak self] success in
            self?.requestsToRetry.forEach { $0(success ? .retry : .doNotRetry) }
            self?.requestsToRetry.removeAll()
        }
    }

    private func refreshToken(completion: @escaping (Bool) -> Void) {
        isRefreshing = true
        AF.request("https://api.example.com/refresh",
                   method: .post,
                   parameters: ["refreshToken": KeychainHelper.refreshToken])
            .responseDecodable(of: TokenResponse.self) { [weak self] response in
                self?.isRefreshing = false
                if let token = response.value?.accessToken {
                    self?.accessToken = token
                    KeychainHelper.accessToken = token
                    completion(true)
                } else {
                    completion(false)
                }
            }
    }
}

// 2. 创建自定义 Session(全局单例,推荐)
enum APIClient {
    static let session = Session(interceptor: AuthInterceptor())

    static func fetchUserProfile() async throws -> UserProfile {
        try await session.request("https://api.example.com/profile")
            .validate()
            .serializingDecodable(UserProfile.self)
            .value  // throws on error
    }
}

// 3. 调用
Task {
    do {
        let profile = try await APIClient.fetchUserProfile()
        print("用户:\(profile.name)")
    } catch {
        print("请求失败:\(error)")
    }
}

这个示例涵盖了:Token 注入、自动刷新、队列等待、async/await 调用——工程级最常见的模式。


五、源码亮点

进阶层:值得借鉴的用法

链式调用设计

Alamofire 所有方法都返回 Self(请求对象本身),使得可以无限链式组合:

AF.request(url)
    .validate()                        // 校验
    .responseDecodable(of: T.self)     // 解析
    .uploadProgress { }               // 进度
// 每一步都是独立关注点,互不干扰

自定义 ResponseSerializer

// 扩展支持自定义格式(如 protobuf)
struct ProtobufSerializer<T: Message>: ResponseSerializer {
    func serialize(request: URLRequest?, response: HTTPURLResponse?,
                   data: Data?, error: Error?) throws -> T {
        guard let data = data else { throw AFError.responseSerializationFailed(...) }
        return try T(serializedData: data)
    }
}

深入层:设计思想解析

责任链模式(Chain of Responsibility)

RequestInterceptoradaptretry 两个钩子将「请求构造」与「失败重试」完全分离,任何一个环节都可以独立替换,不影响其他逻辑。这是典型的责任链 + 开闭原则实践。

EventMonitor:观察者模式的正确姿势

// 实现一个打印所有请求的 Logger
final class NetworkLogger: EventMonitor {
    func requestDidFinish(_ request: Request) {
        print("✅ \(request.request?.url?.absoluteString ?? "")")
    }
    func request<Value>(_ request: DataRequest,
                        didParseResponse response: DataResponse<Value, AFError>) {
        print("📦 StatusCode: \(response.response?.statusCode ?? 0)")
    }
}

// 注入 Session
let session = Session(eventMonitors: [NetworkLogger()])

不侵入业务代码,零耦合实现全链路可观测——比 print 打散在各处优雅得多。


六、踩坑记录

问题 1:responseJSON 废弃警告

  • 原因:5.5+ 起 responseJSON 被标记为 deprecated,官方推荐 responseDecodable
  • 解决:定义 Decodable Model,使用 .responseDecodable(of: MyModel.self)

问题 2:多个请求并发刷新 Token 导致死循环

  • 原因:多个请求同时 401,每个都触发了刷新逻辑
  • 解决:拦截器中用 isRefreshing flag + 队列缓存等待回调(见上方实战示例)

问题 3:AF.request 在 Background Task 中失效

  • 原因:默认 Session 使用前台 URLSession 配置
  • 解决:自建 Session 并传入 URLSessionConfiguration.background(withIdentifier:)
let config = URLSessionConfiguration.background(withIdentifier: "com.app.bg")
let bgSession = Session(configuration: config)

问题 4:上传大文件内存暴涨

  • 原因:使用 Data 形式上传会将整个文件加载进内存
  • 解决:使用 fileURL 形式上传,Alamofire 会以流式方式读取
AF.upload(fileURL, to: "https://example.com/upload")

问题 5:.validate() 没有按预期触发

  • 原因:没有加 .validate(),Alamofire 默认不对 4xx/5xx 报错
  • 解决:养成习惯,链式调用中永远加 .validate(statusCode: 200..<300)

问题 6:响应在主线程,但 UI 更新闪烁

  • 原因responseDecodable 默认回调在主队列,但复杂解析会短暂阻塞
  • 解决:用 queue: 参数将解析切到后台,主动 dispatch 到主线程更新 UI
AF.request(url).responseDecodable(of: T.self, queue: .global(qos: .userInitiated)) { response in
    DispatchQueue.main.async { /* 更新 UI */ }
}

七、延伸思考

同类库横向对比

语言 特点 学习曲线 维护状态
Alamofire Swift 功能全面,生态最成熟 活跃
Moya Swift 基于 Alamofire,API 抽象层 中高 活跃
URLSession + async/await Swift 零依赖,苹果原生 低(但样板多) 官方
AFNetworking Objective-C OC 项目首选 维护模式

推荐使用场景

  • ✅ 中大型 Swift 项目,需要统一网络层
  • ✅ 需要 Token 刷新、请求重试等复杂拦截逻辑
  • ✅ 需要完善的上传/下载进度管理
  • ✅ 团队协作,希望网络层有统一规范

不推荐场景

  • ❌ 简单脚本或极小型项目(引入成本 > 收益)
  • ❌ 纯 SwiftUI + async/await 项目,原生 URLSession 已经足够
  • ❌ 对包体积极度敏感的场景

八、参考资源


九、本期互动

小作业

基于本文的 AuthInterceptor 示例,扩展实现以下功能:当 Token 刷新失败(服务端返回 400)时,自动跳转到登录页,并取消所有等待中的请求。在评论区贴出你的关键代码实现。

思考题

Alamofire 的 RequestInterceptor 将「修改请求」和「重试决策」放在同一个对象里——你认为这是合理的设计吗?如果让你重新设计这个接口,你会如何拆分职责?

读者征集

下一期预计介绍 Kingfisher(图片加载库),如果你在使用 Kingfisher 时踩过坑,欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列每周五晚更新 ➡️ 第1期:Alamofire · ○ 第2期:Kingfisher · ○ 第3期:待定 · ○ 第4期:待定

❌