Swift中的异步等待

1,047 阅读7分钟

今年,WWDC带来了大量的新功能和更新。也许其中最令人期待的是通过使用async/await语法引入新的并发系统。这是对我们编写异步代码方式的巨大改进。

Async/Await之前

想象一下,我们正在为一家杂货店开发一个应用程序,我们想显示其产品列表。我们可能会有这样的东西。

func fetchProducts(_ completion: @escaping([Product]) -> Void) {...}

var products = [Product]()
fetchProducts { [weak self] products in
    guard let strongSelf = self else { return }
    strongSelf.products.append(contentsOf: products)
}

一个相当标准和著名的使用完成块的代码。现在,假设这家杂货店偶尔会有一些产品的优惠活动(例如,"拿两份,付一份")。而且,我们想持有一个包含这些优惠的列表。让我们调整我们的代码,创建一个新的函数来检索一个带有促销文本的字符串,给定一个特定的产品。

func fetchProducts(_ completion: @escaping([Product]) -> Void) {...}
func getOffer(for product: Int, @escaping(String) -> Void) {...}

typealias ProductOffer = (productId: Int, offer: String)
var products = [Product]()
var offers = [ProductOffer]()

fetchProducts { [weak self] products in
    guard let strongSelf = self else { return }

    for product in products {
        strongSelf.products.append(product)

        getOffer(for: product.id) { [weak self] offerText in
            guard let strongSelf = self else { return }
            let productOffer = ProductOffer(productId: product.id, offer: offerText)
            strongSelf.offers.append(productOffer)
        }
    }
}

对于一个简单的功能,我们只有两个嵌套的闭包,你可以看到我们的代码开始变得有点混乱了。

异步/等待

从Swift 5.5开始,我们可以开始使用async/await函数来编写异步代码,而无需使用完成处理程序来返回值。相反,我们被允许直接返回返回对象中的值。

要将一个函数标记为异步,我们只需要在返回类型前加上关键字async

func fetchProducts() async -> [Product] {...}
func getOffer(for product: Int) async -> String {...}

这就更容易和简单了,但最好的部分来自于调用者一方。当我们想使用一个标记为异步的函数的结果时,我们需要确保其执行已经完成。为了实现这一点,我们需要在函数调用前面写上await关键字。通过这样做,当前的执行将被暂停,直到结果可供使用。

let products = await fetchProducts()

for product in products {
    let offerText = await getOffer(for: product.id)

    if !offerText.isEmpty {
        let productOffer = ProductOffer(productId: product.id, offer: offerText)
        offers.append(productOffer)
    }
}

不过,如果我们想在执行异步函数的同时执行其他任务,我们应该把关键字async放在变量(或let)声明前面。在这种情况下,需要将await关键字放在我们要访问异步函数结果的变量(或let)前面。

async let products = fetchProducts()
...
// Do some work
...
print(await products)

并行异步函数

现在想象一下,在我们的应用程序中,我们想按类别获取产品--例如,只获取冷冻产品。让我们继续下去,对我们的代码进行调整。

enum ProductCategory {
    case frozen
    case meat
    case vegetables
    ...
}

func fetchProducts(fromCategory category: ProductCategory) async -> [Product] {...}

let frozenProducts = await fetchProducts(fromCategory: .frozen)
let meatProducts = await fetchProducts(fromCategory: .meat)
let vegetablesProducts = await fetchProducts(fromCategory: .vegetals)

这是可以的,但代码将以串行模式运行,这意味着我们将不会开始获取肉类产品,直到冷冻产品被检索出来。蔬菜也一样。记住,如果我们想暂停我们的执行,直到函数完成其工作,我们就写await关键字。然而,在这个特殊的情况下,我们可以同时开始获取三个类别的产品,并行运行。

为了实现这一点,我们需要在var(或let)声明前面写上async关键字,并在我们想使用它时使用await关键字。

async let frozenProducts = await fetchProducts(fromCategory: .frozen)
async let meatProducts = await fetchProducts(fromCategory: .meat)
async let vegetablesProducts = await fetchProducts(fromCategory: .vegetables) 

....

let products = await [frozenProducts, meatProducts, vegetablesProducts]

错误处理程序

我们的获取函数可能会出现一些错误,使其无法返回预期的数据值。我们如何在我们的async/await上下文中处理这个问题?

