Swift :在Combine中调用异步函数的教程

443 阅读4分钟

当开始采用 Swift 的新异步/等待功能时,我们很可能必须找到方法,将该模式与我们在特定项目中已经编写的任何现有异步代码进行连接和连接。

本周早些时候,我们看了一下在涉及到基于完成处理程序的API时如何做到这一点,以及如何使单输出的Combine发布器与异步/等待兼容。在这篇文章中,让我们继续这一旅程,探讨如何在Combine管道中调用async-marked函数。

面向未来

让我们先用一个名为asyncMap 的自定义操作符扩展Combine的Publisher 协议。在内部,我们将使用Combine内置的flatMapFuture类型,然后我们将使用Task 来触发我们的异步转换--像这样:

extension Publisher {
    func asyncMap<T>(
        _ transform: @escaping (Output) async -> T
    ) -> Publishers.FlatMap<Future<T, Never>, Self> {
        flatMap { value in
            Future { promise in
                Task {
                    let output = await transform(value)
                    promise(.success(output))
                }
            }
        }
    }
}

可以说,上述操作符应该是标准flatMap 操作符的重载(因为这就是我们在引擎盖下实际执行的类型操作)。命名是困难的,但在这种情况下,我认为使用一个新的名字可以使事情变得更清楚(鉴于我们在转换闭包中实际上并没有返回一个新的发布者),同时也模仿了其他map 变体的设计,比如tryMap

有了上述内容,我们现在就可以在Combine管道中自由地调用任何非抛出的 async 函数。例如,这里我们将一个由Combine驱动的对ColorProfile API的调用与一系列async 的函数调用混合在一起:

struct PhotoRenderer {
    var colorProfile: ColorProfile
    var effect: PhotoEffect

    func render(_ photo: Photo) -> AnyPublisher<UIImage, Never> {
        colorProfile
            .apply(to: photo)
            .asyncMap { photo in
                let photo = await effect.apply(to: photo)
                return await uiImage(from: photo)
            }
            .eraseToAnyPublisher()
    }

    private func uiImage(from photo: Photo) async -> UIImage {
        ...
    }
}

上述新功能的真正好处是,它使我们能够更无缝地将代码库的一部分迁移到异步/等待中,同时仍然保持我们现有的基于Combine的API完全不变。

抛出异步函数

接下来,让我们也启用抛出的 async 函数,以便在我们的 Combine 管道中调用。这需要第二个asyncMap 重载,它接受一个封闭的throws ,并自动转发任何抛出的错误到我们的包装Future

extension Publisher {
    func asyncMap<T>(
        _ transform: @escaping (Output) async throws -> T
    ) -> Publishers.FlatMap<Future<T, Error>, Self> {
        flatMap { value in
            Future { promise in
                Task {
                    do {
                        let output = try await transform(value)
                        promise(.success(output))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
    }
}

使用上述新的重载,我们现在就可以轻松地将任何抛出的async 函数调用也插入到我们的组合管道中。例如,下面的LoginStateController 使用了一个由Combine驱动的Keychain API,然后连接到一个由async/await驱动的UserLoader ,以产生我们的最终管道。

class LoginStateController {
    private let keychain: Keychain
    private let userLoader: UserLoader
    ...

    func loadLoggedInUser() -> AnyPublisher<User, Error> {
        keychain
            .loadLoggedInUserID()
            .asyncMap { [userLoader] userID in
                try await userLoader.loadUser(withID: userID)
            }
            .eraseToAnyPublisher()
    }
}

请注意我们是如何在上述asyncMap 闭包中明确地捕获我们的userLoader ,以避免捕获一个self 的引用。

尽管我们现在已经涵盖了绝大多数涉及 Combine 和异步/等待互操作性的用例,但还有一种情况可能需要处理--那就是让一个抛出的 async 函数在一个非抛出的Combine 管道中被调用。

值得庆幸的是,内置的flatMap 操作符已经在处理这个问题了(通过一个专门的重载),所以我们要做的就是创建一个asyncMap 的第三个重载,它的输出类型要与flatMap 的特定版本相匹配。

extension Publisher {
    func asyncMap<T>(
        _ transform: @escaping (Output) async throws -> T
    ) -> Publishers.FlatMap<Future<T, Error>,
                            Publishers.SetFailureType<Self, Error>> {
        flatMap { value in
            Future { promise in
                Task {
                    do {
                        let output = try await transform(value)
                        promise(.success(output))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
    }
}

有了上述内容,我们现在就可以用一个抛出的asyncMap 调用来扩展任何非抛出的Combine管道。例如,在这里,我们将先前看过的非抛出式PhotoRenderer API与抛出式调用新的、异步/await驱动的URLSession API相连接,以执行上传。

struct PhotoUploader {
    var renderer: PhotoRenderer
    var urlSession = URLSession.shared

    func upload(_ photo: Photo,
                to url: URL) -> AnyPublisher<URLResponse, Error> {
        renderer
            .render(photo)
            .asyncMap { image in
                guard let data = image.pngData() else {
                    throw PhotoUploadingError.invalidImage(image)
                }

                var request = URLRequest(url: url)
                request.httpMethod = "POST"

                let (_, response) = try await urlSession.upload(
                    for: request,
                    from: data
                )

                return response
            }
            .eraseToAnyPublisher()
    }
}

有了这三个asyncMap 重载,我们现在就可以在任何Combine管道中插入任何类型的async 标记的调用。这真是太好了!

总结

尽管我仍然认为Combine在涉及到反应式编程时仍然是一个非常有用的工具,但随着时间的推移,许多异步API可能会转移到异步/等待上。值得庆幸的是,这并不意味着我们必须重写我们的项目已经使用的任何现有的基于Combine的代码,因为绝对有可能弥合这两个世界之间的差距。

谢谢你的阅读!