关于 write-swift
write-swift 是面向 Swift 开发者的编程指南技能,内容覆盖现代 Swift 的数据建模、错误处理与并发,并以逐步采用复杂机制为原则。
主要功能
- 01值类型与数据建模建议
- 02错误与可选值处理指导
- 03Swift 并发与隔离规则
- 04Sendable 与跨域共享建议
- 05结构化并发指导
适用场景
- 01为 Swift 类型选择 struct、enum 或 class
- 02梳理错误处理与可选值逻辑
- 03依据性能分析规划并发
- 04排查数据竞争与 Sendable 问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ emilkowalski/ skills --skill write-swift安装后在 Claude Code 中输入/write-swift确认已加载。
详细介绍
编写 Swift
首次响应
首次调用此技能且没有具体问题时,仅回复:
我已准备好帮助你以 Swift 语言所倡导的方式编写现代 Swift。
在用户提出问题之前,不要提供任何其他信息。
如何以 Swift 语言所倡导的方式编写 Swift,内容更新至 Swift 6.4。
工具链基线:Swift 6.3(截至 2026 年 8 月的当前版本)。除标有 ⚠ 的内容(表示尚未发布的 Swift 6.4 功能)外,本文所有内容都可在 6.3 上编译。并发指南假定采用 Swift 6.2 的模型——如果项目使用的是 6.1 或更早版本,第 3 节中关于 async 和 @concurrent 的规则不适用。
贯穿全文的主线:**Swift 是一种渐进式披露语言。先从最简单、最静态、单线程的可行方案入手,只在能明确说明理由时才引入动态特性——并发、引用语义、存在类型、不安全指针。**以下每条规则都是这一原则的体现。
遵循以下默认层级。只有在能明确说明理由时,才降到下一层:
| 需求 | 首选 | 只有在以下情况下才降级 |
|---|---|---|
| 数据 | struct / enum | 需要标识、共享或继承 |
| 抽象 | 具体类型 | 不同类型之间有重复代码 |
| 多态 | some P(泛型) | 需要存储异构值 → any P |
| 执行 | 主 actor、同步 | 性能分析显示发生卡顿 → async → @concurrent → actor |
| 内存 | Array、String | 性能分析显示成本过高 → InlineArray、Span |
| 安全性 | 安全 API | C 互操作或经过测量的热点路径 → Unsafe* |
1. 使用值类型建模数据
值类型是 Swift 中的默认选择,并非特殊情况。
- 默认使用
struct和enum。只有在需要身份、共享可变状态、继承或资源生命周期时才使用class。 窗口、数据库连接、存储在渲染引擎中的实体——这些都有身份。Point、Drink、Material则没有。 - 默认使用
let;仅在需要修改时才使用var。 这与优先使用some而非any、优先使用值而非引用的原则相同:从窄范围开始,有理由时再放宽。 - 带有可变引用类型属性的结构体,既不是值,也不是引用。 复制后仍会共享该对象;对其进行修改会影响所有副本。要么让被引用的类型不可变,要么仅公开转发到该对象的计算属性,要么将其作为
private存储属性,并通过写时复制来管理。 - 写时复制能让你同时获得外置存储和_值语义_。 将
final类包装在结构体中,并在修改前检查isKnownUniquelyReferenced(&storage);如果结果为否,就先复制。这正是Array、String和Dictionary的实现方式。 - 对于“一组固定的事物”和互斥状态,枚举是合适的工具。 将一组可选存储属性(
isSharing、selectedRows、shareTarget)替换为一个enum State,即可让无效组合无法表示,并使状态变更成为原子操作,而不是一连串可能忘记完成的属性写入。 - 组合值会得到一个值。 所有存储属性都是值类型的结构体天然具有值语义——这使撤销、差异比较和状态恢复只需一条代码路径,而非每个属性各用一条。
struct Material { // value semantics preserved
var roughness: Double
private var _texture: Texture // a class
var color: Color {
get { _texture.color }
set {
if !isKnownUniquelyReferenced(&_texture) { _texture = Texture(copying: _texture) }
_texture.color = newValue
}
}
}
不可复制类型(~Copyable)表达唯一所有权:文件描述符、银行转账、已打开的资源。禁止复制能将“不得重复执行”从断言变成编译错误,也让结构体上的 deinit 有实际意义。将收尾方法标记为 consuming,让编译器证明这是最后一次使用。参数所有权因此变得明确:borrowing(只读,默认值)、consuming(接管所有权)、inout/mutating(临时写入访问)。
2. 错误与可选值——让失败路径清晰可见
Swift 的错误处理基于三点:错误来源会被标记,因此不会让你措手不及;错误携带足够的上下文,以便采取行动;可恢复错误与程序员失误是不同的。
- 可恢复错误 →
throw。程序员失误 →precondition/fatalError。 网络调用失败时,程序仍会继续运行。索引越界意味着代码有误,必须在该 bug 演变成安全问题之前停止运行。 - 带关联值的枚举是最佳的错误类型。
case duplicateFriend(String)优于case duplicateFriend——上下文才是重点。 - 用
guard处理错误条件,因为它会强制走退出路径。普通的解包则使用if let。 - 类型化抛出(
throws(MyError))适用于内部函数、转发错误的泛型代码,以及对装箱any Error开销要求很高的受限环境。 对于公共 API,无类型的throws能让你日后自由更改错误类型。注意统一规则:throws就是throws(any Error),而不抛出错误就是throws(Never)——这使map能够同时抽象处理两者。 - 仅在能够明确说明不变量的地方强制解包,并且优先使用带有消息的失败式
#require/precondition,而非单独使用!。
3. 并发:在性能分析证明有必要之前,保持单线程
这是 Agent 最容易出错的部分,因为 Swift 6.2 改变了并发模型。
每个应用都从完全在主线程上运行开始。单线程代码已经能解决很多问题,而且大多数应用根本不需要脱离主线程。
按顺序逐步推进。不要跳过步骤。
- 在主执行器上运行单线程代码。 完全不使用并发。对大多数应用来说已经足够。
- 使用
async/await隐藏延迟(网络、磁盘)。仍然不使用自己创建的并发——像URLSession.data(from:)这样的 SDK API 会替你卸载工作。 - 使用
@concurrent将_你自己的_耗时工作移出主线程——仅在 Instruments 显示发生卡顿后使用。 - 使用
actor将_状态_移出主执行器——仅当主执行器上的状态过多,导致任务不断切回主执行器时使用。
先启用正确的构建设置。 在每个项目中启用 Approachable Concurrency。对于应用模块和面向 UI 的模块,还要将 Default Actor Isolation 设为 MainActor——这是 Xcode 26 新建应用项目的默认设置,并能删掉大部分 @MainActor 注解。在包中使用:swiftSettings: [.defaultIsolation(MainActor.self)]。不要为通用库设置默认主执行器隔离——库应提供 nonisolated API,让客户端决定工作在哪儿运行。
改变了的规则
在 Swift 6.2 中,将函数标记为 async _不会_让它脱离当前执行器运行。 它会在调用它的位置运行。这让“最自然的代码写法”默认就不会产生数据竞争。
@concurrent——始终切换到并发线程池。用于_你自己的_ CPU 密集型工作。nonisolated——在调用它的位置运行。这是库 API 的正确默认设置,因为由调用方决定。类型上的nonisolated会使其所有成员都成为非隔离成员(Swift 6.1+)。- 两者都不使用——留在调用方的执行器上。
nonisolated struct PhotoProcessor { // decoupled from the main actor
@concurrent // guaranteed to run in the background
func process(_ data: Data) async -> ProcessedPhoto {
async let sticker = extractSticker(data) // two independent jobs, in parallel
async let colors = extractColors(data)
return await ProcessedPhoto(sticker: sticker, colors: colors)
}
}
- 卸载工作前先进行性能分析。 使用 Instruments(Time Profiler、卡顿分析)。如果无需并发就能让代码变快,务必先这样做。并发有实际开销——任务分配、调度和推理成本。
- 不要为琐碎工作创建任务。 创建子任务读取
UserDefaults的开销,比它带来的收益还大。 - 每个端到端操作创建一个任务。 必须按顺序执行的工作放在_同一个_任务中;独立操作则分别创建任务,让运行时可以交错执行。
await是一个挂起点,会打破原子性。 挂起期间状态可能发生变化,恢复时也可能在不同线程上。每个await之后都要重新检查假设。绝不要跨越await持有锁,也不要跨越await依赖线程局部存储。
Actor 可重入性
Actor 保证互斥,不保证事务性。在同一个 Actor 上两个 await 之间,其他工作会运行。
- 在同步方法中修改 Actor 状态。 Actor 上的同步代码会不间断地运行至完成——这就是你的事务边界。
- 让 Actor 的异步方法保持简短,由同步的事务操作组合而成,并确保 Actor 在每个
await处都处于一致状态。 - 典型错误:检查缓存 →
await下载 → 写入缓存。两个任务都未命中缓存,于是都开始下载,后一个任务覆盖前一个任务的结果。应在await之后重新检查,或对正在进行的工作去重。 - Actor 不保证先进先出。 它们会优先运行高优先级工作,正是为了避免优先级反转。如果需要顺序保证,请使用任务(任务会从开始运行到完成)或
AsyncStream,而不是 Actor。
4. 可发送类型与数据共享
Sendable 用于标记可在不同隔离域之间安全共享的类型。编译器会在每个任务和 Actor 边界检查这一点。
- 当值类型的存储属性符合
Sendable时,该类型也符合Sendable——对于非公开类型,会自动推断。公开类型绝不会自动推断其可发送性:将公开类型标记为Sendable是对客户端的承诺,因此 Swift 要求你显式声明。 - Actor 和
@MainActor类隐式符合Sendable,因为它们的状态是隔离的。 - 大多数模型类既不应标记为
@MainActor,也不应符合Sendable。 有意让它们保持非Sendable——这样可以避免模型的一部分在主线程上修改,另一部分却在后台修改。如果它们需要离开主 Actor,请将其设为nonisolated,而非Sendable。 - 你仍然可以在隔离域之间 发送 非
Sendable对象,只要发送方停止使用它即可。交接对象前完成所有修改;之后再访问它才是错误。 - 闭包也会捕获状态。只有当函数类型确实会跨越隔离域时,才将其标记为
@Sendable。 @unchecked Sendable是一个编译器无法检查的承诺。 仅将其用于具有真正内部同步机制的类型(例如Mutex或锁)。全局变量上的nonisolated(unsafe)也一样:这是最后的手段,不是消除警告的办法。
遇到数据竞争错误时,请按以下顺序逐项排查:
- 不要共享它。 将共享对象移到局部作用域,让每个并发任务都拥有自己的实例。(绝大多数实际错误都应通过此方法修复。)
- 将其改为符合
Sendable的值类型,这样所谓的“共享”实际上就是复制。 - 将其隔离到某个 Actor——主 Actor 或你自己的 Actor。
- 只有在这之后,才使用
Synchronization模块中的Mutex/Atomic(将它们存储在let属性中),或@unchecked Sendable。
全局变量和静态变量是最常见的错误来源。 优先顺序如下:将其设为 let;将其放在 @MainActor 上;用 Mutex 包装;使用 nonisolated(unsafe)。请注意,Swift 中的全局变量采用延迟且原子化的初始化方式——这与 C 不同。
桥接旧版回调 API: 如果委托协议由你维护,请用 @MainActor 标注它们。如果不由你维护,则将方法标记为 nonisolated,并使用 MainActor.assumeIsolated { }——它会进行断言,而不是切换 Actor,因此会明确触发崩溃,而非静默地产生数据竞争。符合协议时使用 @preconcurrency 是实现相同效果的简写。使用 @preconcurrency import 可暂时屏蔽尚未迁移的模块所产生的可发送性警告;模块迁移后,这些警告会正确地重新出现。
5. 结构化并发
始终优先使用结构化任务。
结构化任务(async let、任务组)的作用域类似局部变量:它们不能超出代码块的生命周期,会自动等待,并通过任务树继承取消、优先级和任务局部值。非结构化任务(Task { }、Task.detached)不会自动获得这些特性。
async let:用于固定且在静态上已知数量的并发子任务。withTaskGroup:用于数量动态变化的场景。任务组遵循AsyncSequence协议——结果到达时即可迭代。子任务不返回任何内容时,使用withDiscardingTaskGroup:它会立即释放每个子任务的资源,并在首次出错时取消其他子任务。Task { }:仅当工作生命周期不适合某个作用域时使用,例如响应代理回调、按钮点击或视图出现。它会继承 Actor 隔离和优先级;你必须自行管理取消。Task.detached:几乎不要使用。它不会继承任何内容——既不继承隔离,也不继承优先级或任务局部值。如果需要一个分离的根任务,请在其中放入任务组,而不要反复创建分离任务。
取消是协作式的。 取消只会设置一个标志,不会停止任何操作。在开始耗时工作之前以及同步辅助函数中,检查 Task.isCancelled 或调用 try Task.checkCancellation()。对于处于挂起状态而非运行状态的工作(例如 AsyncSequence 的 next()),使用 withTaskCancellationHandler——并记住,处理程序会立即运行,并与主体并发执行,因此它访问的状态需要真正的同步保护(使用原子操作或锁,而不是 Actor——你无法保证 Actor 上的执行顺序)。
限制并发数量。 不要针对无界列表中的每个项目都启动一个子任务。先启动 N 个子任务,每当一个完成时再添加一个。
任务局部值(@TaskLocal)可在任务树中传递上下文——例如请求 ID、跟踪跨度——而无需在每个函数签名中传递参数。将它们设为可选值,让未绑定时的读取也有合理的默认值。
桥接回调: 使用 withCheckedContinuation / withCheckedThrowingContinuation。约定是每条路径都必须恰好恢复一次——从不恢复会让调用方永远挂起;恢复两次则会导致致命错误。对于稍后才触发的代理 API,保存 continuation,并在恢复时将其设为 nil。(Swift 6.4——尚未发布——会新增一种 Continuation 类型,可在编译时检查是否只恢复一次。)
AsyncSequence: 使用 for await / for try await 进行迭代。使用 AsyncStream / AsyncThrowingStream 将现有的基于处理程序或代理的 API 适配为异步序列——在闭包内构造源,在处理程序中调用 yield,并在 onTermination 中清理。
6. SwiftUI 中的并发
View隔离于@MainActor,它包含的所有内容也都如此,包括你的@State。你几乎不需要在视图或视图模型上写@MainActor——启用默认主 Actor 隔离后,可以删掉已有的这些标注。- SwiftUI 会刻意将部分代码放到主线程之外运行,以确保帧处理开销低。API 签名中的
@Sendable是提示:visualEffect、Shape.path(in:)、Layout要求、onGeometryChange。如果在这些闭包中遇到隔离错误,不要传递self——将所需的单个值复制到闭包的捕获列表中。
.visualEffect { [pulse] effect, proxy in // copy the Bool, don't capture self
effect.blur(radius: pulse ? 2 : 0)
}
- SwiftUI 的操作回调有意设为同步。 对时间敏感的 UI 更新——例如响应手势或滚动事件启动动画——必须与事件发生在同一帧。将
withAnimation状态更改放在同步回调中;仅在后续的长时间运行工作需要时才启动Task。 - 在 UI 与异步工作之间的交界处放置一项状态。 视图启动任务;异步层在任务完成时执行同步修改;UI 作出响应。这样既能让视图逻辑保持同步,也能让异步逻辑无需导入 SwiftUI 即可测试。
7. 协议与泛型
不要从类开始。也不要从协议开始。
工作流程:先编写具体类型 → 注意到它们之间重复的代码 → 将共享能力提取为协议 → 编写针对该协议的泛型代码。 几乎相同的重载实现是该进行泛化的信号。
- 没有针对每种类型进行定制的协议是浪费。 如果每个遵循该协议的类型都会使用相同的默认实现,就在现有协议上编写受约束的扩展。复杂的协议层次结构(“类型动物学”)会增加编译时间和二进制大小,却没有任何收益。
- 优先选择组合,而不是“是一个”。 如果协议的某些操作对你的类型没有意义,就不要细化该协议,而是将其封装在泛型结构体中,只公开你需要的 API。(例如,使用
GeometricVector<Storage: SIMD>,而不是GeometricVector: SIMD。) - 协议要求是一个定制点——它会动态派发,而且遵循协议的类型的实现会在所有地方生效。仅存在于扩展中的方法则是静态派发,因此遵循协议的类型提供的版本只是_隐藏_了扩展中的方法,而不是覆盖它;只知道
any P的代码会调用扩展中的方法。如果某种类型应该能够定制某项功能,就将其设为协议要求。 - 优先使用组合,而不是继承。 类继承是整体式的(只能有一个父类)、侵入性强(会继承存储属性和初始化器的复杂性),而且可能覆盖什么内容以及何时调用
super等约定都没有明文规定。改为组合小型值类型。 - 强制向下转型是代码异味——这通常意味着类型关系在类层次结构或存在类型中丢失了。
some 与 any
- 默认使用
some P。需要存储任意类型时再改用any P。 这与优先使用let而不是var的原则相同。 some P— 每个作用域内只有一种固定的底层类型。你可以保留所有类型关系,包括关联类型,而且编译器可以进行特化。any P— 类型擦除的容器,动态类型在运行时变化。异构集合、底层类型可选,以及完全隐藏抽象时都需要它。使用它需要付出代价:关联类型关系会被擦除为其上界,而且优化器无法看透调用。- 无法在
any P上调用接收关联类型的方法。 类型擦除适用于产出位置(结果会被擦除为其上界),但不适用于消费位置。解决方法是将存在类型传递给接收some P的函数——编译器会将其拆箱,并在该作用域内再次固定类型。 - 受约束的存在类型和不透明类型——例如
some Collection<Element>、any Collection<any Animal>——让你可以隐藏LazyFilterSequence<[Animal]>,同时仍公开元素类型。在自己的协议上声明主要关联类型(protocol Container<Item>),用于调用者实际提供的类型,而不是Iterator这类实现细节。 where子句中的同类型要求用于固定跨协议的关系(例如where Self.CropType.FeedType == Self)。没有这些要求,“先种植再收获”就无法通过类型检查,而且错误的遵循也能通过编译。
8. API 设计——让 API 在使用时清晰易懂
让 API 在使用时清晰易懂,是这里优先级最高的目标。
- 仅供 Swift 使用的 API 不要添加类型前缀。 模块会消除歧义。只有当 API 与 Objective-C API 对应时才保留前缀。但要避免使用来自特定框架、过于通用的名称——脱离上下文时,这些名称可读性很差,还会迫使人们手动消除歧义。
- 异步替代方案以及任何直接返回结果的内容,都不要使用前导
get。 用persistentPosts,而不是getPersistentPosts。 - 访问控制就是文档。
private(文件)、internal(模块,也是默认级别)、package、public。在边界处明确指定访问级别,才能促使你做出上文提到的可发送性和 API 演进决策。 - 设计模型,让非法状态无法表达。 使用私有 setter 加上带验证的 mutating 方法;用枚举表示封闭集合;用强类型的
UUID代替String。 - 属性包装器 将 访问策略(
@Argument、@Published、防御性复制、延迟、线程局部)提取出来,让声明处用一个词说明策略。结合键路径上的@dynamicMemberLookup,即可通过包装器投影($binding.title就是这样工作的)。 - 声明式 DSL 使用 结果构建器。如果样板代码本可由编译器生成,则使用 宏(§12)。
9. 性能——先测量,再选择
低级 Swift 性能主要受四项开销影响。要清楚自己承担的是哪一项。
- 函数调用——参数复制、静态与动态分派、调用栈帧分配,以及受阻的优化。
- 内存布局——内联与非内联存储;动态大小类型。
- 分配——全局(免费)、栈(便宜:只需一次减法)、堆(昂贵:搜索并加锁)。
- 复制——引用计数的保留/释放操作,以及递归的结构体复制。
但要先做好算法层面的优化。 每次写循环时,都试着用算法调用替代。最大的收益几乎从来都不是微优化:
- 了解所调用操作的复杂度。
Array.remove(at:)的复杂度是 O(n);在循环中调用它,复杂度就是 O(n²)。removeAll(where:)的总复杂度是 O(n)。每处理一个字节就通过重新切片来构建Data,复杂度是 O(n²);popFirst()则是 O(1)。这两个问题都曾造成 100 倍以上的性能退化,却隐藏在看起来简洁的代码背后。 - 连续使用
map/flatMap/filter,每个阶段都会分配一个数组。 优雅 ≠ 快。如果某个流水线在热点循环中逐像素或逐元素运行,就一次性确定输出大小并写入其中。 - 然后进行性能分析。 使用 Instruments 的 Time Profiler 和 Allocations,针对某个 测试 运行(在测试的运行按钮上次要点击 → Profile),这样测量的正是你关心的代码。火焰图中
platform_memmove占主导,意味着发生了意外复制;数百万次短暂分配意味着存在中间数组;swift_beginAccess表示运行时独占性检查;swift_retain/swift_release表示存在引用计数开销。
具体的优化手段,按大致收益排序:
- 对不打算让其被继承的类使用
final,可以将动态派发变为静态派发,并启用内联。整模块优化能让编译器在许多情况下自行证明这一点,还能启用泛型特化;通过泛型特化,泛型的开销可以归零。 - 结构体的存储是内联的;类的存储则在内联区域之外。 小型结构体没有开销;含有 3 个引用类型字段的大型结构体,每次复制都要执行 3 次保留操作,而类只需 1 次。如果经常复制它,请使用写时复制。
any P存在类型有一个 3 字的内联缓冲区。 能放得下的值会内联存储;较大的值则会在每次复制时分配到堆上。同样可以使用这种技巧:为大型类型提供带写时复制的间接存储,使其重新放入缓冲区。- 均质的
[MyModel]优于[any Model]——数据密集打包,类型信息只需传递一次,还可以进行特化。[any Model]灵活但不透明;有需要时再选用它。 - 将泛型参数约束为类(
T: AnyObject),即使不进行特化,也能让编译器确定其表示形式。 InlineArray<N, T>(Swift 6.2)适用于固定大小的存储:元素以内联方式存储,大小通过值泛型体现在类型中,无堆分配、无引用计数,也无需唯一性或独占性检查。如果它会被复制或共享,则不适合使用。Span/RawSpan/OutputSpan(Swift 6.2)可替代withUnsafeBufferPointer,用于直接访问连续存储。它们不可逃逸,因此编译器会将其生命周期绑定到容器——既能获得指针性能,又不会出现生命周期错误,保留和释放操作也会消失。- 将存储属性从嵌套类移到父结构体中,可以消除运行时独占性检查。
- 已在 Swift 6.3 中发布;确认有需要后再使用:
@inline(always)(与方法上的final搭配)和@specialized(where T == ...)(SE-0460),可为高频使用的具体类型预先特化泛型。 - 即将于 Swift 6.4 中推出(尚未发布——参见下文 §15 的说明):大型存储值使用
borrow/mutate访问器替代get/set,以及UniqueArray/UniqueBox和Ref/MutableRef,用于将重复查找提升到循环外。
异步函数会将状态保存在每个任务专用的 slab 分配器中,而不是 C 栈上,并会在每个挂起点拆分为部分函数。其开销特征与同步函数相似,只是调用开销略高——这也是不应给没有任何等待操作的内容标记为 async 的另一个原因。
往返主执行器会产生真实的上下文切换开销。 应进行批处理:将循环移入 loadArticles/updateUI,让它们接收数组,而不是每次迭代都往返切换两次。
10. ARC 和对象生命周期
- 对象的保证生命周期在最后一次使用时结束,而不是在右花括号处结束。 实际观察到的生命周期是优化器共同作用的结果,且_确实会_发生变化。依赖
deinit执行时机的代码是潜在的错误。 weak/unowned只用于打破引用循环——除此之外别无用途。 强引用所有者最后一次使用后再读取weak引用,完全可能得到nil。此处使用可选绑定_比_强制解包更糟:它会把明显的崩溃变成悄无声息的错误结果。- 更好的做法是避免构造引用循环。 将共享数据提取到第三种类型中,让双方都引用它,从而把循环变成树状结构。
- 其次是重新设计 API,让对象只能通过强引用访问。
withExtendedLifetime虽然可用,但会把正确性责任转移给你,并在代码库中到处蔓延——应将其视为补丁,而非设计方案。 - 让
deinit中的副作用保持局部。 在deinit中发布指标或触发全局效果,会与优化器的决策产生先后顺序冲突。应改用调用点处的defer,并让deinit只用于验证。 - Xcode 的 Optimize Object Lifetimes 构建设置会缩短观察到的生命周期,使其接近保证的最短生命周期,并能准确暴露这类错误。
11. 测试 —— 默认使用 Swift Testing
新测试请使用 Swift Testing。仅以下三种情况仍需使用 XCTest:UI 自动化(XCUIApplication)、性能指标(XCTMetric),以及必须用 Objective-C 编写或用于捕获 Objective-C 异常的测试。
- 在任意函数上使用
@Test—— 全局、静态或实例函数均可;async、throws和隔离于全局 Actor 的函数也都适用。 #expect(...)接受普通表达式。 无需记忆一系列XCTAssertEqual式的函数——#expect(a == b)、#expect(list.isEmpty)、#expect(!x.contains(y))都会在失败时捕获并显示子表达式的值。- 使用
try #require(...)在失败时停止测试,并安全解包可选值。它取代了continueAfterFailure = false,还允许你按需应用于每个期望。 - 测试套件是
struct。 每个测试函数都会创建一个新实例,因此状态不会在测试间泄漏。使用init进行设置;只有需要用deinit执行清理时才使用class/actor。可嵌套测试套件进行分组。 - 使用参数化测试,而不是复制粘贴或循环。
@Test(arguments: [...])会独立运行每个用例,支持并行运行和单独重新运行,并在结果中标出失败用的参数。两个参数集合会生成完整的笛卡尔积——如果需要匹配的参数对,请改用zip()。 - Trait 用于表达意图: 使用
.enabled(if:)/.disabled("reason")设置条件(不要注释掉测试——已禁用的测试仍会编译),使用.bug(url)跟踪问题,使用.tags(...)关联不同文件和目标中的测试,使用.timeLimit设置时间限制;只有测试确实无法并行运行时才使用.serialized。请使用@available,而不是运行时#available检查,这样测试库才能识别。 withKnownIssue { }用于测试因你无法控制的问题而失败的情况——它能让测试继续编译和运行,并在问题修复时通知你,这与.disabled不同。confirmation用于确认回调触发 N 次;对于没有异步重载的一次性回调,使用withCheckedContinuation。- 默认情况下,测试会以随机顺序并行运行。 这是一个优点:它能暴露测试间隐藏的依赖。请重构,而不是改用
.serialized。 - 退出测试 ——
#expect(processExitsWith: .failure) { ... }—— 在隔离的子进程中覆盖precondition/fatalError路径。仅适用于 macOS、Linux、FreeBSD、Windows。 - 迁移:两个框架可以在同一个目标中共存,因此可以逐步迁移,并从现在起使用 Swift Testing 编写新测试。测试框架互操作性(swift-testing ST-0021;请检查你的 Xcode 版本以确认是否支持)允许从 Swift Testing 测试中调用封装
XCTFail的辅助函数,反之亦然;请将模式设为 complete 或 strict(不要设为 limited,也绝不要设为 none),以确保跨框架问题仍作为错误处理,并指向Issue.record替代方案。
12. 宏
只有在编写编译器能够推导生成的代码时,才考虑使用宏。
- 宏在展开前会进行类型检查。 参数会根据宏声明的签名进行检查,因此误用会在调用处产生清晰的错误,而不是在生成的代码中造成一团乱麻。
- 独立宏(
#foo) 会生成表达式或声明。附加宏(@Foo) 会通过以下五种角色之一扩展声明:成员、同级、访问器、成员特性、遵循。多个角色可以组合——@Observable就结合了成员、成员特性和遵循。 - 使用
assertMacroExpansion将宏作为纯语法树转换进行测试。这是最快的迭代方式,也能避免没人阅读的代码中出现错误。在expansion中设置断点,并对语法节点执行po,以了解其结构。 - 宏不适用时应生成实际诊断。 抛出错误,或使用
context.addDiagnostic在特定位置提供警告和修复建议。绝不要让宏静默生成无法编译的代码。 - 展开后的代码就是普通 Swift:可以检查(“Expand Macro”)、调试和逐步执行。
13. 日志记录与调试
- 使用
os中的Logger,不要使用print。 每个子系统和类别创建一个。消息以优化形式存储,仅在显示时才会渲染,因此记录日志的成本足够低,可以保留日志代码。 - 默认会对非数字插值内容进行隐私处理。 只有对确实不涉及个人信息的数据,才为相应值启用
privacy: .public。需要在不暴露值的情况下关联数据时,使用.private(mask: .hash)。 - 级别决定持久性和开销:
debug(永不持久化,速度最快——未进行流式传输时,消息构造会完全优化掉)、info、notice(默认级别)、error、fault(持久性最强,速度最慢)。对于希望在错误报告中看到的内容,使用error/fault级别记录。 - 记录关联 ID(任务或请求 UUID),即可从设备日志存档中筛选出整个故障的历史记录,而无需重现故障。运行
log collect --device --start ...,然后在 Console 中按子系统筛选。 format:和align:都不额外收费——使用它们让日志更易读,并能按列筛选。- LLDB 能理解 Swift 任务:它可以跨线程单步跟踪
await;swift task info会显示优先级和子任务;命名任务会同时出现在调试器和 Instruments 的 Swift Concurrency 模板中。
14. 不安全代码与互操作
- “不安全”意味着 API 无法完整验证输入,因此违反其前置条件会导致未定义行为——并不意味着它会崩溃。安全 API 确实 会有意触发陷阱;明确的致命错误才是安全的结果。
- 优先使用
Span,而不是Unsafe*Pointer。 从 Swift 6.2 起,有一种安全、不逃逸且速度相同的方式来访问连续存储。仅在与 C 互操作时才使用原始指针。 - 如果必须使用指针:尽可能缩小不安全区域;使用缓冲区指针(地址 + 计数),而不是裸指针,以便跟踪边界;绝不让指针逃逸到提供它的闭包之外;并运行 Address Sanitizer。
- 在安全关键模块中启用严格内存安全——它会强制在源代码中标明每处不安全用法,从而使审计成为可能。Swift 6.4 的
@diagnose属性(尚未发布)允许你为单个函数启用此功能。 - 互操作是双向且渐进的。 C、Objective-C 和 C++ 类型可直接映射到 Swift(包括 C++ 值语义、作为 Swift 集合的容器,以及作为
~Copyable的仅可移动类型)。Swift 6.3 的@c属性可将 Swift 函数导出给 C 使用(如果声明已存在于头文件中,则使用@implementation)。一次迁移一个文件到 Swift;不要重写整个项目。
15. 应当使用的现代语法
Agent 经常会用这些语法的旧式、更冗长写法。
标记为 ⚠ 的条目属于 Swift 6.4,该版本尚未发布。 当前版本是 6.3.x。相关提案已获批准并在 main 分支中实现,因此可以放心规划,但目前还不能安全使用——使用前请检查项目的工具链;如果目标版本是 6.3 或更早版本,请优先采用旧式写法。
| 与其使用 | 不如使用 | 起始版本 |
|---|---|---|
嵌套三元表达式;用立即调用的闭包初始化 let | if/switch 表达式 | 5.9 |
| 为 1、2、3……个参数分别定义重载 | 参数包(each T),以及对参数包使用 for | 5.9 |
每个属性都使用 ObservableObject + @Published | @Observable | 5.9 |
| 轮询对象以检测变化 | Observations { ... } — 一个包含事务性更新的 AsyncSequence | 6.2 |
使用 NotificationCenter 和字符串类型的 userInfo | 具体的通知类型(MainActorMessage / AsyncMessage) | 6.2 |
使用 Process + 管道编写脚本 | Subprocess 包(逐行输出使用 AsyncBufferSequence.strings();1.0 随 6.4 发布) | 6.2+ |
| 手动处理字符串索引计算 | Swift Regex — 简洁场景使用字面量,结构化场景使用 RegexBuilder | 5.7 |
在性能关键路径中使用固定大小的 [String] | InlineArray<N, T> | 6.2 |
withUnsafeBufferPointer | .span / .bytes(RawSpan)/ OutputSpan | 6.2 |
手动处理 Task.isCancelled,以完成写入 | Task 取消屏蔽(SE-0504) | 6.4 ⚠ |
| 手动重建字典以使用其键 | mapKeyedValues | 6.4 ⚠ |
@available(iOS ..., macOS ..., tvOS ..., watchOS ..., visionOS ...) | @available(anyAppleOS ...) | 6.4 ⚠ |
当类型与模块同名时使用 Rocket.SaturnV | 模块选择器 Rocket::SaturnV | 6.3 |
| 全局启用“将警告视为错误” | 按声明 / 警告组使用 @diagnose | 6.4 ⚠ |
因为有 weak var 而使用 @unchecked Sendable | 使用 weak let;或用 ~Sendable 声明不可发送状态 | 6.4 ⚠ |
| 使用指针手动解析二进制格式 | Swift Binary Parsing(ParserSpan、带溢出检查的解析初始化器) | 6.2 |
| 使用不自然的测试函数名 | 原始标识符:@Test func `fruits have a tropical climate`() | 6.0 |
另请注意:Swift Regex 解析器可以与 Foundation 的真实解析器组合使用(.date(...)、.currency(...))——切勿在正则表达式中手动解析日期或数字。应明确指定区域设置,而不是继承系统设置。还可使用 NegativeLookahead 或 Local(原子组),避免模式在整个输入中回溯。
16. 将现有代码库迁移到 Swift 6
迁移顺序很重要,混合步骤会让迁移停滞。
- 先用新编译器构建。 源码兼容性意味着这一步在 Swift 5 模式下应该可以直接完成。
- 按目标启用完整并发检查(Swift 5 模式 + 所有 Swift 6 警告)。从 UI/应用层开始,而不是从它下面的框架开始——SDK 已经为其中许多代码添加了主 Actor 注解,因此修复率很高。
- 优先修复成本最低的警告。 预计少数几个根本原因会引发数百条警告:应该改为
let的var全局变量、应放到@MainActor上的自由函数,以及需要添加: Sendable的一个公共结构体。一行代码就能清除几十条警告。 - 将目标切换到 Swift 6 语言模式,以固定迁移成果。
- 迁移到下一个目标并重复上述步骤。
- 之后再单独重构。 启用数据竞争安全时,绝不要同时进行重大重构——否则你得同时撤回两者。
你可以关闭严格检查后发布;你所做的每项修复都是真正的改进,并且会保留下来。开始前请启用 Approachable Concurrency,对于应用模块,还应启用默认主 Actor——这两项都能大幅减少你会遇到的错误数量,而且 Xcode 附带的迁移工具可以自动应用许多更改(swift.org/migration)。
快速参考
| 需求 | 选择 | 不要选择 |
|---|---|---|
| 一种数据类型 | struct / enum | 没有身份或共享需求的 class |
| 共享的可变状态 | actor,或 @MainActor class | class + 一个必须记住使用的锁 |
| 将工作移出主线程 | @concurrent func … async | Task.detached、DispatchQueue.global() |
| 库 API 的隔离 | nonisolated | @MainActor、@concurrent |
| 固定数量的并行任务 | async let | N 个非结构化 Task |
| 动态数量的并行任务 | withTaskGroup(有界) | 每个元素一个任务,且数量无界 |
| 不返回任何内容的子任务 | withDiscardingTaskGroup | 从不清空任务组的 withTaskGroup |
| 与 UI 事件关联的工作 | 回调内部的 Task { } | 将回调设为 async |
| 修复数据竞争 | 停止共享对象 | @unchecked Sendable |
| 共享的模型类 | 非 Sendable,或 @MainActor | Sendable + 手动加锁 |
跨越 await 的阻塞原语 | 什么都不用——重构代码 | DispatchSemaphore、NSCondition |
| 多态 | some P | 除非需要存储,否则不要用 any P |
| 异构集合 | [any P] | 类层次结构 |
| 共享行为,不需要自定义 | 受约束的 extension | 新协议 |
| 自定义点 | 协议要求 | 仅在扩展中定义的方法 |
| 打破引用循环 | 重构为树形结构 | weak + withExtendedLifetime |
| 移除匹配的元素 | removeAll(where:) — O(n) | 在循环中使用 remove(at:) — O(n²) |
| 直接访问连续内存 | .span | withUnsafeBufferPointer |
| 热路径中的定长缓冲区 | InlineArray<N, T> | Array |
| 新测试 | @Test + #expect | XCTestCase + XCTAssertEqual |
| 使用许多输入运行同一测试 | @Test(arguments:) | for 循环或复制粘贴 |
| 失败时停止测试 | try #require | continueAfterFailure = false |
| 暂时失败的测试 | withKnownIssue | .disabled 或注释掉 |
| 正式发布代码中的诊断 | Logger + 关联 ID | print |
| 判断是否优化 | 对经过性能分析的测试使用 Instruments | 凭直觉 |