Swift并发编程怎么用才不翻车?

404收集者
2026-01-03 09:37
阅读 742

上个月实验室接了个外包项目,帮一家创业公司做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岗的必问题。分享几个高频题:

  1. async函数一定在后台线程执行吗?
    不是!async只表示函数可能挂起,不指定执行队列。实际运行在哪取决于调用上下文。想确保在主线程要用@MainActor

  2. Task和Operation有什么区别?
    Task是Swift原生的轻量级并发单元,基于结构化并发;Operation是Objective-C时代的产物,功能更重(支持依赖、优先级等),但和现代Swift风格不太搭。

  3. 如何测试异步代码?
    XCTest现在支持async测试方法:

    func testUserProfileLoading() async throws {
        let service = MockUserService()
        let profile = try await service.fetchProfile()
        XCTAssertEqual(profile.name, "张三")
    }
    

    别再用XCTestExpectation了,那都是上个时代的眼泪。

  4. 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