Swift并发编程:async/await实战踩坑实录

后端修仙人
2025-12-28 19:46
阅读 4205

上周五晚上十点半,办公室只剩我和隔壁组的运维小哥。他蹲在机房门口啃煎饼果子,我盯着Xcode里一堆DispatchQueue回调嵌套发呆——这代码简直像意大利面拌了胶水,又乱又黏。就在产品经理第N次催问“用户头像加载能不能再快点”时,我突然想起WWDC21那个被我们全组忽略的Swift并发模型。第二天顶着黑眼圈翻文档,才惊觉:这玩意儿简直是为游戏服务端转iOS开发的我量身定制的!

作为在网易做了三年服务端的老兵(主要搞后端逻辑和网络协议),去年调岗到新成立的移动端小组时,最不适应的就是iOS那套异步回调地狱。写惯了Go的goroutine和Java的CompletableFuture,看到Objective-C时代遗留的completion handler嵌套三层以上就想砸键盘。好在Swift 5.5带来的async/await终于让iOS开发有了现代语言的样子。

被面试题逼出来的学习契机

事情要从上个月说起。组里来了个实习生,拿着LeetCode刷题准备秋招。有天他神秘兮兮地问我:“哥,Swift怎么处理异步操作?能像JavaScript那样用async/await吗?” 我当时正忙着修一个因线程竞争导致的游戏道具丢失bug(别问,问就是没加锁),随口回了句“iOS哪有那么高级”。结果第二天晨会,技术总监老王直接甩出一道面试题挑战:

“实现一个带缓存的图片加载器,要求:1) 网络请求失败自动重试3次 2) 相同URL的请求要复用结果 3) 主线程不能阻塞”

看着实习生憋红的脸,我默默打开了Apple官方文档。这一看不得了——Swift的并发模型居然和JS的Promise有异曲同工之妙,但更安全!特别是Task和Actor的设计,完美解决了我在服务端最头疼的数据竞争问题。

从回调地狱到结构化并发

先说说传统写法有多反人类。假设我们要加载用户头像并更新UI:

// 地狱模式(别学!)
NetworkManager.shared.fetchImage(url: avatarURL) { image, error in
    guard let image = image else {
        // 处理错误...这里可能又要嵌套
        return
    }
    DispatchQueue.main.async {
        self.imageView.image = image
        // 如果还要加载背景图?再嵌套一层!
    }
}

这种代码在我刚接手的项目里随处可见,测试同学每次提bug都说“偶现图片不显示”,八成是主线程切换时机出了问题。而用async/await重构后:

// 天堂模式(真香!)
@MainActor
func loadAvatar() async {
    do {
        let image = try await NetworkManager.shared.fetchImage(url: avatarURL)
        imageView.image = image // 自动在主线程执行!
    } catch {
        handleError(error)
    }
}

关键点在于@MainActor这个属性包装器——它保证了函数体内所有代码都在主线程执行,再也不用担心跨线程访问UI控件被Crash Reporter追着打。这设计思路和我们服务端用Actor模型隔离状态简直一模一样!

实战:打造高可靠图片加载器

回到面试题挑战,我花了三天时间用Swift并发模型重写了图片加载模块。核心思路分三步走:

第一步:基础网络请求封装

// NetworkManager.swift
class NetworkManager {
    func fetchImage(url: URL) async throws -> UIImage {
        let (data, _) = try await URLSession.shared.data(from: url)
        guard let image = UIImage(data: data) else {
            throw ImageError.invalidData
        }
        return image
    }
}

注意这里直接抛异常而不是回调,配合try await使用清爽得不行。不过第一次上线就翻车了——测试同学反馈弱网环境下App卡死。原来默认的URLSession超时太短,赶紧加上配置:

private lazy var session: URLSession = {
    var config = URLSessionConfiguration.default
    config.timeoutIntervalForRequest = 10 // 关键!
    return URLSession(configuration: config)
}()

第二步:实现智能重试机制

重试逻辑用递归最直观:

func fetchWithRetry(url: URL, maxRetries: Int = 3) async throws -> UIImage {
    do {
        return try await fetchImage(url: url)
    } catch {
        if maxRetries > 0 {
            print("重试剩余次数: \(maxRetries)")
            return try await fetchWithRetry(url: url, maxRetries: maxRetries - 1)
        }
        throw error // 抛出最终错误
    }
}

但要注意避免无限递归!有次我手滑把maxRetries - 1写成maxRetries + 1,模拟器直接内存爆炸——还好是在开发阶段,不然上架审核肯定被拒(App Store对崩溃率可是零容忍)。

第三步:请求去重与缓存

这才是最棘手的部分。多个页面同时请求同一张头像时,必须合并请求。最初想用字典存Task,但发现Swift的Task没有类似JS Promise的“pending状态共享”机制。灵机一动想到用AsyncStream:

actor ImageCache {
    private var inflightTasks: [URL: Task<UIImage, Error>] = [:]
    
    func loadImage(url: URL) async throws -> UIImage {
        // 检查是否有进行中的任务
        if let task = inflightTasks[url] {
            return try await task.value
        }
        
        // 创建新任务
        let task = Task {
            defer { 
                Task { @MainActor in
                    inflightTasks[url] = nil // 注意Actor隔离
                }
            }
            return try await fetchWithRetry(url: url)
        }
        inflightTasks[url] = task
        return try await task.value
    }
}

这里用了actor来保证缓存字典的线程安全——这正是我在服务端天天念叨的“状态隔离”思想!不过要注意defer块里修改actor属性必须用Task { @MainActor in ... },否则编译器会报错。第一次写的时候被这个坑得够呛,Xcode提示“Mutating actor state from non-isolated context”时我还以为是语法错误。

JS开发者特别注意事项

组里有个前端转iOS的同事总把Swift并发和JS搞混,这里划重点:

特性 JavaScript (ES2017+) Swift (5.5+)
异常处理 try/catch try/catch
并发模型 单线程事件循环 结构化并发(Task/Actor)
状态共享 需手动加锁 Actor自动隔离
取消机制 AbortController Task.cancel()
主线程保障 无(需手动dispatch) @MainActor自动处理

最大的区别在于Swift的并发是编译期保障的。比如你试图在非MainActor里改UI,Xcode直接标红报错;而在JS里可能运行时才崩。这对从服务端转过来的我简直是福音——再也不用担心半夜被PagerDuty叫醒修数据竞争bug了!

上线后的血泪教训

本以为万事大吉,结果上架前两天遇到审核被拒。苹果审核员反馈:“App在后台时触发网络请求”。排查发现是某个定时任务没检查应用状态:

// 错误示范!
Task {
    let data = try await fetchData() // 后台也会执行
}

// 正确姿势
Task {
    guard UIApplication.shared.applicationState == .active else { 
        return // 后台直接退出
    }
    let data = try await fetchData()
}

另外要注意iOS 13以下系统不支持async/await!我们游戏最低支持iOS 12,最后不得不保留两套网络层。建议新项目直接放弃老系统,毕竟现在iOS 15+覆盖率都90%了。

给同行的建议

如果你和我一样从服务端转移动端,或者正被回调地狱折磨,强烈建议拥抱Swift并发:

  1. 从简单场景入手:先改造工具类方法(比如文件读写),别一上来就重构核心模块
  2. 善用Xcode诊断工具:打开Thread Sanitizer能提前发现数据竞争
  3. 别滥用@MainActor:只在真正需要主线程的地方用,否则会拖慢性能
  4. 测试弱网环境:用Network Link Conditioner模拟2G网络,很多并发bug只在弱网出现

最后说个好消息:自从用上async/await,我们项目的Crash率下降了40%,连测试同学都夸代码好测了(虽然她还是经常找我修bug)。上周团建吃饭时,产品经理举杯说:“下次需求能不能周三给?周五上线那种...” 我默默掏出手机看了眼日历——还好这次不用再熬夜改回调了!

作者注:本文代码已在GitHub开源(链接略),包含完整的单元测试用例。作为Vim党,我甚至写了套async/await的snippets,用:AsyncFetch就能生成模板代码——谁说老派程序员不能拥抱新技术?

评论 0

最热最新
暂无评论
后端修仙人Lv.1
0
影响力
0
文章
0
粉丝