Swift 5.5 有哪些新功能? SwiftUI 教程

Swift 5.5 有哪些新功能?

距离 WWDC21 不到两周,这意味着第一个 Swift 5.5 测试版即将发布,它带来了大量改进——async/await、actor、投掷属性等等。第一次问“ Swift 5.5 中没有什么新东西”可能会更容易,因为变化太大了。

在本文中,我将通过代码示例逐一介绍每个更改,以便您了解每个更改在实践中是如何工作的。在我们开始之前,有两个重要的警告:

  • 这是第一次有如此多的 Swift Evolution 提案如此紧密地相互关联,所以尽管我试图将这些更改组织成一个有凝聚力的流程,但只有在您阅读了几个提案后,并发工作的某些部分才真正有意义。
  • 其中一些主要部分仍在经历 Swift Evolution,尽管它们目前在最新的 Swift 5.5 快照中可用,但它们可能会在WWDC21之前甚至之后进一步发展。随着事情的解决,这篇文章几乎肯定会发生变化。
  • 提示:如果您想自己尝试代码示例,也可以将其下载为 Xcode playground。

Async/await 异步/等待

SE-0296在 Swift 中引入了异步 (async) 函数,让我们可以运行复杂的异步代码,就像它是同步的一样。这分两步完成:使用 newasync关键字标记异步函数,然后使用await关键字调用它们,类似于 C# 和 JavaScript 等其他语言。

要了解 async/await 如何帮助语言,看看我们之前如何解决相同的问题会很有帮助。完成处理程序通常用于 Swift 代码中,以允许我们在函数返回后发回值,但正如您将看到的,它们的语法很复杂。

例如,如果我们想编写从服务器获取 100,000 条天气记录的代码,对它们进行处理以计算一段时间内的平均温度,然后将结果平均值上传回服务器,我们可能会这样写:

func fetchWeatherHistory(completion: @escaping ([Double]) -> Void) {
    // Complex networking code here; we'll just send back 100,000 random temperatures
    DispatchQueue.global().async {
        let results = (1...100_000).map { _ in Double.random(in: -10...30) }
        completion(results)
    }
}

func calculateAverageTemperature(for records: [Double], completion: @escaping (Double) -> Void) {
    // Sum our array then divide by the array size
    DispatchQueue.global().async {
        let total = records.reduce(0, +)
        let average = total / Double(records.count)
        completion(average)
    }
}

func upload(result: Double, completion: @escaping (String) -> Void) {
    // More complex networking code; we'll just send back "OK"
    DispatchQueue.global().async {
        completion("OK")
    }
}

我用假值替换了实际的网络代码,因为网络部分在这里不相关。重要的是,这些函数中的每一个都可能需要一些时间来运行,因此与其阻塞函数的执行并直接返回一个值,不如使用完成闭包仅在我们准备好时才将某些内容发送回去。

当涉及到使用该代码时,我们需要在链中一一调用它们,为每个链提供完成闭包以继续链,如下所示:

fetchWeatherHistory { records in
    calculateAverageTemperature(for: records) { average in
        upload(result: average) { response in
            print("Server response: \(response)")
        }
    }
}

希望你能看到这种方法的问题:

  • 这些函数可能会多次调用它们的完成处理程序,或者完全忘记调用它。
  • 参数语法@escaping (String) -> Void可能难以阅读。
  • 在调用站点,我们最终得到了一个所谓的厄运金字塔,每个完成处理程序的代码越来越缩进。
  • 在 Swift 5.0 添加Result类型之前,使用完成处理程序发回错误变得更加困难。

从 Swift 5.5 开始,我们现在可以通过将函数标记为异步返回值而不是依赖完成处理程序来清理我们的函数,如下所示:

func fetchWeatherHistory() async -> [Double] {
    (1...100_000).map { _ in Double.random(in: -10...30) }
}

func calculateAverageTemperature(for records: [Double]) async -> Double {
    let total = records.reduce(0, +)
    let average = total / Double(records.count)
    return average
}

func upload(result: Double) async -> String {
    "OK"
}

这已经删除了很多关于异步返回值的语法,但在调用站点它甚至更清晰:

func processWeather() async {
    let records = await fetchWeatherHistory()
    let average = await calculateAverageTemperature(for: records)
    let response = await upload(result: average)
    print("Server response: \(response)")
}

正如你所看到的,所有的闭包和缩进都消失了,有时被称为“直线代码”——除了await关键字之外,它看起来就像同步代码。

