Swift :使用Combine的`Future`和主题构建API的教程

809 阅读7分钟

尽管Combine主要围绕着发布者的概念,这些发布者会随着时间的推移发布一系列的值,但它也包括一组方便的API,使我们能够利用框架的全部功能,而不一定要从头开始编写完全自定义的发布者实现

例如,假设我们想将组合支持添加到现有的、基于闭合的API中--比如这个ImageProcessor ,它使用经典的完成处理程序模式来异步通知其调用者,当对给定图像的处理完成或失败时:

struct ImageProcessor {
    func process(
        _ image: UIImage,
        then handler: @escaping (Result<UIImage, Error>) -> Void
    ) {
        // Process the image and call the handler when done
        ...
    }
}

上述 API 使用 Swift 内置的Result 类型来封装成功处理的图像,或遇到的任何错误。要了解更多信息,请查看这篇基础知识文章

现在,与其重写我们的ImageProcessor ,不如以一种完全附加的方式为它添加一个新的、由Combine驱动的API。这不仅可以保持我们所有的现有代码的完整性,而且还可以让我们在编写新的代码时,根据具体情况选择是使用Combine还是完成处理程序。

用期货进行改造

为了实现这一点,让我们使用Combine的Future ,它是对Futures/Promises模式的改编,该模式在各种不同的编程语言中都非常常用。从本质上讲,CombineFuture 给了我们一个promise 闭包,我们可以在异步操作完成后调用它,然后 Combine 会自动将我们给该闭包的Result 映射到适当的发布者事件中。

在这种情况下,真正方便的是我们现有的完成处理程序闭包已经使用了一个Result 类型作为它的输入,所以我们在基于Future 的实现中所要做的就是简单地将 Combine 给我们的promise 闭包传递给我们现有的process API 的调用--像这样:

extension ImageProcessor {
    func process(_ image: UIImage) -> Future<UIImage, Error> {
        Future { promise in
            process(image, then: promise)
        }
    }
}

为了说明问题,如果我们使用一个专门的完成处理程序闭包,然后手动将其结果传递给我们的promise ,那么上面的实现会是什么样子:

extension ImageProcessor {
    func process(_ image: UIImage) -> Future<UIImage, Error> {
        Future { promise in
            process(image) { result in
                promise(result)
            }
        }
    }
}

因此,Future 类型提供了一种非常好的方式,可以将现有的、基于闭包的API转换为可以在Combine的反应式世界中使用的API--而且,由于这些期货实际上只是像其他的发布者一样,这意味着我们可以使用Combine的整套操作符来转换和观察它们:

processor.process(image)
    .replaceError(with: .errorIcon)
    .map { $0.withRenderingMode(.alwaysTemplate) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: imageView)
    .store(in: &cancellables)

然而,就像一般的期货和承诺一样,Combine的Future 类型只能发出一个结果,因为一旦它的promise 闭包被调用,它将立即完成并被关闭。

那么,如果我们想在一段时间内发射多个值,这确实是Combine的主要设计目的呢?

处理多个输出值

让我们回到之前的基于闭包的ImageProcessor API,想象一下它接受两个闭包--一个在图像处理过程中被定期调用,并有进度更新,另一个在操作完全完成后被调用:

struct ImageProcessor {
    typealias CompletionRatio = Double
    typealias ProgressHandler = (CompletionRatio) -> Void
    typealias CompletionHandler = (Result<UIImage, Error>) -> Void

    func process(
        _ image: UIImage,
        onProgress: @escaping ProgressHandler,
        onComplete: @escaping CompletionHandler
    ) {
        // Process the image and call the progress handler to
        // report the operation's ongoing progress, and then
        // call the completion handler once the image has finished
        // processing, or if an error was encountered.
        ...
    }
}

Double 上面我们使用了类型别名,这既是为了使我们的实际方法定义更容易阅读,也是为了将传入我们的ProgressHandler 。想了解更多,请看《Swift中类型别名的力量》。

在我们开始为上述新的API更新基于Combine的扩展之前,让我们引入一个名为ProgressEvent 的枚举,我们将使用它作为我们将要创建的Combine发布器的输出类型(因为发布器只能发出单一类型的值)。它将包括两种情况,一种是更新事件,另一种是完成事件:

extension ImageProcessor {
    enum ProgressEvent {
        case updated(completionRatio: CompletionRatio)
        case completed(UIImage)
    }
}