我们有几个选择。第一个是返回众所周知的结果对象。

func fetchProducts() async -> Result<[Product], Error> {...}

let result = try await fetchProducts()
switch result {
    case .success(let products):
        // Handle success
    case .failure(let error):
        // Handle error
}

另一个是使用try/catch方法。

func fetchProducts() async throws -> [Product[ {...}
...
do {
    let products = try await fetchProducts()
} catch {
    // Handle the error
}

使用Result类型时,我们的主要好处是改进我们的完成处理程序。除此之外,我们在使用结果的时候得到了更简洁的代码,能够在成功和失败的情况下进行切换。

另一方面,抛出错误的使用在函数的定义中增加了额外的可读性,因为我们只需要放上函数将返回的结果类型。错误处理被隐藏在函数的实现中。

异步序列

假设我们有一个要求,即从某个.csv文件中加载一个产品列表。一个传统的方法是一次性加载所有的行,然后开始处理它们。但是,如果我们想在得到其中一行时就开始做一些工作,会发生什么情况?我们现在可以使用异步序列来做这件事。

let url = URL(string: "http://www.grocery.com/products.csv")
for try await in url.lines {
    // Do some work
}

使用这个新功能也允许我们以比以前更简单的方式来处理这个特殊的情况(读取文件)。你可以查看这个stackoverflow 讨论,看看我们是如何做到这一点的,并看看这种方法比以前的方法有什么优势。

异步/等待与完成处理程序

正如我们在前面的章节中所看到的,使用async/await语法与使用完成块相比,有很多改进。让我们做一个简单的总结。

优点

  • 避免了嵌套闭包的厄运金字塔问题
  • 减少代码
  • 更容易阅读
  • 安全。使用async/await,结果是有保证的,而完成块可能被调用,也可能不被调用。

劣势

  • 它只适用于Swift 5.5和iOS 15以上版本。

代理人

看看下面这个例子,只是一个简单的订单类,我们将在其中添加产品并最终结账。

class Order {
    
    var products = [Product]()
    var finalPrice = 0

    func addProduct(_ product: Product) {
        products.append(product)
        finalPrice += product.price
    }
}

如果我们在一个单线程应用程序中,这段代码就很好。但如果我们有多个线程可以访问我们订单的最终价格,会发生什么呢?

  1. 我们在产品列表中,将一些特定的产品添加到我们的订单中。该应用程序将调用addProduct函数。
  2. 该产品被添加到我们订单的产品列表中
  3. 在最终价格被更新之前,用户试图结账。
  4. 应用程序将读取我们订单的最终价格。
  5. addProduct函数完成并更新最终价格。但是用户已经结账了,而且支付的费用比他们应该支付的少。

这个问题被称为 数据赛跑当某些特定的资源可以从应用程序的代码的多个部分访问时。

同样在Swift 5.5和iOS 15中引入的Actor为我们解决了这个问题。Actor基本上就像一个类,但有几个关键的区别,使它们成为线程安全的

  • 每次只允许一个任务访问其状态

  • 存储的属性和函数只能从Actor外部访问,如果操作是异步进行的。

  • 存储的属性不能从Actor外部写入。

在缺点方面。

  • 行为体不支持继承

你可以把Actor看成是类似于[semaphores](en.wikipedia.org/wiki/Semaph…

要创建一个,我们只需要使用actor关键字。

actor Order {
    
    var products = [Product]()
    var finalPrice = 0

    func addProduct(_ product: Product) {
        products.append(product)
        finalPrice += product.price
    }
}

而且我们可以使用与结构和类相同的初始化语法来创建一个实例。如果我们想访问最终价格,我们必须使用关键字await(因为在actor的范围之外,我们只允许异步访问属性)。

print(await order.finalPrice)

结论

毫无疑问,async/await为编写异步代码带来了一种更简单的方式,不再需要使用完成块。此外,如果我们的应用程序开始扩展,我们会得到更多可读和灵活的代码。

然而,对于我们大多数人来说,最低的iOS部署目标将是一个入门障碍,除非你从头开始一个项目,在这种情况下,强烈建议等到iOS 15 + Xcode 13 + Swift 5.5的正式发布,以充分利用新的并发系统的优势。