Swift并发编程怎么用才不翻车?
上个月实验室接了个外包项目,帮一家创业公司做iOS端的健康管理App。需求文档写得花里胡哨,什么“实时同步云端数据”、“毫秒级响应”、“丝滑用户体验”,结果开发时间只给了三周。我一边在Mac上敲Swift代码,一边还得抽空刷LeetCode准备秋招——毕竟谁不想跳槽去大厂拿高薪呢?(手动狗头)
更惨的是,这项目之前是外包给另一家团队做的,交接过来的代码里还混着不少DispatchQueue和回调地狱。产品经理上周五下午突然跑来问:“能不能把数据加载做得快一点?用户反馈卡成PPT。”我当时差点把咖啡喷到键盘上。
没办法,只能硬着头皮重构。正好最近在研究Rust的async模型,想着Apple自家的async/await也该学起来了。于是花了几天时间啃文档、看WWDC视频、踩坑填坑,总算把核心模块重写了一遍。今天就来聊聊这次实战中的一些血泪经验。
为什么不用GCD了?
先说说背景。老代码里到处都是这样的写法:
DispatchQueue.global().async {
let data = fetchDataFromNetwork()
DispatchQueue.main.async {
self.updateUI(with: data)
}
}
乍一看没问题,但当业务逻辑复杂起来——比如要串行调用三个API,每个依赖前一个的结果,还要处理错误重试——代码就变成了嵌套地狱。调试起来更是噩梦:断点打进去发现线程跳来跳去,Xcode的堆栈信息看得人眼花。
而且GCD没法取消任务。有一次测试同学反馈:“切页面的时候还在加载上一页的数据,内存爆了!”我翻了半小时代码才发现是某个网络请求没被正确取消。
这时候async/await的优势就出来了:
- 结构化并发:任务天然可取消
- 线性代码流:像写同步代码一样写异步
- 自动线程管理:不用手动切回main queue
当然,也不是银弹。比如在性能极度敏感的场景(比如每秒处理上千次回调的音视频处理),GCD可能还是更可控。但对大多数App来说,async/await的开发效率提升远大于那点微乎其微的性能损失。
async/await实战三板斧
第一板斧:基础网络请求
先看最简单的场景——从服务器拉取用户资料:
// 定义异步函数
func fetchUserProfile() async throws -> UserProfile {
let url = URL(string: "https://api.example.com/profile")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(UserProfile.self, from: data)
}
// 调用时
Task {
do {
let profile = try await fetchUserProfile()
DispatchQueue.main.async {
self.updateProfileView(profile)
}
} catch {
print("加载失败: \(error)")
}
}
注意这里有个坑:虽然async函数内部会自动处理线程切换,但更新UI仍然需要显式切回主线程!Apple文档里反复强调这点,但很多人(包括我)第一次写的时候都忘了,结果在真机上跑出诡异的UI错乱。
后来发现可以用@MainActor来简化:
@MainActor
func updateProfileView(_ profile: UserProfile) {
// 自动在主线程执行
nameLabel.text = profile.name
}
这样调用时就不用再包一层DispatchQueue.main.async了,清爽很多。
第二板斧:并发多个任务
产品有个需求:首页要同时显示用户信息、通知列表、待办事项。老代码是三个串行请求,总耗时1.5秒左右。改成并发后:
Task {
async let profile = fetchUserProfile()
async let notifications = fetchNotifications()
async let todos = fetchTodos()
// 等所有任务完成
let (user, notes, tasks) = try await (profile, notifications, todos)
await MainActor.run {
renderHomePage(user: user, notifications: notes, todos: tasks)
}
}
实测加载时间降到600ms以内。关键就是async let这个语法糖——它会自动启动并发任务,比手动创建多个Task再用group.waitAll()简洁多了。
不过要注意:如果某个任务失败,其他任务不会自动取消。对于敏感操作(比如支付),可能需要用withTaskGroup手动控制:
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await fetchPaymentInfo() }
group.addTask { try await validateUser() }
for try await result in group {
// 处理结果
}
}
第三板斧:任务取消与超时
最头疼的是处理用户快速切换页面的场景。以前用GCD得自己维护一堆CancellationToken,现在直接利用Task的取消机制:
class ProfileViewController: UIViewController {
private var loadDataTask: Task<Void, Never>?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadDataTask?.cancel() // 取消上次未完成的任务
loadDataTask = Task {
do {
let data = try await fetchUserData()
await updateUI(data)
} catch is CancellationError {
// 被取消时不处理
} catch {
showError(error)
}
}
}
}
配合超时就更稳了:
func fetchWithTimeout() async throws -> Data {
return try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
try await fetchFromNetwork()
}
group.addTask {
try await Task.sleep(nanoseconds: 5_000_000_000) // 5秒
throw TimeoutError()
}
let result = try await group.next()
group.cancelAll() // 取消其他任务
return result
}
}
上线前压测时发现,这种写法在弱网环境下能有效防止页面假死。
面试官最爱问的几个问题
最近面了几家公司(没错,就是在骑驴找马),发现async/await成了iOS岗的必问题。分享几个高频题:
async函数一定在后台线程执行吗?
不是!async只表示函数可能挂起,不指定执行队列。实际运行在哪取决于调用上下文。想确保在主线程要用@MainActor。Task和Operation有什么区别?
Task是Swift原生的轻量级并发单元,基于结构化并发;Operation是Objective-C时代的产物,功能更重(支持依赖、优先级等),但和现代Swift风格不太搭。如何测试异步代码?
XCTest现在支持async测试方法:func testUserProfileLoading() async throws { let service = MockUserService() let profile = try await service.fetchProfile() XCTAssertEqual(profile.name, "张三") }别再用
XCTestExpectation了,那都是上个时代的眼泪。async/await能完全替代Combine吗?
不能。对于复杂的事件流处理(比如搜索框防抖+合并多次请求),Combine的声明式风格还是更合适。但简单场景用async/await代码更直观。
和其他语言的并发模型对比
作为同时在折腾Rust的人,忍不住做个横向对比:
| 特性 | Swift async/await | Rust async/await | JavaScript Promise |
|---|---|---|---|
| 内存安全 | ARC自动管理 | 所有权系统保证 | GC回收 |
| 取消机制 | Task.cancel() | Drop future | AbortController |
| 错误处理 | throws/try | Result<T, E> | try/catch 或 .catch() |
| 并发原语 | Task, TaskGroup | tokio::spawn, join! | Promise.all, async* |
| 调试体验 | Xcode集成良好 | 需要tokio-console | 浏览器DevTools |
最让我惊喜的是Swift的结构化并发设计——子任务会自动随父任务取消,避免了资源泄漏。这点比JS的Promise优雅太多(还记得那些忘记处理reject导致的内存泄漏吗?)。
不过Rust的零成本抽象确实狠,在嵌入式场景下Swift还是太“重”了。但对iOS开发来说,Swift这套已经足够好用。
App Store审核踩过的坑
别以为写了async/await就能高枕无忧。上个月提交审核时被拒了一次,理由是“应用在后台时仍在进行网络活动”。查了半天才发现:
// 错误示范:没有限制后台行为
Task {
let data = try await fetchLargeFile() // 用户切到后台还在下载
saveToDisk(data)
}
解决方案是在SceneDelegate里监听状态:
func sceneDidEnterBackground(_ scene: UIScene) {
// 取消所有非关键任务
backgroundTask?.cancel()
}
或者用ProcessInfo检测:
func shouldContinueInBackground() -> Bool {
return ProcessInfo.processInfo.isiOSAppOnMac ||
UIApplication.shared.applicationState == .active
}
另外提醒一句:不要在async函数里直接调用阻塞API(比如sleep())。这会占用宝贵的并发线程,导致其他任务饿死。一定要用Task.sleep()这种挂起操作。
写在最后
折腾完这个项目,最大的感受是:Apple终于把iOS的并发模型带进了现代编程的殿堂。虽然初期学习曲线有点陡(特别是Actor和Sendable那些概念),但一旦上手,代码的可读性和健壮性提升是肉眼可见的。
现在我的简历上又能多写一行“精通Swift并发编程”了(笑)。不过说实话,比起炫技,更重要的是理解背后的并发模型——毕竟面试官随便问个“Task局部变量的生命周期”,就能筛掉一半只会复制粘贴教程的人。
对了,如果你也在准备跳槽,建议把async/await和Combine的适用场景搞清楚。最近几家大厂的面试题都在考这个权衡。至于我?继续刷题去了,LeetCode第876题还没AC呢……
(完)

评论 0