关于异步函数的工作方式,有一些简单明了的特定规则:

  • 同步函数不能简单地直接调用异步函数——这是没有意义的,所以 Swift 会抛出一个错误。
  • 异步函数可以调用其他异步函数,但如果需要,它们也可以调用常规同步函数。
  • 如果您有可以以相同方式调用的异步和同步函数,Swift 将优先选择与您当前上下文匹配的任何一个——如果调用站点当前是异步的,那么 Swift 将调用异步函数,否则它将调用同步函数。
    最后一点很重要,因为它允许库作者提供他们代码的同步和异步版本,而无需专门命名异步函数。

添加async/await非常适合与try/一起使用catch,这意味着异步函数和初始化程序可以在需要时抛出错误。这里唯一的条件是 Swift 对关键字强制执行特定顺序,并且该顺序在调用站点和函数之间颠倒。

例如,我们可能有一些函数试图从服务器获取多个用户,并将它们保存到磁盘,这两种方法都可能因抛出错误而失败:

enum UserError: Error {
    case invalidCount, dataTooLong
}

func fetchUsers(count: Int) async throws -> [String] {
    if count > 3 {
        // Don't attempt to fetch too many users
        throw UserError.invalidCount
    }

    // Complex networking code here; we'll just send back up to `count` users
    return Array(["Antoni", "Karamo", "Tan"].prefix(count))
}

func save(users: [String]) async throws -> String {
    let savedUsers = users.joined(separator: ",")

    if savedUsers.count > 32 {
        throw UserError.dataTooLong
    } else {
        // Actual saving code would go here
        return "Saved \(savedUsers)!"
    }
}

如您所见,这两个函数都被标记了async throws——它们是异步函数,它们可能会抛出错误。

在调用它们时,关键字的顺序被翻转为try await而不是await try,如下所示:

func updateUsers() async {
    do {
        let users = try await fetchUsers(count: 3)
        let result = try await save(users: users)
        print(result)
    } catch {
        print("Oops!")
    }
}

因此,函数定义中的“异步、抛出”,但调用点处的“抛出、异步”——将其视为展开堆栈。不仅try await比更自然地读一点await try,但它也更能反映所发生的情况:我们正在等待一些工作来完成的,它的时候不会完全可能落得投掷。

Swift 本身现在有了 async/await Result,Swift 5.0 中引入的类型变得不那么重要了,因为它的主要好处之一是改进了完成处理程序。这并不意味着Result没有用,因为它仍然是存储操作结果以供以后评估的最佳方式。

重要提示:使函数异步并不意味着它会神奇地与其他代码同时运行,这意味着除非您另行指定,否则调用多个异步函数仍将按顺序运行它们。

async到目前为止,您看到的所有函数都依次被其他async函数调用,这是有意的:这个 Swift Evolution 提案本身并没有提供任何从同步上下文运行异步代码的方法。相反,这个功能是在一个单独的结构化并发提案中定义的,尽管希望我们也能看到 Foundation 的一些重大更新。

Async/await: sequences 异步/等待:序列

SE-0298引入了使用新AsyncSequence协议在异步值序列上循环的能力。当您想要在值可用时按顺序处理它们而不是一次性预先计算所有值时,这对于某些地方很有帮助——可能是因为它们需要时间来计算,或者因为它们尚不可用。

UsingAsyncSequence几乎与 using 相同Sequence,不同的是你的类型应该符合AsyncSequenceand AsyncIterator,并且你的next()方法应该被标记async。当您的序列结束时,请确保您nil从发送回来next(),就像使用 一样Sequence。

例如,我们可以创建一个DoubleGenerator从 1 开始并在每次调用时将其数字加倍的序列:

struct DoubleGenerator: AsyncSequence {
    typealias Element = Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 1

        mutating func next() async -> Int? {
            defer { current &*= 2 }

            if current < 0 {
                return nil
            } else {
                return current
            }
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator()
    }
}

提示:如果您只是从该代码中出现的任何地方删除“async”,您就可以有效地Sequence做完全相同的事情——这就是这两者的相似之处。

一旦你有了你的异步序列,你可以通过for await在异步上下文中使用它来循环它的值,如下所示:

func printAllDoubles() async {
    for await number in DoubleGenerator() {
        print(number)
    }
}

该AsyncSequence协议还提供了各种常见方法的默认实现,如map(),compactMap(),allSatisfy(),等等。例如,我们可以检查我们的生成器是否输出一个特定的数字,如下所示:

func containsExactNumber() async {
    let doubles = DoubleGenerator()
    let match = await doubles.contains(16_777_216)
    print(match)
}

有效的只读属性

SE-0310升级了 Swift 的只读属性,以单独或一起支持async和throws关键字,使它们更加灵活。

