Swift:决定在哪个DispatchQueue上运行一个完成处理程序的教程

115 阅读7分钟

当谈到为异步操作调用完成处理程序时,长期以来,苹果开发者社区的既定惯例是简单地继续在操作本身(或至少其最后部分)执行的任何DispatchQueue

例如,当使用内置的URLSession API来执行基于数据任务的网络调用时,我们附加的完成处理程序将在一个队列上执行,该队列由URLSession 本身内部管理。

let task = URLSession.shared.dataTask(with: url) {
    data, response, error in

    // This code will be executed on an internal URLSession queue,
    // regardless of what queue that we created our task on.
    ...
}

上述惯例在理论上可以说是完全合理的--因为它鼓励我们编写非阻塞的异步代码,而且它倾向于减少在不需要时在队列之间跳转的开销。然而,如果我们不小心的话,它也常常会导致不同类型的错误和竞赛条件。

这是因为,在一天结束时,绝大多数应用程序中的绝大多数代码都不会是线程安全的。让一个类、函数或其他类型的实现成为线程安全的,通常会涉及到相当多的工作,特别是当涉及到UI相关的代码时,因为苹果的所有核心UI框架(包括UIKit和SwiftUI)都只能从主线程安全使用。

记住要在主队列中调度UI更新

让我们来看一个例子,在这个例子中,我们建立了一个ProductLoader ,使用上面提到的URLSession API,根据其ID加载一个给定的Product

class ProductLoader {
    typealias Handler = (Result<Product, Error>) -> Void

    private let urlSession: URLSession
    private let urlResolver: (Product.ID) -> URL
    
    ...

    func loadProduct(withID id: UUID,
                     completionHandler: @escaping Handler) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            data, response, error in

            // Decode data, perform error handling, and so on...
            ...
            
            handler(result)
        }

        task.resume()
    }
}

上面的类遵循那个既定的惯例,不在任何特定的队列上派发它的completionHandler 调用,而是简单地在它自己的完成处理程序中内联调用那个闭包,而这个闭包又被URLSession 在前面提到的那个内部背景队列中调用。

正因为如此,当我们在任何类型的UI相关代码中使用我们的ProductLoader ,我们需要记住总是明确地在我们的应用程序的主DispatchQueue 上分配任何结果的UI更新--例如,像这样:

class ProductViewController: UIViewController {
    private let productID: Product.ID
    private let loader: ProductLoader
    private lazy var nameLabel = UILabel()
    private lazy var descriptionLabel = UILabel()
    
    ...

    func update() {
        loader.loadProduct(withID: productID) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let product):
                    self?.nameLabel.text = product.name
                    self?.descriptionLabel.text = product.description
                case .failure(let error):
                    self?.handle(error)
                }
            }
        }
    }
}

要记住在我们的异步闭包中执行上述类型的DispatchQueue 调用,在实践中可能并不是一个问题,因为(就像经典的weak self 舞蹈一样)在为苹果平台开发应用程序时,我们必须非常频繁地做这件事,所以我们不可能忘记这件事。

另外,如果我们真的忘记了添加这个调用(或者如果有人刚刚开始开发应用程序,还没有了解这方面的情况),那么一旦我们运行任何代码,不小心从后台线程调用了仅有主队列的API,Xcode的主线程检查器就会迅速触发一个紫色警告。

然而,如果我们不使用闭包呢?例如,让我们想象一下,我们的ProductLoader ,而使用委托模式,每当它完成一个操作时,它不是调用一个完成处理程序,而是调用一个委托方法。

class ProductLoader {
    weak var delegate: ProductLoaderDelegate?
    ...

    func loadProduct(withID id: UUID) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            [weak self] data, response, error in

            guard let self = self else { return }

            // Decode data, perform error handling, and so on...
            ...
            
            self.delegate?.productLoader(self,
                didFinishLoadingWithResult: result
            )
        }

        task.resume()
    }
}

如果我们现在回到我们的ProductViewController ,并对其进行相应的更新,那么就不再很清楚调用站点(在这种情况下是其委托协议的实现)正在处理一个异步操作的结果,这使得我们更有可能忘记在主队列上异步地执行我们的UI更新。

因此,尽管在调用下面的方法时,Xcode仍然会给我们一个运行时错误(而我们的UI更新是在后台队列上执行的),但光看它的实现并不十分明显,它的实现是不正确的。

extension ProductViewController: ProductLoaderDelegate {
    func productLoader(
        _ loader: ProductLoader,
        didFinishLoadingWithResult result: Result<Product, Error>
    ) {
        switch result {
        case .success(let product):
            nameLabel.text = product.name
            descriptionLabel.text = product.description
        case .failure(let error):
            handle(error)
        }
    }
}

