Swift生态的变与不变:并发与状态管理的实践观察 📅 发布时间:2026/9/9 15:32:42 👁 浏览次数: 第 121 期周报看到这个数字的时候我自己也愣了一下。每周整理几条 Swift 生态里值得关注的内容这件事不知不觉就坚持到了三位数。这期标题里借用了莫奈那套著名的《干草堆》——他对着同一堆干草画了几十遍晨光、暮色、冬雪、夏雾光影一直在变草堆却始终立在那里。Swift 世界给我的感觉差不多也是这样六月的 WWDC 一过新特性就像新的光线一样打下来把整个生态照得闪闪发亮可语言的根、社区做事的方式、写代码时最核心的那些理念其实一直没动过。所以这一期我不打算做逐条更新罗列而是借着变幻的光影不变的干草堆这个意象聊聊我对 Swift 生态里变与不变的一些观察顺便把我最近在项目里做并发迁移、SwiftUI 状态管理重构的真实过程翻出来把步骤、取舍和踩过的坑都摊开讲。不管你是刚接触 Swift 的新手还是已经在生产环境里滚了几年的老手这篇文章应该都能给你一点参考。1. 写在 #121 期之前变化的光影不变的干草堆1.1 为什么要用莫奈的干草堆来理解 Swift 生态莫奈在 1890 年到 1891 年间画了大约三十幅《干草堆》同一个题材从秋天画到春天反复捕捉光线在草堆表面留下的痕迹。评论家说莫奈真正想画的不是草堆本身而是光线的瞬间感受。但有趣的是如果没有那堆实实在在的干草作为锚点那些光影变化就失去了意义。我对 Swift 这十年的观感很接近这个状态。Swift 1.0 时代我们还在跟 optional 和访问控制搏斗后来陆续等来了 protocol extension、泛型改进、SwiftUI、async/await、宏、严格并发检查。每一年都有新东西落到 Xcode 的 Release Notes 里给人的感觉就是光影一直在变快到你追都追不过来。可如果拉开时间线你会发现那些真正支撑起这个语言的底层主张——内存安全、值语义、协议导向、编译期兜底——几乎没有动摇过。写周报最怕的就是变成新特性新闻联播今天发一个新框架明天出一个新 API每篇都是哇这个东西好厉害读者看完却没有留下任何结构性的认识。所以我一直在想怎么给技术内容加一个锚点。到了第 121 期我觉得干草堆这个意象特别适合用来做这一期的结构一方面说说那些正在变化的前沿另一方面也回头看看那些长期稳定的内核两边对照着看很多困惑反而能解开。1.2 这一轮光影WWDC 之后值得盯紧的几条线每年六月的 WWDC 都是 Swift 生态一次集中的打光时刻。今年观察下来我自己的判断是对大部分 Swift 开发者来说真正值得盯紧的不是某个炫酷的新组件而是下面这几条线和你的日常工作直接相关。第一条是并发模型的全面收紧。Swift 5.5 引入 async/await 是个分水岭后面几个版本一直在完善 actor、Sendable、全局隔离这些概念。到 Swift 6 这一代严格并发检查变成默认行为数据竞争会在编译期被大量拦截这等于把并发安全从运行时祈祷变成了编译器把关。对这个变化不同项目的感受会差很多新项目从第一天就用 Swift 6 语言模式顺风顺水老项目则要面对一大堆历史代码的迁移问题这个过程非常考验对隔离边界的理解。第二条是 SwiftUI 状态管理的收敛。从早期一堆属性包装器到后来引入宏苹果把 UI 框架的数据流往更少关键字、更多编译器保证的方向推。你看 State、Observable、Environment 这些 API 的演进本质上都在做同一件事让状态来源单一、变化路径清晰。第三条是 Swift 跨平台边界的扩大服务端、嵌入式、命令行工具甚至 Windows 和 Linux 上的支持都在往前推进。对苹果生态内的开发者来说这可能暂时不是主线但它意味着同一套语言能力可以复用到更多场景对长期职业规划是个值得留意的变量。后面几节我会把这三条线展开分别讲清楚变的到底是什么、不变的是什么、以及实际操作中怎么应对。2. 光影一Swift 并发改造怎么一步步迁移过去2.1 从 async/await 到严格并发变的其实是表达方式Swift 在 async/await 出现之前异步代码主要靠闭包回调来传递结果。逻辑简单的时候问题不大一旦出现先请求 A再用 A 的结果请求 B最后把 B 的结果刷新到界面这种链条代码就会迅速膨胀成嵌套的回调结构也就是大家常说的回调地狱。我自己维护过一个比较老的项目一个登录流程里嵌套了四层闭包改一个参数要顺着闭包一路看下去眼睛都能看花。async/await 解决的是表达方式的问题。它把异步流程重新拉平让代码看上去像同步代码一样顺。你可以这样写func loadUserAndPosts() async throws - (User, [Post]) { let user try await fetchUser() let posts try await fetchPosts(for: user.id) return (user, posts) }对比之前嵌套闭包的写法这段代码的阅读顺序就是执行顺序调试时打断点也直观得多。但我要特别强调一点async/await 只是表面上把代码拉平了底层的并发执行模型并没有变简单。线程切换、任务调度、数据竞争这些老问题依然存在只是换了一种形式出现。到了 Swift 6变化进一步加深。编译器默认启用严格并发检查它会盯着你的类型和数据流不允许你在没做隔离的情况下跨 actor 随意访问可变状态。换句话说以前一些跑起来好像没问题、偶尔崩溃一次的并发 bug现在直接变成了编译错误。这在一开始会让很多老项目炸出一堆报错但从长期的工程质量角度看这个演进方向是对的——它把安全从运行时前移到了编译期这正是 Swift安全优先哲学在并发领域的延伸。2.2 迁移实录把回调式网络模块改写成 Swift Concurrency理论说再多不如动手改一段代码。我最近把一个旧项目里的网络层模块从闭包回调改造成 Swift Concurrency整个过程很典型我拆开来一步步说。改造之前模块长这样enum APIClient { static func fetchUser(id: Int, completion: escaping (ResultUser, Error) - Void) { let url URL(string: https://api.example.com/users/\(id))! URLSession.shared.dataTask(with: url) { data, response, error in if let error error { completion(.failure(error)) return } guard let data data else { completion(.failure(APIError.emptyData)) return } do { let user try JSONDecoder().decode(User.self, from: data) completion(.success(user)) } catch { completion(.failure(error)) } }.resume() } }这段代码的问题是调用者必须手动管理闭包的生命周期还要处理两层嵌套的错误分支。如果调用方在闭包里又发起下一个网络请求回调嵌套就开始了。第一步先把它改成 async 版本接口保持不变enum APIClient { static func fetchUser(id: Int) async throws - User { let url URL(string: https://api.example.com/users/\(id))! let (data, response) try await URLSession.shared.data(from: url) guard let http response as? HTTPURLResponse, (200..300).contains(http.statusCode) else { throw APIError.badStatus } return try JSONDecoder().decode(User.self, from: data) } }这里我做了一个很小的设计取舍把状态码检查也纳入进来而不是像旧版本那样只判断 data 是否为空。原因是 async/await 版本代码更短有空间把网络层的通用校验逻辑统一收敛省得每个调用方自己判断 response。第二步关注并发安全。如果 APIClient 需要维护缓存或者请求去重逻辑那它必须是一个有状态的类型。这种情况下我会把它声明成 actor让编译器来保证对内部状态访问的隔离actor APIClient { private var cache: [Int: User] [:] func fetchUser(id: Int) async throws - User { if let cached cache[id] { return cached } let url URL(string: https://api.example.com/users/\(id))! let (data, response) try await URLSession.shared.data(from: url) guard let http response as? HTTPURLResponse, (200..300).contains(http.statusCode) else { throw APIError.badStatus } let user try JSONDecoder().decode(User.self, from: data) cache[id] user return user } }actor 的好处是它的隔离规则由编译器强制你在 actor 外部访问 cache 是直接编译报错的必须通过 actor 内部方法才能操作。这就是隔离边界的实体化——不再靠程序员自觉而是靠类型系统兜底。第三步处理调用方。原来的 ViewModel 里调用代码是APIClient.fetchUser(id: id) { result in switch result { case .success(let user): self.user user case .failure(let error): self.errorMessage error.localizedDescription } }改成 async/await 后是do { let user try await client.fetchUser(id: id) self.user user } catch { self.errorMessage error.localizedDescription }改动量不大但阅读体验差距明显。调用方的 await 会自动把后续代码调度到合适的执行上下文UI 层不需要手动切回主线程这背后是 MainActor 在起作用。如果你在 View 或 ViewModel 上标注了 MainActor那么 await 回来之后的代码默认就在主线程执行。2.3 迁移过程中的心态与边界别拿着一把小刀去砍大树迁移最忌讳的就是一把梭。async/await 有传染性一个函数变成 async调用它的函数大概率也要变成 async一路往上传染可能波及整个模块。如果你的项目有几万行老代码想在一个版本里全部切成 Swift 6 语言模式很容易出现几百个编译错误然后整个团队陷入漫长的修错循环。我的做法是分批切。新写的代码直接上 async/await老代码在维护到的时候顺手改不为了迁移而迁移。同时打开 Xcode 里的并发检查增量开关从 Swift 5 语言模式逐步过渡到 Swift 6 语言模式每切一个模块就把这个模块里的编译错误消干净再切下一个。这里有一个技术细节值得单独说并发迁移不是简单的把 callback 改成 async。真正难的是找出哪些类型需要在 actor 之间传递、哪些类型需要标 Sendable。比如你有一个自定义的模型类里面有几个 var 属性它在两个 actor 之间传的时候编译器会报错提示类型不符合 Sendable。这种报错不是让你删掉代码而是在提醒你这个类型的设计在并发环境下不安全。如果这个模型确实需要跨隔离域传递可以考虑把它改成 struct或者把属性改成不可变let再或者用 actor 封装可变部分。我把这个决策过程总结成一张表方便你对照场景推荐做法原因纯数据结构无共享可变状态用 struct let 属性自动满足 Sendable值拷贝天然并发安全有共享可变状态且跨任务访问用 actor 封装编译器强制隔离避免数据竞争旧代码太多暂时无法整体迁移保持 Swift 5 语言模式新代码用 async渐进改造控制风险回调与 async 必须混用用 withCheckedThrowingContinuation 桥接保留现有接口逐步替换调用方实战下来我最大的体会是迁移不是重写是厘清边界。你越清楚每个类型的状态归属、每个对象在哪个隔离域里存活迁移就越顺。与其抱怨编译器报错多不如把它当成一次免费的并发审查。3. 光影二SwiftUI 状态管理从属性包装器到宏的转身3.1 状态管理为什么是 SwiftUI 项目最容易被绕晕的地方SwiftUI 上手容易但状态管理一直是争议最多的区域。早期版本里一个简单的数据模型可能要同时用到 State、ObservedObject、StateObject、EnvironmentObject 好几种属性包装器配合 ObservableObject 协议和 Published 属性还要引入 Combine 框架。对一个新手来说光搞清楚什么时候该用哪个就够喝一壶的。我见过不少团队项目状态管理方案五花八门有的页面用 StateObject有的用 ObservedObject还有的干脆把数据全放全局单例里最后的结果就是 bug 出现时没有任何规律排查像大海捞针。这不是开发者不努力而是旧方案本身太容易出错了属性包装器太多职责边界模糊中间还有 Combine 的订阅生命周期在暗处作祟。苹果后续的解决思路很清晰引入宏Macro把观察这个能力从 Combine 手里拿回来变成编译器层面的自动生成。这就是 Observable 宏登场的背景。它让一个普通类具备可观察能力却不强制你继承 ObservableObject也不需要 Published 来标记属性定义变得干净很多。这个变化的本质是减少模板代码、统一数据流变更可观察这件事由编译器兜底人脑需要记的东西就少了。3.2 用 Observable 重构一个计时器页面的完整过程拿一个简单的计时器页面举例。旧方案下的代码是这样final class TimerModel: ObservableObject { Published var count: Int 0 private var timer: Timer? func start() { timer Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.count 1 } } } struct TimerView: View { StateObject private var model TimerModel() var body: some View { Text(\(model.count)) Button(开始) { model.start() } } }旧方案问题在哪第一TimerModel 必须继承 ObservableObject引入 Combine 的发布语义。第二StateObject 和 ObservedObject 的区别需要自己理解透彻搞错生命周期就等着看界面不刷新的诡异问题。用 Observable 重构之后Observable final class TimerModel { var count: Int 0 private var timer: Timer? func start() { timer Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.count 1 } } } struct TimerView: View { State private var model TimerModel() var body: some View { Text(\(model.count)) Button(开始) { model.start() } } }改动看起来不大但语义清晰了很多。State 直接持有模型对象模型里的属性默认就被观察不需要 Published类也不需要继承 ObservableObject不依赖 Combine。代码量减少心智负担降低出错的概率自然也就小了。这里有个细节想提醒你Observable 配合 State 使用时如果你在 body 里写 Text((model.count))SwiftUI 会正确追踪 count 属性的读取count 变化时刷新视图但如果你把 model.count 传给一个子视图的普通属性子视图不会自动更新因为它没有形成依赖追踪。实际遇到这种情况可以考虑用 Bindable 获取绑定或者把数据访问放在需要响应的视图层级里。3.3 原地踩坑预览不刷新、环境丢失、导航状态重置的排查思路SwiftUI 状态管理的坑很多都是结构对了但细节没对导致的。我整理了几个高频问题每个都来自真实项目的现场排查。第一个是预览不刷新。最常见的原因是 Preview 里没有正确注入环境依赖。比如你的视图在 body 里用了 EnvironmentObject但 Preview 代码里忘了加 .environmentObject(...)那预览就会崩溃或者白屏。用 Observable 之后环境注入的方式也要相应调整在视图上通过 .environment(_:model) 传入。很多新项目踩坑是因为网上旧教程还在教 EnvironmentObject 的用法代码混用之后预览就炸了。第二个是导航状态与数据不同步。NavigationStack 的 path 如果绑定到一个数组这个数组的生命周期必须跟视图保持一致。我见过有人把 path 放在普通 let 属性里结果导航栈怎么点都不推进也有人把 path 放到全局单例里结果切到后台再回来导航栈被系统还原单例里的数组却已经变了。排这类问题第一件事是确认 path 的真实持有者是谁是不是跟视图生命周期对齐。第三个是旧组件和新观察宏混用带来的警告。一个 Observable 对象如果同时又被包装成 ObservableObject 去兼容旧代码Xcode 会在运行时输出警告告诉你这种用法会退化为旧的观察机制。这种兼容性看似方便长期看会掩盖问题建议尽快统一到新方案。我实操中还有一个体会状态管理出问题的时候不要急着在视图层打补丁先画一下数据从哪来、单向流怎么走。大部分卡壳都是因为有人在某个中间层直接修改了不属于自己的状态破坏了单向数据流。这个原则从 SwiftUI 1.0 到现在的版本都没变过属于干草堆级别的稳定性。4. 光影三Swift 的领地正在扩大但脚手架没变4.1 从苹果生态走向服务端与嵌入式Swift 在撕掉iOS 专属标签Swift 刚出来那几年大家默认它是写 iOS App 的语言出了苹果生态基本没人关心。这几年情况明显变了。Swift 在 Linux 和 Windows 上有了官方支持服务端框架比如 Vapor、Hummingbird也越来越成熟一些公司已经用 Swift 写后端 API跟客户端共享一套模型代码。今年的新动态里Swift 在嵌入式方向也有布局这意味着将来你车里、家电里可能也有跑着 Swift 的逻辑。这件事对普通开发者的影响有多大我的看法是不必急着换赛道但值得留意。服务端和嵌入式对语言特性的需求反过来会推动 Swift 核心能力的进化。比如更严格的并发检查、更小的运行时、更高效的编译优化这些改进最终都会让苹果生态内的 App 开发受益。所以这是一场光影的变化Swift 的适用场景在变多但它解决问题的基本范式——类型安全、协议抽象、值语义——没有变脚手架还是那一套。如果你对服务端感兴趣建议用一个小项目切入比如写一个返回 JSON 的 REST API跑在本机 8080 端口用 SwiftPM 管理依赖。这个过程能帮你理解 Swift 在非 UI 场景下的表现也会让你对并发、生命周期这些概念有更深的理解。4.2 SwiftPM、宏与调试体验工具链进步让变更有底气Swift 生态能持续变化另一个不可忽视的支撑是工具链本身的进步。SwiftPM 从早期只能做包管理慢慢变成了支持多平台构建、支持资源文件、支持宏编译的一体化工具。最典型的是宏Macro这个特性的引入它允许你在编译期生成代码像 Observable 这种能力就是建立在宏基础上的。宏的价值在于把一些之前运行时才发生的动态行为提前到编译期去做生成代码变得更可预测、更好调试。我自己用过几个第三方宏之后最大的感受就是能省则省但不能滥用。宏可以隐藏很多样板代码但也可能让代码变得难追踪调试时你会看到展开后的代码但源文件里只是一行 SomeMacro。所以在项目里引入宏之前我会确认这个宏是否开源、社区维护是否活跃、展开后的代码是否可控。这些判断标准跟当年选择第三方库一样属于稳定的决策框架。调试体验这些年也在肉眼可见地变好。并发调试现在能看到任务树Data Race 检测工具也比以前直观崩溃日志符号化越来越自动化。工具链做好了一件事让开发者把精力花在业务逻辑上而不是环境配置上。这个方向上的变化属于默默发光的部分不容易被看到但收益是实实在在的。5. 干草堆Swift 十年不变的那些设计内核5.1 值语义、内存安全、协议导向为什么这些才是语言的根聊完变化的部分我想回到最核心的问题Swift 那些不变的东西到底是什么为什么我认为它们比新特性更重要第一是值语义。Swift 里 struct 是默认推荐的数据类型拷贝发生时是值拷贝每个实例独立。这意味着数据在传递过程中不会因为某处修改而影响其他地方大大减少了隐式共享状态带来的 bug。在并发环境里值语义本身就是一种安全你把一个 struct 传给一个 actoractor 拿到的是独立副本两边互不干扰。这一点从 Swift 1.0 到 6.x 始终坚持是语言最底层的地基。第二是内存安全。Swift 通过 ARC 管理引用计数同时用编译期检查来避免悬垂指针和数据竞争。可选值Optional强制你处理没有值的情况从语法层面避免了空指针崩溃。这些设计让 Swift 在保持高性能的同时依然能给你一种编译器在替我盯着的安全感。第三是协议导向编程。Swift 的协议和协议扩展让抽象变得灵活你可以为任意类型增加行为而不是非得套一层继承关系。这个特性让 Swift 既能写高性能的底层代码又能构建出干净的领域抽象。十年里 API 层面的变化没有动摇这些根基。所以我给新人的建议是追新特性可以但先花时间理解这几个稳定内核它们才是你写任何 Swift 代码都通用的底层能力。学 Observable 之前先搞懂单向数据流用 actor 之前先理解隔离写泛型之前先琢磨协议。内核稳定了外面的光影怎么变你都不慌。5.2 记录者的执念第 121 期周报我为什么还在写写周报这件事看似是输出对我来说其实是一种输入。每周为了写一期周报我需要浏览大量的提案、博客、更新说明筛选出真正有价值的信息再用自己的话整理出来。这个过程强迫我把不熟悉的知识搞懂而不是流于转发收藏。到了第 121 期我越来越相信一个朴素的道理技术内容最重要的不是新而是结构。一条新 API 的热度可能两周就过去但一个帮你理解 API 背后设计思路的框架可以长期伴你成长。这也是我在周报里尽量多写为什么而不是只写是什么的原因。对想持续成长的开发者我的建议很简单找到一个你愿意长期输出的主题把它当成你的干草堆。可以是对某个框架的源码解读可以是系列化的踩坑总结甚至可以是每周的学习笔记。不管外部怎么变这个稳定的输出节奏会让你保持沉静也会在某个时刻变成别人认识你的方式。第 121 期写到这里我又一次确认了这个选择的价值。6. 周报读者高频问题速查并发、SwiftUI 与学习节奏6.1 并发改造中最容易被卡住的 3 个问题最近评论区、私信里问到最多的并发问题集中在三个地方。第一个是 actor 方法重入导致的看似死锁。注意actor 的隔离不是绝对的串行阻塞调用 actor 方法时如果方法内部 await 了一个不会立即完成的异步操作actor 是允许其他任务进来执行其他方法的这就是所谓的重入。很多人第一次遇到时以为死锁了其实只是执行顺序和直觉不一样。排查方法是仔细看 actor 方法内部 await 前后的状态变化别假设它整个过程独占 actor。第二个是 Sendable 报错泛滥。从 Swift 5 切到 Swift 6 语言模式时最常见的一百种报错里至少有八十种是关于 Sendable 的。我的处理顺序是先处理值类型跨隔离域传递的场景把这些类型改成 struct 或加 let 约束再处理单例/全局状态的场景给它们增加 MainActor 或放入 actor最后处理闭包捕获可变状态的场景用 [weak self] 或显式标记 Sendable。按这个顺序走报错量会明显下降。第三个是 async 桥接旧回调时的坑。用 withCheckedThrowingContinuation 时如果旧的回调可能在多个时机被调用或者可能永远不调用你的 continuation 就会出问题。标准做法是确保一个回调只被调用一次并且设置超时保护。不少崩溃都藏在这种回调不保证调用一次的老接口里。6.2 SwiftUI 状态问题排查速查表我把最近项目里遇到的、以及周报读者反馈的典型 SwiftUI 状态问题整理成一张速查表遇到问题先对号入座现象可能原因排查与解决思路预览不刷新或崩溃Preview 缺少环境依赖或数据对象检查 .environment 注入确认 Observable 用 .environment(_:model) 传递列表删除后 UI 卡顿或错乱数据模型缺少稳定唯一标识给 List 提供稳定 id推荐用 Identifiable 协议避免用数组下标做 id导航栈状态重置NavigationStack path 与视图生命周期不一致确认 path 由 State 或等价状态持有避免放在全局单例里修改数据但视图不更新观察链断裂子视图读取数据时未形成依赖把数据访问移到需要刷新的视图层级或用 Bindable 建立绑定旧组件混用出现运行时警告Observable 对象被包装成 ObservableObject统一迁移到新观察宏消除兼容层异步更新 UI 崩溃在非主线程修改了视图状态给 ViewModel 标注 MainActor或把 UI 更新包进 MainActor.run这张表不能覆盖所有情况但覆盖了大概八成我见过的SwiftUI 状态灵异事件。记住一个底层原则状态出了问题先找数据的源头在哪、谁持有它的生命周期大多数问题都出在这两层没有对齐。6.3 新特性学不过来我的筛选原则每年 WWDC 之后都会被新特性轰炸一轮总有读者问那么多东西到底该学什么我的筛选原则大概是这样的。先看它解决什么问题。一个特性如果真的解决了某个长期困扰你的痛点它才值得你花整块时间研究。比如严格并发检查如果你总为数据竞争头疼那它就是命中要害的特性如果一个新组件只是换了个皮肤那收藏一下看个热闹就行。再看它是否改变你的姿势。一个特性如果是新瓶装旧酒学习成本低设计惯性还在如果它改变了你的编码方式——像 SwiftUI 刚出来那样、像宏刚出来那样——那你需要认真跟进因为它会直接影响未来的项目架构。最后用 mini demo 验证而不是收藏即学会。我每研究一个新特性都会建一个最小的测试工程跑通之后再写几十行笔记最后挑有价值的部分写进周报。这套流程看起来慢但长期积累下来知识是成体系的比三天打鱼两天晒网地刷教程有效得多。最近重看莫奈晚年的干草堆系列我注意到他越画越不像草堆而是逐渐变成了光影本身的形状。这个变化挺有意思——当你对一个事物足够熟悉之后你看到的就不再是它的表面而是它和周围环境互相作用的那些关系。我对 Swift 大概也有点类似的感觉新框架也好新语法也好看多了之后我更关心的反而是它们和原有设计哲学之间的呼应。技术的风往哪吹我们控制不了但自己手里的那个干草堆——不管是多年练出的代码手感还是持续输出的习惯——是能守住的。希望这一期周报能让你在追赶变化的同时也别忘了回头看看自己已经打牢的那些东西。