Swift :使用`AsyncSequence` 和`AsyncStream` API 实现各种异步序列的教程及实例

1,008 阅读3分钟

当使用标准的for 循环对任何Swift集合进行迭代时,有两个关键组件决定哪些元素将被传递到我们的迭代代码中--一个序列和一个迭代器。例如,Swift的标准Array 类型是一个序列,并使用IndexingIterator 作为其迭代器类型。

虽然我们在编写Swift代码时经常直接与序列互动,但我们很少需要自己处理迭代器,因为只要我们使用for 循环,语言就会自动为我们管理这些实例。

不过,由专用类型明确控制迭代的事实真的很强大,因为这使我们能够编写我们自己的、完全自定义的序列,其迭代方式与通过内置类型(如ArrayDictionarySet )循环时完全相同。

在这篇文章中,让我们看看这个系统是如何工作的,以及它是如何延伸到Swift的并发世界的--使我们能够创建完全异步的序列和流,随着时间的推移传递它们的值。

序列和迭代器

假设我们想建立一个自定义序列,让我们从一系列的URL下载数据。为了实现这一点,我们首先需要实现一个符合Sequence 协议的自定义类型,这又会使用所需的makeIterator 方法创建一个迭代器--像这样:

struct RemoteDataSequence: Sequence {
    var urls: [URL]

    func makeIterator() -> RemoteDataIterator {
        RemoteDataIterator(urls: urls)
    }
}

接下来,让我们实现上面的RemoteDataIterator 类型,它通过每次系统请求一个新元素时增加一个index 属性来执行其迭代:

struct RemoteDataIterator: IteratorProtocol {
    var urls: [URL]
    fileprivate var index = 0

    mutating func next() -> Data? {
        guard index < urls.count else {
            return nil
        }

        let url = urls[index]
        index += 1

        // If a download fails, we simply move on to
        // the next URL in this case:
        guard let data = try? Data(contentsOf: url) else {
            return next()
        }

        return data
    }
}

我们不需要担心管理多个并发的迭代,因为每次启动一个新的for 循环时,系统会自动创建一个新的迭代器(使用我们序列的makeIterator 方法)。

有了以上这些,我们现在就可以用与在数组上迭代时完全相同的语法来迭代我们所有的下载数据:

for data in RemoteDataSequence(urls: urls) {
    ...
}

真是太酷了!然而,像这样同步下载数据可能不是一个很好的主意(除了在编写脚本或命令行工具时),因为这样做会完全阻塞当前线程,直到所有下载完成。因此,让我们来探讨一下如何将上述内容变成一个异步序列来代替。

异步迭代

Swift 5.5 的新并发系统引入了异步序列和迭代器的概念,其定义方式与同步序列几乎完全相同。因此,为了使我们的RemoteDataSequence 异步化,我们所要做的就是使其符合AsyncSequence 协议并实现makeAsyncIterator 方法--像这样:

struct RemoteDataSequence: AsyncSequence {
    typealias Element = Data

    var urls: [URL]

    func makeAsyncIterator() -> RemoteDataIterator {
        RemoteDataIterator(urls: urls)
    }
}

请注意,上面的typealias 其实是不需要的,因为编译器应该能够推断出我们序列的Element 类型,但从Xcode 13.0来看,似乎不是这样的。

接下来,让我们给我们的RemoteDataIterator 也做一个异步改造--这涉及到给它的next 方法添加asyncthrows 关键字(因为我们希望我们的迭代能够在数据下载失败时产生错误)--然后我们可以使用内置的URLSession 网络 API 来完全异步地下载我们的数据:

struct RemoteDataIterator: AsyncIteratorProtocol {
    var urls: [URL]
    fileprivate var urlSession = URLSession.shared
    fileprivate var index = 0

    mutating func next() async throws -> Data? {
        guard index < urls.count else {
            return nil
        }

        let url = urls[index]
        index += 1

        let (data, _) = try await urlSession.data(from: url)
        return data
    }
}

有了上述变化,我们的RemoteDataSequence 现在已经变成了一个完全异步的序列,这就要求我们在迭代其元素时使用await (以及在这种情况下,try )--因为我们的数据现在将在后台下载,并在准备好时被送入我们的for 循环:

for try await data in RemoteDataSequence(urls: urls) {
    ...
}

异步迭代器可以抛出错误这一事实真的很强大,因为这可以让我们在遇到错误时自动退出for 循环,而不是要求我们手动跟踪这种错误。当然,这并不意味着所有的异步序列都需要有抛出错误的能力。如果我们在声明迭代器的next 方法时简单地省略了throws 关键字,那么我们的序列将被认为是不抛出的(而且在迭代其元素时我们不再需要使用try )。

