迁往 Swift 5.5 Concurrency 之路:重构方式的选择

最近 Xcode 13 RC 已经正式发布了,Swift 5.5 Concurrency 可以说是这次最大的更新。本篇文章总结一个我在一个老项目上以重构的方式迁移使用 Concurrency 特性时遇到的一个问题,相信也将会是很多人也会遇到的情况,所以写文章记之。

我能用上 Concurrency 吗?

目前为止,大多数的开发者可能觉得 Swift Concurrency 这个只能部署在 iOS 15 上的特性,暂时用不起来,两年后再说。不过,Apple 可能很快就会将 Concurrency 的 Back-Deploy 完成,让大家可以用在 iOS 13 和 macOS 10.15 及以上的系统。可以见我昨天发在推上的、最近的一次关于这个工作的跟进:https://twitter.com/tualatrix/status/1436342262510669828

根据目前的猜测,大概会在 Xcode 13.1(Swift 版本号约 5.5.1),我们就可以在 iOS 13 及更新的系统上用上 Concurrency 了。期待这件事情变成现实吧!

以重构方式迁移至 Concurrency

正如标题所说,本篇我将讲述的是一个以重构的方式迁移至 Concurrency 的过程。Xcode 目前提供了三种重构方式。

Xcode 13 Concurrency Refactor

除非你的项目是 iOS 15 Only,不然大家都会用后面这两种:

  • Add Async Alternative:这种重构方式是把老的方法进行转换,即把传统的 completionHandler 式回调转换成 async 代码,并且将老的 completionHandler 方法包装至 async 版本,然后标记成 deprecated,以提示在接下去的维护工作中不断去改掉。
  • Add Async Wrapper:这种重构方式是不修改老的方法,而是新建一个调用老方法的 async 代码。

比如,这是一个典型的例子,这里用延迟执行一秒然后 Dispatch 至 Main Queue 来模拟下载图片并回到主线程的过程。

func loadImage(completionHandler: (@escaping () -> Void)) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        DispatchQueue.main.async {
            completionHandler()
        }
    }
}

在用 Xcode 的 Refactor 工具以 Add Async Alternative 方式重构后,就会变成这样:

@available(*, deprecated, message: "Prefer async alternative instead")
func loadImage(completionHandler: (@escaping () -> Void)) {
    Task {
        await loadImage()
        completionHandler()
    }
}

func loadImage() async {
    return await withCheckedContinuation({ continuation in
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            DispatchQueue.main.async {
                continuation.resume(returning: ())
            }
        }
    })
}

而用 Add Async Wrapper 就会变成这样,可以发现老方法没有改动:

func loadImage(completionHandler: (@escaping () -> Void)) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        DispatchQueue.main.async {
            completionHandler()
        }
    }
}

func loadImage() async {
    return await withCheckedContinuation { continuation in
        loadImage() {
            continuation.resume(returning: ())
        }
    }
}

乍看两种方式没什么不同,只是 Alternative 方式增加了一个 deprecated 的警告而已。其实有很大不同。因为我要彻底改掉老代码,所以我选了这个方式,直到踩到了一个坑。

Async Alternative 至 Concurrency 的问题

在实际运行时,Async Alternative 重构模式对传统的 completionHandler 调用是存在问题的。

在下面这段代码中,你会发现,假如对传统调用不做任何修改,而这段代码又操作了 UI 的话,就会有异常,因为它并不跑在 Main Thread 上。

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        loadImage {
            NSLog("Load image finished, thread is main: \(Thread.isMainThread)")
            // Load image finished, thread is main: false
            // do some UI work 💥
        }

        Task {
            await loadImage()
            NSLog("Load image finished, thread is main: \(Thread.isMainThread)")
            // Load image finished, thread is main: true
            // do some UI Work ✅
        }
    }

}

这里你会有一个疑问,loadImage async 版本的核心代码不是 Dispatch 至 Main Queue 了吗?为什么传统调用变成不是 Main Thread 了。传统方法因为封装了 async 方式(Task),它的执行上下文已经变成了 Concurrency 那一套了,并不完全等效于之前的代码。这就是 Swift Concurrency 用 Async Alternative 模式重构存在的一个「坑」了。

所以依然要对老的代码进行一些修改,在 completionHandler() 外包装一个 DispatchQueue.main.async,然后就会跑在主线程了。

小结

这里可以得出的结论是,如果你想要保证老代码运行没有问题,同时项目要继续支持旧系统,只是自己想尝鲜一下玩玩 async,那么就用 Add Async Wrapper 的方式。

如果你想用上真正的 Concurrency 而不是封装老方法的那套,并且有计划项目会变成 iOS 15 Only,想通过 Deprecated Warning 的方式 Push 自己尽快完成迁移, 那么就用 Add Async Alternative 模式。只是注意这个方式,会改变老方法的行为,一定要注意修改代码。

欢迎使用图拉鼎和他的团队开发的作品

效率控 - 聚合众多实用小工具

装机必备的高颜值工具箱,拥有超过 18 款工具,完成日常各类任务。支持 iPhone、iPad 和 macOS。

1 Comment

yue

Xcode Version 13.0 (13A233)
2021-10-19 15:14:12.874875+0800 AsyncTest[12190:2844336] Load image finished, thread is main: true
2021-10-19 15:14:12.972536+0800 AsyncTest[12190:2844336] Load image finished, thread is main: true

Leave a Comment