SWIFT:使用Combine中`share`运算符避免重复工作的教程

719 阅读5分钟

当使用Combine编写异步代码时,我们有时可能想分享一组给定的并发操作的结果,而不是为每个操作执行重复的工作。让我们来看看share 操作符是如何以一种非常巧妙的方式使我们做到这一点的。

重复的、并发的网络调用

举个例子,假设我们正在处理下面这个ArticleLoader ,它使用URLSession ,从一个给定的URL加载一个Article 模型:

class ArticleLoader {
    private let urlSession: URLSession
    private let decoder: JSONDecoder

    init(urlSession: URLSession = .shared,
         decoder: JSONDecoder = .init()) {
        self.urlSession = urlSession
        self.decoder = decoder
    }

    func loadArticle(from url: URL) -> AnyPublisher<Article, Error> {
        urlSession
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Article.self, decoder: decoder)
            .eraseToAnyPublisher()
    }
}

现在我们说,我们希望上述loadArticle 方法被多次调用,无论是并行的还是快速连续的,使用相同的URL - 目前这将导致重复的网络请求,因为每次调用我们的方法都会产生一个全新的发布器。

重用发布器

为了解决这个问题,让我们把我们创建的每个发布者都存储在一个字典里(以每个发布者的URL为键),然后当我们收到一个loadArticle ,我们会首先检查该字典是否包含一个可以重复使用的现有发布者--像这样:

class ArticleLoader {
    typealias Publisher = AnyPublisher<Article, Error>

    private let urlSession: URLSession
    private let decoder: JSONDecoder
    private var publishers = [URL: Publisher]()
    ...

    func loadArticle(from url: URL) -> Publisher {
        if let publisher = publishers[url] {
            return publisher
        }

        let publisher = urlSession
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Article.self, decoder: decoder)
            .handleEvents(receiveCompletion: { [weak self] _ in
                self?.publishers[url] = nil
            })
            .eraseToAnyPublisher()

        publishers[url] = publisher
        return publisher
    }
}

请注意,一旦完成,我们也会从我们的字典中删除每个发布者,以避免在内存中保留旧的发布者。我们使用receiveCompletion ,而不是receiveOutput ,以便在遇到错误时也能得到通知。

现在,看看上面的代码,最初可能会觉得我们已经解决了我们的问题。然而,如果我们看一下我们的网络日志(或者简单地把print("Done") 调用到我们的handleEvents 闭包中),那么事实证明,我们实际上仍然在执行多个重复的操作。这怎么可能呢?

事实证明,即使我们确实在重复使用我们的发布者实例,这也不能保证我们真的在重复使用这些发布者正在执行的工作。事实上,在默认情况下,每个发布者都会为附着在它身上的每个订阅者运行整个数据管道。这最初可能看起来相当奇怪,所以让我们从一个稍微不同的角度来检查这种行为。

新的订阅者,新的价值

作为另一个快速的例子,我们在这里创建了一个发布器,它使用Timer ,每秒钟发布一个新的随机数,然后我们将两个独立的订阅者附加到该发布器上,这两个订阅者都只是打印他们收到的数字:

var cancellables = Set<AnyCancellable>()

let randomNumberGenerator = Timer
        .publish(every: 1, on: .main, in: .common)
        .autoconnect()
        .map { _ in Int.random(in: 1...100) }

randomNumberGenerator
    .sink { number in
        print(number)
    }
    .store(in: &cancellables)

randomNumberGenerator
    .sink { number in
        print(number)
    }
    .store(in: &cancellables)

可以说,如果我们的两个订阅者每秒都得到完全相同的数字,那就有点奇怪了,因为我们期望每个数字都是完全随机的(因此有点 "独特")。因此,从这个角度来看,Combine出版商为每个订阅者产生单独的输出这一事实可以说是非常有意义的。

但是,回到我们的ArticleLoader ,我们如何才能修改这个默认行为,以防止重复的网络调用被执行?

使用共享操作符

好消息是,我们所要做的就是使用share 操作符,它(就像它的名字所暗示的那样)修改一个给定的Combine管道,使其工作的结果在所有用户之间自动共享:

class ArticleLoader {
    ...

    func loadArticle(from url: URL) -> Publisher {
        if let publisher = publishers[url] {
            return publisher
        }

        let publisher = urlSession
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Article.self, decoder: decoder)
            .handleEvents(receiveCompletion: { [weak self] _ in
                self?.publishers[url] = nil
            })
            .share()
            .eraseToAnyPublisher()

        publishers[url] = publisher
        return publisher
    }
}

仅仅是这个微小的变化,我们现在就完全解决了我们的问题。现在,即使多个loadArticle 调用快速连续发生,也只执行一个网络调用,其结果将被报告给每个调用站点。

好吧,也许 "完全解决 "并不完全正确,因为我们的实现仍然有一个潜在的问题--它目前没有考虑到这样一个事实,即我们的ArticleLoader 可能会在不同的线程上被调用,而不是URLSession 返回其数据任务的输出。虽然这很可能不会导致任何实际的问题,但我们还是要做一个快速的奖励,使我们的实现完全是线程安全的,同时我们也在做这个工作。

要做到这一点,让我们对我们的loadArticle 实施做一些调整。首先,我们将把我们的组合管道建立在我们的输入URL上,然后我们将立即跳转到一个内部的 DispatchQueue,当我们从一个发布者那里收到一个完成事件时,我们也会使用它。这样,我们可以保证我们的publishers 字典总是在完全相同的队列中被读取和写入:

class ArticleLoader {
    ...
    private let queue = DispatchQueue(label: "ArticleLoader")

    func loadArticle(from url: URL) -> Publisher {
        Just(url)
            .receive(on: queue)
            .flatMap { [weak self, urlSession, queue, decoder] url -> Publisher in
                if let publisher = self?.publishers[url] {
                    return publisher
                }

                let publisher = urlSession
                    .dataTaskPublisher(for: url)
                    .map(\.data)
                    .decode(type: Article.self, decoder: decoder)
                    .receive(on: queue)
                    .handleEvents(receiveCompletion: { [weak self] _ in
                        self?.publishers[url] = nil
                    })
                    .share()
                    .eraseToAnyPublisher()

                self?.publishers[url] = publisher
                return publisher
            }
            .eraseToAnyPublisher()
    }
}

有了这些调整,我们现在有了一个完全线程安全的实现,它成功地重用了发布者,避免了执行任何重复的工作。下一步可能是在上述实现中加入缓存(我们目前只是依靠URLSession 提供的默认缓存机制),如果这对我们来说是有用的。

总结

这就是如何使用share 操作符来避免Combine管道中的重复工作。

谢谢你的阅读!