为了证明这一点,我们可以创建一个BundleFile结构体,尝试加载应用程序资源包中的文件内容。因为文件可能不存在,可能存在但由于某种原因无法读取,或者可能可读但太大而需要时间阅读,我们可以将contents属性标记为async throws如下所示:

enum FileError: Error {
    case missing, unreadable
}

struct BundleFile {
    let filename: String

    var contents: String {
        get async throws {
            guard let url = Bundle.main.url(forResource: filename, withExtension: nil) else {
                throw FileError.missing
            }

            do {
                return try String(contentsOf: url)
            } catch {
                throw FileError.unreadable
            }
        }
    }
}

因为contents既是异步又是抛出,我们try await在尝试读取时必须使用:

func printHighScores() async throws {
    let file = BundleFile(filename: "highscores")
    try await print(file.contents)
}

结构化并发 Structured concurrency

SE-0304引入了一系列在 Swift 中执行、取消和监控并发操作的方法,并建立在 async/await 和 async 序列引入的工作之上。

为了更简单的演示目的,这里有几个我们可以使用的示例函数——一个异步函数来模拟获取特定位置的一定数量的天气读数,以及一个同步函数来计算哪个数字位于斐波那契的特定位置顺序:

enum LocationError: Error {
    case unknown
}

func getWeatherReadings(for location: String) async throws -> [Double] {
    switch location {
    case "London":
        return (1...100).map { _ in Double.random(in: 6...26) }
    case "Rome":
        return (1...100).map { _ in Double.random(in: 10...32) }
    case "San Francisco":
        return (1...100).map { _ in Double.random(in: 12...20) }
    default:
        throw LocationError.unknown
    }
}

func fibonacci(of number: Int) -> Int {
    var first = 0
    var second = 1

    for _ in 0..<number {
        let previous = first
        first = second
        second = previous + first
    }

    return first
}

结构化并发引入的最简单的异步方法是能够使用该@main属性立即进入异步上下文,只需用 标记main()方法即可完成async,如下所示:

@main
struct Main {
    static func main() async throws {
        let readings = try await getWeatherReadings(for: "London")
        print("Readings are: \(readings)")
    }
}

提示:在发布之前,也应该可以直接在 main.swift 中运行异步代码,而不使用该@main属性。

结构化并发引入的主要变化得到了两种新类型Task和 的支持TaskGroup,它们允许我们单独或以协调的方式运行并发操作。

在最简单的形式中,您可以通过创建一个新Task对象并将要运行的操作传递给它来开始并发工作。这将立即开始在后台线程上运行,您可以使用它await来等待其完成值返回。

因此,我们可能会fibonacci(of:)在后台线程上多次调用,以计算序列中的前 50 个数字:

func printFibonacciSequence() async {
    let task1 = Task { () -> [Int] in
        var numbers = [Int]()

        for i in 0..<50 {
            let result = fibonacci(of: i)
            numbers.append(result)
        }

        return numbers
    }

    let result1 = await task1.value
    print("The first 50 numbers in the Fibonacci sequence are: \(result1)")
}