诚然,委托模式已经不像以前那么时髦了(不过我还是喜欢它),但上述问题绝对不是该特定模式所特有的。事实上,如果我们现在看一下我们的ProductLoader 及其相关的视图控制器的一个非常现代的、基于Combine的版本--我们可以看到它和我们基于委托的实现有完全相同的问题--我们的UI更新目前最终会在后台队列中执行,这一点一点都不明显。

class ProductLoader {
    ...

    func loadProduct(withID id: UUID) -> AnyPublisher<Product, Error> {
        urlSession
            .dataTaskPublisher(for: urlResolver(id))
            .map(\.data)
            .decode(type: Product.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

class ProductViewController: UIViewController {
    ...
    private var updateCancellable: AnyCancellable?

    func update() {
        updateCancellable = loader
            .loadProduct(withID: productID)
            .convertToResult()
            .sink { [weak self] result in
                switch result {
                case .success(let product):
                    self?.nameLabel.text = product.name
                    self?.descriptionLabel.text = product.description
                case .failure(let error):
                    self?.handle(error)
                }
            }
    }
}

上面我们使用了"用方便的API扩展Combine "中的自定义convertToResult 操作符,以便能够轻松地将我们的Combine管道的输出作为一个Result

因此,总结一下,无论我们选择哪种模式来实现我们的异步操作,我们总是有可能忘记在主队列上手动分配我们的UI更新--特别是当一个给定的回调可能是在后台队列上执行的时候,这一点并不明显。

明确的队列注入

那么,我们怎样才能解决上述问题呢?它甚至值得修复,还是我们应该假设每个有一定经验的Swift开发者都会一直记得确保他们的UI更新将在主队列上执行?

如果你问我,我认为任何真正伟大的API都不应该依赖于它的调用者记住(甚至知道)某些惯例--这些惯例最好是被植入API设计本身。毕竟,确保一个API不会被错误使用的一个非常可靠的方法就是让它不可能(或者至少是很难)这样做--通过利用Swift的类型系统等工具在编译时验证每个调用。

在这种情况下,一种方法是始终在主队列上调用我们的完成处理程序,这将完全消除让我们的任何调用站点意外地在后台队列上执行UI更新的风险。

class ProductLoader {
    ...

    func loadProduct(withID id: UUID,
                     completionHandler: @escaping Handler) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            data, response, error in

            ...

            DispatchQueue.main.async {
                completionHandler(result)
            }
        }

        task.resume()
    }
}

然而,上述模式最终也会导致自身的问题,特别是如果我们希望在我们确实希望以非阻塞的方式在后台队列上继续执行的情况下使用我们的ProductLoader

因此,这里有一个更加动态的版本,它仍然使用主队列作为所有完成处理程序调用的默认值,但也允许注入一个显式的DispatchQueue --让我们既可以在远离主线程的并发环境中使用我们的ProductLoader ,也可以在我们的UI代码中使用,同时大大降低了在错误的队列中执行UI更新的风险。

// Completion handler-based version:

class ProductLoader {
    ...

    func loadProduct(
        withID id: UUID,
        resultQueue: DispatchQueue = .main,
        completionHandler: @escaping Handler
    ) {
        let task = urlSession.dataTask(with: urlResolver(id)) {
            data, response, error in

            ...

            resultQueue.async {
                completionHandler(result)
            }
        }

        task.resume()
    }
}

// Combine-based version:

class ProductLoader {
    ...
    
    func loadProduct(
        withID id: UUID,
        resultQueue: DispatchQueue = .main
    ) -> AnyPublisher<Product, Error> {
        urlSession
            .dataTaskPublisher(for: urlResolver(id))
            .map(\.data)
            .decode(type: Product.self, decoder: JSONDecoder())
            .receive(on: resultQueue)
            .eraseToAnyPublisher()
    }
}

当然,上述模式确实依赖于我们记住将resultQueue 参数添加到我们的每个异步API中(我们也可以将其作为一个初始化参数来实现),但至少现在我们不必记住在每个调用站点上总是使用DispatchQueue.main.async ,我个人认为这是一个很大的胜利。

结论

虽然没有完全防错的API,而且为任何一种平台开发应用程序总是需要学习和记住某些惯例,但如果我们能够使我们在自己的应用程序中设计的API尽可能容易使用(或难以误用),那么这往往会导致代码库的强大和直接使用。

默认在主队列上调用完成处理程序可能只是其中的一小部分,但它可能变成一个相当重要的部分,特别是在大量使用异步操作导致UI更新的代码库中。

谢谢你的阅读!