mcpField
W
write-swift

write-swift

github.com/emilkowalski开发51.1k124.7k 次安装今天更新
代码仓库

关于 write-swift

write-swift 是面向 Swift 开发者的编程指南技能,内容覆盖现代 Swift 的数据建模、错误处理与并发,并以逐步采用复杂机制为原则。

主要功能

  1. 01值类型与数据建模建议
  2. 02错误与可选值处理指导
  3. 03Swift 并发与隔离规则
  4. 04Sendable 与跨域共享建议
  5. 05结构化并发指导

适用场景

  1. 01为 Swift 类型选择 struct、enum 或 class
  2. 02梳理错误处理与可选值逻辑
  3. 03依据性能分析规划并发
  4. 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
安全性安全 APIC 互操作或经过测量的热点路径 → 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 改变了并发模型。

每个应用都从完全在主线程上运行开始。单线程代码已经能解决很多问题,而且大多数应用根本不需要脱离主线程。

按顺序逐步推进。不要跳过步骤。

  1. 在主执行器上运行单线程代码。 完全不使用并发。对大多数应用来说已经足够。
  2. 使用 async/await 隐藏延迟(网络、磁盘)。仍然不使用自己创建的并发——像 URLSession.data(from:) 这样的 SDK API 会替你卸载工作。
  3. 使用 @concurrent 将_你自己的_耗时工作移出主线程——仅在 Instruments 显示发生卡顿后使用。
  4. 使用 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) 也一样:这是最后的手段,不是消除警告的办法。

遇到数据竞争错误时,请按以下顺序逐项排查:

  1. 不要共享它。 将共享对象移到局部作用域,让每个并发任务都拥有自己的实例。(绝大多数实际错误都应通过此方法修复。)
  2. 将其改为符合 Sendable 的值类型,这样所谓的“共享”实际上就是复制。
  3. 将其隔离到某个 Actor——主 Actor 或你自己的 Actor。
  4. 只有在这之后,才使用 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 性能主要受四项开销影响。要清楚自己承担的是哪一项。

  1. 函数调用——参数复制、静态与动态分派、调用栈帧分配,以及受阻的优化。
  2. 内存布局——内联与非内联存储;动态大小类型。
  3. 分配——全局(免费)、栈(便宜:只需一次减法)、堆(昂贵:搜索并加锁)。
  4. 复制——引用计数的保留/释放操作,以及递归的结构体复制。

但要先做好算法层面的优化。 每次写循环时,都试着用算法调用替代。最大的收益几乎从来都不是微优化:

  • 了解所调用操作的复杂度。 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 或更早版本,请优先采用旧式写法。

与其使用不如使用起始版本
嵌套三元表达式;用立即调用的闭包初始化 letif/switch 表达式5.9
为 1、2、3……个参数分别定义重载参数包(each T),以及对参数包使用 for5.9
每个属性都使用 ObservableObject + @Published@Observable5.9
轮询对象以检测变化Observations { ... } — 一个包含事务性更新的 AsyncSequence6.2
使用 NotificationCenter 和字符串类型的 userInfo具体的通知类型(MainActorMessage / AsyncMessage)6.2
使用 Process + 管道编写脚本Subprocess 包(逐行输出使用 AsyncBufferSequence.strings();1.0 随 6.4 发布)6.2+
手动处理字符串索引计算Swift Regex — 简洁场景使用字面量,结构化场景使用 RegexBuilder5.7
在性能关键路径中使用固定大小的 [String]InlineArray<N, T>6.2
withUnsafeBufferPointer.span / .bytes(RawSpan)/ OutputSpan6.2
手动处理 Task.isCancelled,以完成写入Task 取消屏蔽(SE-0504)6.4 ⚠
手动重建字典以使用其键mapKeyedValues6.4 ⚠
@available(iOS ..., macOS ..., tvOS ..., watchOS ..., visionOS ...)@available(anyAppleOS ...)6.4 ⚠
当类型与模块同名时使用 Rocket.SaturnV模块选择器 Rocket::SaturnV6.3
全局启用“将警告视为错误”按声明 / 警告组使用 @diagnose6.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

迁移顺序很重要,混合步骤会让迁移停滞。

  1. 先用新编译器构建。 源码兼容性意味着这一步在 Swift 5 模式下应该可以直接完成。
  2. 按目标启用完整并发检查(Swift 5 模式 + 所有 Swift 6 警告)。从 UI/应用层开始,而不是从它下面的框架开始——SDK 已经为其中许多代码添加了主 Actor 注解,因此修复率很高。
  3. 优先修复成本最低的警告。 预计少数几个根本原因会引发数百条警告:应该改为 let 的 var 全局变量、应放到 @MainActor 上的自由函数,以及需要添加 : Sendable 的一个公共结构体。一行代码就能清除几十条警告。
  4. 将目标切换到 Swift 6 语言模式,以固定迁移成果。
  5. 迁移到下一个目标并重复上述步骤。
  6. 之后再单独重构。 启用数据竞争安全时,绝不要同时进行重大重构——否则你得同时撤回两者。

你可以关闭严格检查后发布;你所做的每项修复都是真正的改进,并且会保留下来。开始前请启用 Approachable Concurrency,对于应用模块,还应启用默认主 Actor——这两项都能大幅减少你会遇到的错误数量,而且 Xcode 附带的迁移工具可以自动应用许多更改(swift.org/migration)。

快速参考

需求选择不要选择
一种数据类型struct / enum没有身份或共享需求的 class
共享的可变状态actor,或 @MainActor classclass + 一个必须记住使用的锁
将工作移出主线程@concurrent func … asyncTask.detached、DispatchQueue.global()
库 API 的隔离nonisolated@MainActor、@concurrent
固定数量的并行任务async letN 个非结构化 Task
动态数量的并行任务withTaskGroup(有界)每个元素一个任务,且数量无界
不返回任何内容的子任务withDiscardingTaskGroup从不清空任务组的 withTaskGroup
与 UI 事件关联的工作回调内部的 Task { }将回调设为 async
修复数据竞争停止共享对象@unchecked Sendable
共享的模型类非 Sendable,或 @MainActorSendable + 手动加锁
跨越 await 的阻塞原语什么都不用——重构代码DispatchSemaphore、NSCondition
多态some P除非需要存储,否则不要用 any P
异构集合[any P]类层次结构
共享行为,不需要自定义受约束的 extension新协议
自定义点协议要求仅在扩展中定义的方法
打破引用循环重构为树形结构weak + withExtendedLifetime
移除匹配的元素removeAll(where:) — O(n)在循环中使用 remove(at:) — O(n²)
直接访问连续内存.spanwithUnsafeBufferPointer
热路径中的定长缓冲区InlineArray<N, T>Array
新测试@Test + #expectXCTestCase + XCTAssertEqual
使用许多输入运行同一测试@Test(arguments:)for 循环或复制粘贴
失败时停止测试try #requirecontinueAfterFailure = false
暂时失败的测试withKnownIssue.disabled 或注释掉
正式发布代码中的诊断Logger + 关联 IDprint
判断是否优化对经过性能分析的测试使用 Instruments凭直觉

评论

支持 Markdown