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

注:此文已经失效,在最新的 Xcode 13.2 中,已经无法重现案例,因而这只是 Swift 5.5 Concurrency 早期的 Bug。

最近 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 模式。只是注意这个方式,会改变老方法的行为,一定要注意修改代码。

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

One Switch - 多功能开关工具

常驻 macOS 菜单栏的开关工具,可以快速开关 AirPods、睡眠模式、切换黑暗模式等。

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