异步流

虽然能够定义完全自定义的异步序列是非常强大的,但标准库也提供了两个独立的类型,使我们能够创建这样的序列,而不需要定义我们自己的任何类型。这些类型是:AsyncStreamAsyncThrowingStream ,前者让我们创建不抛出的异步序列,而后者让我们可以选择抛出错误。

回到从一系列URL下载数据的例子,让我们看一下我们如何使用AsyncThrowingStream ,而不是声明自定义类型来实现同样的功能。这样做将涉及启动一个异步Task ,在这个异步yield ,所有被下载的数据,然后我们finish ,报告遇到的任何错误--像这样:

func remoteDataStream(
    forURLs urls: [URL],
    urlSession: URLSession = .shared
) -> AsyncThrowingStream<Data, Error> {
    AsyncThrowingStream { continuation in
        Task {
            do {
                for url in urls {
                    let (data, _) = try await urlSession.data(from: url)
                    continuation.yield(data)
                }

                continuation.finish(throwing: nil)
            } catch {
                continuation.finish(throwing: error)
            }
        }
    }
}

虽然以上是一个完美的实现,但实际上可以使用另一个AsyncThrowingStream 初始化器来简化它--这给了我们一个已经被标记为async 的闭包,在这个闭包中我们可以专注于返回流中的下一个元素:

func remoteDataStream(
    forURLs urls: [URL],
    urlSession: URLSession = .shared
) -> AsyncThrowingStream<Data, Error> {
    var index = 0

    return AsyncThrowingStream {
        guard index < urls.count else {
            return nil
        }

        let url = urls[index]
        index += 1

        let (data, _) = try await urlSession.data(from: url)
        return data
    }
}

上面我们在流的闭包中捕获了本地index 变量,使我们能够使用它来跟踪我们的迭代状态。

有了以上两种实现,我们现在就可以在新的异步流上进行迭代,就像我们之前在自定义的AsyncSequence 上循环一样:

for try await data in remoteDataStream(forURLs: urls) {
    ...
}

因此,AsyncStreamAsyncThrowingStream 可以被看作是AsyncSequence 协议的具体实现,就像Array 是同步Sequence 协议的具体实现。在大多数情况下,使用流可能是最直接的实现,但如果我们想获得对特定迭代的完全控制,那么建立一个自定义的AsyncSequence 可能是最好的办法。

这一切是如何与Combine联系起来的?

现在,如果你使用过苹果的Combine框架,那么你可能会问自己这套新的异步序列API与该框架有什么关系,因为它们都使我们能够随时间发射和观察数值。

虽然我在WWDC文章《Swift的新并发功能对Combine的未来意味着什么》中已经在一定程度上讨论了这个问题,但我的看法是,Combine是一个功能齐全的反应式编程框架,而这个新的异步序列系统为构建任何类型的异步序列提供了更多低级别的API--不管是否接受反应式编程风格。

不过好消息是,Combine现在已经完全兼容AsyncSequence ,这使我们能够把任何Publisher ,变成这样一个异步序列的值。例如,这里是我们之前的数据下载功能的Combine驱动版本:

func remoteDataPublisher(
    forURLs urls: [URL],
    urlSession: URLSession = .shared
) -> AnyPublisher<Data, URLError> {
    urls.publisher
        .setFailureType(to: URLError.self)
        .flatMap(maxPublishers: .max(1)) {
            urlSession.dataTaskPublisher(for: $0)
        }
        .map(\.data)
        .eraseToAnyPublisher()
}

然后将上述函数返回的AnyPublisher 转换成一个AsyncSequence ,我们所要做的就是访问它的values 属性--系统会处理其余的事情。

let publisher = remoteDataPublisher(forURLs: urls)

for try await data in publisher.values {
    ...
}

非常整洁!上述API应该被证明在有现有Combine代码的代码库中是非常有用的,因为它让我们在采用Swift的新并发系统时也能继续使用这些代码(无需修改!)。

要想反其道而行之,在基于 Combine 的代码中使用async 标记的 API,请查看"在 Combine 管道中调用异步函数"。

总结

我希望这篇文章让你对 Swift 新的AsyncSequenceAsyncStream API 的工作原理有了一些新的认识,知道如何使用它们来实现各种异步序列,以及这些新 API 与 Combine 的关系。

谢谢你的阅读!