如您所见,我需要明确地编写,Task { () -> [Int] in以便 Swift 了解任务将返回,但如果您的任务代码更简单,则不需要。例如,我们可以这样写并得到完全相同的结果:

let task1 = Task {
    (0..<50).map(fibonacci)
}

同样,任务在创建后立即开始运行,并且该printFibonacciSequence()函数将继续在计算斐波那契数列时所在的线程上运行。

提示:我们任务的操作是一个非转义闭包,因为任务会立即运行它而不是存储它以备后用,这意味着如果您Task在类或结构中使用self,则不需要使用它来访问属性或方法。

在读取完成的数字时,await task1.value将确保执行printFibonacciSequence()暂停,直到任务的输出准备就绪,此时它将返回。如果您实际上并不关心任务返回什么——如果您只想让代码开始运行并随时完成——您不需要将任务存储在任何地方。

对于抛出未捕获错误的任务操作,读取任务的value属性也会自动抛出错误。因此,我们可以编写一个同时执行两项工作的函数,然后等待它们都完成:

func runMultipleCalculations() async throws {
    let task1 = Task {
        (0..<50).map(fibonacci)
    }

    let task2 = Task {
        try await getWeatherReadings(for: "Rome")
    }

    let result1 = await task1.value
    let result2 = try await task2.value
    print("The first 50 numbers in the Fibonacci sequence are: \(result1)")
    print("Rome weather readings are: \(result2)")
}

斯威夫特给我们提供了内置的任务优先级high,default,low,和background。上面的代码没有专门设置一个,所以它会得到default,但我们可以说一些类似的东西Task(priority: .high)来定制它。如果您只是为 Apple 平台编写代码,您还可以使用更熟悉的优先级userInitiatedinplace of high 和utilityinplace of low,但您无法访问,userInteractive因为那是为主线程保留的。

除了运行操作之外,Task还为我们提供了一些静态方法来控制我们的代码运行方式:

  • 调用Task.sleep()将导致当前任务休眠特定的纳秒数。在出现更好的情况之前,这意味着写入 1_000_000_000 表示 1 秒。
  • 调用Task.checkCancellation()将检查是否有人通过调用其cancel()方法要求取消此任务,如果是,则抛出一个CancellationError.
  • 调用Task.yield()将暂停当前任务片刻,以便为可能正在等待的任何任务留出一些时间,如果您在循环中进行密集工作,这一点尤其重要。
    您可以在以下代码示例中看到睡眠和取消,它使任务进入睡眠状态一秒钟,然后在完成之前取消它:
func cancelSleepingTask() async {
    let task = Task { () -> String in
        print("Starting")
        await Task.sleep(1_000_000_000)
        try Task.checkCancellation()
        return "Done"
    }

    // The task has started, but we'll cancel it while it sleeps
    task.cancel()

    do {
        let result = try await task.value
        print("Result: \(result)")
    } catch {
        print("Task was cancelled.")
    }
}

在该代码中,Task.checkCancellation()将意识到任务已被取消并立即抛出CancellationError,但在我们尝试读取task.value.

提示:使用task.result以获得Result含有任务的成功和失败值值。例如,在上面的代码中,我们会得到一个Result<String, Error>. 这并不会要求一个try电话,因为你仍然需要处理的成功或失败的案例。

对于更复杂的工作,您应该创建任务组- 一起工作以产生完成值的任务集合。

为了最大限度地降低程序员以危险方式使用任务组的风险,他们没有简单的公共初始化程序。相反,任务组是使用以下函数创建的:使用withTaskGroup()您想要完成的工作主体调用它,您将被传递到要处理的任务组实例中。进入组后,您可以使用该async()方法添加工作,它将立即开始执行。

重要提示:您不应该尝试将该任务组复制到主体之外withTaskGroup()——编译器无法阻止您,但您只会给自己制造问题。

要查看任务组如何工作的简单示例 – 以及演示他们如何安排操作的重要点,请尝试以下操作:

func printMessage() async {
    let string = await withTaskGroup(of: String.self) { group -> String in
        group.async { "Hello" }
        group.async { "From" }
        group.async { "A" }
        group.async { "Task" }
        group.async { "Group" }

        var collected = [String]()

        for await value in group {
            collected.append(value)
        }

        return collected.joined(separator: " ")
    }

    print(string)
}

这创建了一个任务组,旨在生成一个完成的字符串,然后使用async()任务组的方法将几个闭包排队。这些闭包中的每一个都返回一个字符串,然后将其收集到一个字符串数组中,然后再连接到一个字符串中并返回进行打印。

提示:任务组中的所有任务都必须返回相同类型的数据,因此对于复杂的工作,您可能会发现自己需要返回具有关联值的枚举,以便准确获得您想要的数据。在单独的 Async Let Bindings 提案中引入了一个更简单的替代方案。

每次调用都async()可以是您喜欢的任何类型的函数,只要它产生一个字符串即可。然而,尽管任务组在返回之前会自动等待所有子任务完成,但是当该代码运行时,它会有点折腾它将打印的内容,因为子任务可以按任何顺序完成——我们很可能会得到“例如,您好来自任务组 A”,因为我们是“您好来自任务组”。

如果您的任务组正在执行可能会抛出的代码,您可以直接在组内处理错误,或者让它在组外冒泡到那里处理。后一个选项是使用不同的函数处理的withThrowingTaskGroup(),try如果您没有捕获抛出的所有错误,则必须调用它。

例如,下一个代码示例计算单个组中多个位置的天气读数,然后返回所有位置的总体平均值:

func printAllWeatherReadings() async {
    do {
        print("Calculating average weather…")

        let result = try await withThrowingTaskGroup(of: [Double].self) { group -> String in
            group.async {
                try await getWeatherReadings(for: "London")
            }

            group.async {
                try await getWeatherReadings(for: "Rome")
            }

            group.async {
                try await getWeatherReadings(for: "San Francisco")
            }

            // Convert our array of arrays into a single array of doubles
            let allValues = try await group.reduce([], +)

            // Calculate the mean average of all our doubles
            let average = allValues.reduce(0, +) / Double(allValues.count)
            return "Overall average temperature is \(average)"
        }

        print("Done! \(result)")
    } catch {
        print("Error calculating data.")
    }
}

在这种情况下,async()除了传入的位置字符串之外,对 的每个调用都是相同的,因此您可以使用类似于在循环中for location in ["London", "Rome", "San Francisco"] {调用async()的方法。

任务组有一种cancelAll()方法可以取消组内的任何任务,但使用async()after 将继续向组添加工作。作为替代方案,asyncUnlessCancelled()如果组已被取消,您可以使用跳过添加工作 – 检查其返回的布尔值以查看工作是否已成功添加。

async let bindings

SE-0317引入了使用简单语法创建和等待子任务的能力async let。这对于您处理异构结果类型的任务组的替代方案特别有用 – 即,如果您希望组中的任务返回不同类型的数据。

为了演示这一点,我们可以创建一个结构体,该结构体具有来自三个不同异步函数的三种不同类型的属性:

struct UserData {
    let username: String
    let friends: [String]
    let highScores: [Int]
}

func getUser() async -> String {
    "Taylor Swift"
}

func getHighScores() async -> [Int] {
    [42, 23, 16, 15, 8, 4]
}

func getFriends() async -> [String] {
    ["Eric", "Maeve", "Otis"]
}

如果我们想User从所有这三个值中创建一个实例,async let是最简单的方法——它同时运行每个函数,等待这三个值都完成,然后使用它们来创建我们的对象。

这是它的外观:

func printUserDetails() async {
    async let username = getUser()
    async let scores = getHighScores()
    async let friends = getFriends()

    let user = await UserData(name: username, friends: friends, highScores: scores)
    print("Hello, my name is \(user.name), and I have \(user.friends.count) friends!")
}

重要提示:只有async let当您已经在异步上下文中时才能使用,并且如果您没有显式地等待结果,async letSwift 将在退出其作用域时隐式等待它。

当投掷功能的工作,你没有必要使用try带有async let-可以自动推回到你等待的结果。同样,await关键字也暗示,因此不是打字try await someFunction()用的async let,你可以随便写someFunction()。

为了证明这一点,我们可以编写一个异步函数来递归计算斐波那契数列中的数字。这种方法非常天真,因为没有记忆,我们只是在重复大量的工作,所以为了避免导致一切停止,我们将把输入范围限制在 0 到 22 之间:

enum NumberError: Error {
    case outOfRange
}

func fibonacci(of number: Int) async throws -> Int {
    if number < 0 || number > 22 {
        throw NumberError.outOfRange
    }

    if number < 2 { return number }
    async let first = fibonacci(of: number - 2)
    async let second = fibonacci(of: number - 1)
    return try await first + second
}

在该代码中,对 的递归调用fibonacci(of:)是隐式的try await fibonacci(of:),但我们可以不使用它们并在下一行直接处理它们。

Sendable 和 @Sendable 闭包

SE-0302添加了对“可发送”数据的支持,这些数据可以安全地传输到另一个线程。这是通过新Sendable协议和@Sendable功能属性来实现的。

跨线程发送许多事情本质上是安全的:

  • 所有SWIFT的核心价值类型,包括Bool,Int,String,和类似的。
  • 可选,其中包装的数据是值类型。
  • 包含值类型(例如Array或 )的标准库集合Dictionary<Int, String>。
  • 元素都是值类型的元组。
  • 元类型,例如String.self.
    这些已更新以符合Sendable协议。

至于自定义类型,这取决于你在做什么:

  • Actor 会自动遵守,Sendable因为它们在内部处理同步。
  • Sendable如果您定义的自定义结构和枚举仅包含也符合 的值,则它们也将自动符合Sendable,类似于Codable工作方式。
  • 自定义类可以符合,Sendable只要它们继承自NSObject或根本不继承,所有属性都是常量并且它们本身符合Sendable,并且它们被标记为final停止进一步继承。

Swift 允许我们使用@Sendable函数或闭包的属性来将它们标记为并发工作,并且会强制执行各种规则来阻止我们对自己开枪。例如,我们传入Task初始化器的操作被标记为@Sendable,这意味着这种代码是允许的,因为捕获的值Task是一个常量

func printScore() async { 
    let score = 1

    Task { print(score) }
    Task { print(score) }
}

但是,该代码将不会被允许的,如果score是一个变量,因为它可以通过的任务之一,而另一个是改变其值来访问。

您可以使用 标记自己的函数和闭包@Sendable,这将对捕获的值强制执行类似的规则:

func runLater(_ function: @escaping @Sendable () -> Void) -> Void {
    DispatchQueue.global().asyncAfter(deadline: .now() + 3, execute: function)
}

发表回复