将async/await与其他Swift代码连接起来的方法

232 阅读6分钟

在今年的WWDC大会上,Swift 5.5的新并发功能套件无疑发挥了重要作用。特别是,新引入的async/await模式不仅可以在以Swift为重点的会议和公告中看到,而且在会议上公布的新API和功能中都可以看到。

虽然async/await很有可能成为未来在苹果平台上编写异步代码的主要方式,但就像所有重大的技术转变一样,我们还需要一段时间才能达到这个目标。因此,在这篇文章中,让我们探讨一些方法来 "弥补 "async/await的新世界和其他类型的异步Swift代码之间的差距。

什么是 async/await?

让我们先快速回顾一下async/await到底是什么。随着我对这种新模式以及它被整合到苹果SDK中的方式获得更多的实践经验,我将在未来的文章中详细介绍,但在最基本的层面上,async/await使我们能够用async 关键字来注释异步函数(或计算属性),这反过来要求我们在调用它们时使用await 关键字。在这一点上,系统会自动管理所有涉及到等待这种异步调用完成的复杂性,而不会阻断任何其他的、外部的代码执行。

例如,下面的DocumentLoader 有一个async-markedloadDocument 方法,它使用Foundation的URLSession API,通过等待从指定URL下载的数据来执行异步网络调用:

struct DocumentLoader {
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()

    func loadDocument(withID id: Document.ID) async throws -> Document {
        let url = urlForForLoadingDocument(withID: id)
        let (data, _) = try await urlSession.data(from: url)
        return try decoder.decode(Document.self, from: data)
    }

    ...
}

所以async-marked函数可以反过来调用其他异步函数,只需在这些调用前加上await 关键字。在这一点上,本地的执行将被暂停,直到那个嵌套的await 完成,这期间也不会阻挡任何其他代码的执行。

从同步上下文中调用异步函数

但问题是--我们如何从一个本身不是异步的上下文中调用一个async-标记的函数?例如,如果我们想在诸如基于UIKit的视图控制器中使用上述DocumentLoader 呢?这就是任务的作用。我们需要做的是将我们对loadDocument 的调用包裹在一个Task ,在其中我们可以执行我们的异步调用--像这样:

class DocumentViewController: UIViewController {
    private let documentID: Document.ID
    private let loader: DocumentLoader
    
    ...

    private func loadDocument() {
        Task {
            do {
                let document = try await loader.loadDocument(withID: documentID)
                display(document)
            } catch {
                display(error)
            }
        }
    }

    private func display(_ document: Document) {
        ...
    }

    private func display(_ error: Error) {
        ...
    }
}

上述模式真正巧妙的地方在于,我们现在可以使用 Swift 默认的do/try/catch 错误处理机制,即使是在执行异步调用时。我们也不再需要做任何形式的 "弱自舞 "以避免保留周期,我们甚至不需要在主队列上手动分配我们的UI更新,因为现在已经*由主角色*为我们处理了。

用async/await支持改造现有的API

接下来,让我们来看看如何走另一条路--即如何使我们现有的一些异步代码与新的async/await模式兼容。

作为一个例子,假设我们的应用程序包含以下CommentLoader 类型,让我们使用基于完成处理程序的API加载所有附加到给定文档的评论:

struct CommentLoader {
    ...

    func loadCommentsForDocument(
        withID id: Document.ID,
        then handler: @escaping (Result<[Comment], Error>) -> Void
    ) {
        ...
    }
}

最初,我们可能需要对上述API进行重大改变,以使其与异步/等待兼容,但实际上并非如此。我们真正要做的是使用新的withCheckedThrowingContinuation 函数,将对我们现有方法的调用包裹在一个async- 标记的版本中--就像这样:

extension CommentLoader {
    func loadCommentsForDocument(
        withID id: Document.ID
    ) async throws -> [Comment] {
        try await withCheckedThrowingContinuation { continuation in
            loadCommentsForDocument(withID: id) { result in
                switch result {
                case .success(let comments):
                    continuation.resume(returning: comments)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

请注意,传递到我们封装闭包中的continuation ,只能被调用一次。如果我们不小心调用了两次,就会导致一个致命的错误。在这种情况下,这种情况发生的可能性为零,因为我们的完成处理程序只被调用一次,但在使用这种技术时,这绝对是值得注意的事情。

有了上述内容,我们现在可以使用await 关键字轻松地调用我们的loadCommentsForDocument 方法,就像调用系统提供的异步API时一样。例如,这里我们可以更新我们的DocumentLoader ,现在可以自动获取它所加载的每个文档的评论。

struct DocumentLoader {
    var commentLoader: CommentLoader
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()

    func loadDocument(withID id: Document.ID) async throws -> Document {
        let url = urlForForLoadingDocument(withID: id)
        let (data, _) = try await urlSession.data(from: url)
        var document = try decoder.decode(Document.self, from: data)
        document.comments = try await commentLoader.loadCommentsForDocument(
            withID: id
        )
        return document
    }
}

async/await真正的好处是,即使我们增加了额外的、嵌套的调用,我们的代码也没有真正地增长多少复杂性。它看起来或多或少就像老式的同步代码,再加上一些await 的关键字。

适应单输出的Combine发布器

最后,让我们也来看看如何让某些由Combine驱动的代码也能兼容异步/等待。虽然 Swift 的新并发系统包括其他更类似于 "Combine "的方式,可以随着时间的推移发出动态值流(比如AsyncSequence ,以及即将推出的AsyncStream ),但如果我们想要做的只是等待来自 Combine 管道的单个异步结果,那么这里有一种方法可以让我们以一种相当轻量级的方式实现。

使用我们之前使用的基于continuation 的技术,让我们用一个singleResult 方法来扩展 Combine 的Publisher 协议,该方法将用给定发布者发出的第一个值来恢复我们的延续。我们还将使用 Swift 的闭合捕获机制来保留 Combine 订阅的AnyCancellable 实例,直到我们的操作完成--就像这样:

extension Publishers {
    struct MissingOutputError: Error {}
}

extension Publisher {
    func singleResult() async throws -> Output {
        var cancellable: AnyCancellable?
        var didReceiveValue = false

        return try await withCheckedThrowingContinuation { continuation in
            cancellable = sink(
                receiveCompletion: { completion in
                    switch completion {
                    case .failure(let error):
                        continuation.resume(throwing: error)
                    case .finished:
                        if !didReceiveValue {
                            continuation.resume(
                                throwing: Publishers.MissingOutputError()
                            )
                        }
                    }
                },
                receiveValue: { value in
                    guard !didReceiveValue else { return }

                    didReceiveValue = true
                    cancellable?.cancel()
                    continuation.resume(returning: value)
                }
            )
        }
    }
}

如果我们现在想象一下,我们之前使用的CommentLoader ,而是有一个由Combine驱动的API(而不是一个基于闭包的API),那么我们现在可以很容易地使用async/await来使用上述扩展来调用它:

struct CommentLoader {
    ...

    func loadCommentsForDocument(
        withID id: Document.ID
    ) -> AnyPublisher<[Comment], Error> {
        ...    
    }
}

...

let comments = try await loader
    .loadCommentsForDocument(withID: documentID)
    .singleResult()

当然,就像它的名字所暗示的那样,我们新的singleResult 方法将只返回一个给定的Combine发布器发出的第一个值,所以它应该只用于那些预计不会在一段时间内产生多个值的发布器(除非我们只对第一个值感兴趣)。

我们将在接下来的文章中仔细研究如何在Combine、async/await和Swift新并发系统的其他部分之间搭建桥梁。

总结

Async/await 为在 Swift 中编写异步代码提供了一种令人兴奋的新方法,并且很可能成为苹果公司未来整体 API 设计中非常关键的一部分。然而,由于它不能向后兼容旧的操作系统版本,而且我们很可能还需要与其他尚未使用async/await的代码进行交互,因此找到将这些代码与Swift的新并发系统连接起来的方法对许多团队来说将是非常重要的。

谢谢你的阅读!