关于如何更新我们的Combine API的初步想法可能是继续使用Future 类型,就像我们之前做的那样,但现在要多次调用其promise 闭包来报告更新和完成事件--例如像这样:

extension ImageProcessor {
    func process(_ image: UIImage) -> Future<ProgressEvent, Error> {
        Future { promise in
            process(image,
                onProgress: { ratio in
                    promise(.success(
                        .updated(completionRatio: ratio)
                    ))
                },
                onComplete: { result in
                    promise(result.map(ProgressEvent.completed))
                }
            )
        }
    }
}

然而,上述做法是行不通的,因为--如前所述--Combine期货只能发出一个值,这意味着在上述设置下,我们只能在整个管道完成之前收到第一个updated 事件。

使用主题发送值

相反,这是一个主题的伟大用例--它让我们在手动完成之前发送尽可能多的值。Combine有两个主要的主题实现:PassthroughSubjectCurrentValueSubject 。让我们从使用前者开始,它不保留我们将发送的任何值,而是将它们传递给它的每个订阅者。

下面是我们如何使用这样一个主题来更新我们的Combine-poweredImageProcessing API,现在完全支持进度更新和完成事件:

extension ImageProcessor {
    func process(_ image: UIImage) -> AnyPublisher<ProgressEvent, Error> {
        // First, we create our subject:
        let subject = PassthroughSubject<ProgressEvent, Error>()

        // Then, we call our closure-based API, and whenever it
        // sends us a new event, then we'll pass that along to
        // our subject. Finally, when our operation was finished,
        // then we'll send a competion event to our subject:
        process(image,
            onProgress: { ratio in
                subject.send(.updated(completionRatio: ratio))
            },
            onComplete: { result in
                switch result {
                case .success(let image):
                    subject.send(.completed(image))
                    subject.send(completion: .finished)
                case .failure(let error):
                    subject.send(completion: .failure(error))
                }
            }
        )
        
        // To avoid returning a mutable object, we convert our
        // subject into a type-erased publisher before returning it:
        return subject.eraseToAnyPublisher()
    }
}

现在我们要做的就是更新我们之前的调用站点,以处理ProgressEvent 值,而不仅仅是UIImage 实例--像这样:

processor.process(image)
    .replaceError(with: .completed(.errorIcon))
    .receive(on: DispatchQueue.main)
    .sink { event in
        switch event {
        case .updated(let completionRatio):
            progressView.completionRatio = completionRatio
        case .completed(let image):
            imageView.image = image.withRenderingMode(
                .alwaysTemplate
            )
        }
    }
    .store(in: &cancellables)

然而,在使用PassthroughSubject 时要记住的一点是,每个附加到它的订阅者将只接收在该订阅变得活跃发送的值。

所以在我们的例子中,由于我们要立即开始每个图像处理操作,而且我们不知道调用者是否会对处理我们发出的值的方式应用某种形式的延迟,所以我们可能反而想使用CurrentValueSubject 。就像它的名字所暗示的,这样的主体将跟踪我们发送给它的当前(或最后)值,一旦所有新的订阅者连接到我们的主体,就会反过来将其发送给他们。

值得庆幸的是,在这两个主题变体之间的切换通常非常简单,因为唯一的区别是我们必须用我们希望它跟踪的初始电流值来初始化一个CurrentValueSubject

extension ImageProcessor {
    func process(_ image: UIImage) -> AnyPublisher<ProgressEvent, Error> {
        let subject = CurrentValueSubject<ProgressEvent, Error>(
            .updated(completionRatio: 0)
        )

        ...

        return subject.eraseToAnyPublisher()
    }
}

值得指出的是,上述的新实现也会导致初始的ProgressEvent 值在我们的主题被创建时被立即发射出来,这可能是我们想要的,也可能不是,取决于情况。但是,在这种情况下,它实际上是非常好的,因为这将确保我们所有的进度处理代码在连接到我们的主题时总是被重置为零。

结论

Combine的Future 和主题类型在构建由Combine驱动的API时绝对值得记住--而且通常可以作为从头开始构建完全自定义发布者的更简单的替代方案。当然,还有Published 属性,它提供了另一种通过存储属性发射值的方式,这在本文中已经深入介绍过了--所以Combine确实提供了一套相当全面的工具,我们可以根据我们想要建立的东西来选择。