取消异步/等待网络请求

Har*_*lue 8 ios async-await swift urlsession

我有一个网络层,当前使用完成处理程序来传递操作完成的结果。

由于我支持多个 iOS 版本,因此我在应用程序内扩展网络层以提供对合并的支持。我想将其扩展到现在也支持异步/等待,但我很难理解如何以允许我取消请求的方式实现这一点。

基本实现如下所示;


protocol HTTPClientTask {
    func cancel()
}

protocol HTTPClient {
    typealias Result = Swift.Result<(data: Data, response: HTTPURLResponse), Error>
    @discardableResult
    func dispatch(_ request: URLRequest, completion: @escaping (Result) -> Void) -> HTTPClientTask
}

final class URLSessionHTTPClient: HTTPClient {
    
    private let session: URLSession
    
    init(session: URLSession) {
        self.session = session
    }
    
    func dispatch(_ request: URLRequest, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask {
        let task = session.dataTask(with: request) { data, response, error in
            completion(Result {
                if let error = error {
                    throw error
                } else if let data = data, let response = response as? HTTPURLResponse {
                    return (data, response)
                } else {
                    throw UnexpectedValuesRepresentation()
                }
            })
        }
        task.resume()
        return URLSessionTaskWrapper(wrapped: task)
    }
}

private extension URLSessionHTTPClient {
    struct UnexpectedValuesRepresentation: Error {}
    
    struct URLSessionTaskWrapper: HTTPClientTask {
        let wrapped: URLSessionTask
        
        func cancel() {
            wrapped.cancel()
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

它非常简单地提供了一个抽象,允许我注入一个URLSession实例。

通过返回,HTTPClientTask我可以cancel从客户端调用并结束请求。

Combine我使用如下方式在客户端应用程序中扩展它:

extension HTTPClient {
    typealias Publisher = AnyPublisher<(data: Data, response: HTTPURLResponse), Error>

    func dispatchPublisher(for request: URLRequest) -> Publisher {
        var task: HTTPClientTask?

        return Deferred {
            Future { completion in
                task = self.dispatch(request, completion: completion)
            }
        }
        .handleEvents(receiveCancel: { task?.cancel() })
        .eraseToAnyPublisher()
    }
}
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,我现在有一个支持取消任务的界面。

但是async/await,我不确定这应该是什么样子,我如何提供取消请求的机制。

我目前的尝试是;

extension HTTPClient {
    func dispatch(_ request: URLRequest) async -> HTTPClient.Result {

        let task = Task { () -> (data: Data, response: HTTPURLResponse) in
            return try await withCheckedThrowingContinuation { continuation in
                self.dispatch(request) { result in
                    switch result {
                    case let .success(values): continuation.resume(returning: values)
                    case let .failure(error): continuation.resume(throwing: error)
                    }
                }
            }
        }

        do {
            let output = try await task.value
            return .success(output)
        } catch {
            return .failure(error)
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

然而,这只是提供了async实现,我无法取消它。

这应该如何处理?

Rob*_*Rob 12

Swift\xe2\x80\x99 的新并发模型可以很好地处理取消。虽然 WWDC 2021 视频重点介绍了checkCancellationisCancelled模式(例如,探索 Swift视频中的结构化并发),但在这种情况下,人们将使用withTaskCancellationHandler创建一个任务,在任务本身被取消时取消网络请求。(显然,这只是 iOS 13/14 中的一个问题,因为在 iOS 15 中人们只需使用提供的async方法,data(for:delegate)或者data(from:delegate:),它也可以很好地处理取消。)

\n

例如,请参阅SE-0300:将异步任务与同步代码连接起来的延续:其他示例。这个download例子有点过时了,所以这里是一个更新的版本:

\n
extension URLSession {\n    @available(iOS, deprecated: 15, message: "Use `data(from:delegate:)` instead")\n    @available(macOS, deprecated: 12, message: "Use `data(from:delegate:)` instead")\n    func data(with url: URL) async throws -> (URL, URLResponse) {\n        try await download(with: URLRequest(url: url))\n    }\n\n    @available(iOS, deprecated: 15, message: "Use `data(for:delegate:)` instead")\n    @available(macOS, deprecated: 12, message: "Use `data(for:delegate:)` instead")\n    func data(with request: URLRequest) async throws -> (Data, URLResponse) {\n        let sessionTask = SessionTask(session: self)\n\n        return try await withTaskCancellationHandler {\n            try await withCheckedThrowingContinuation { continuation in\n                Task {\n                    await sessionTask.data(for: request) { data, response, error in\n                        guard let data, let response else {\n                            continuation.resume(throwing: error ?? URLError(.badServerResponse))\n                            return\n                        }\n\n                        continuation.resume(returning: (data, response))\n                    }\n                }\n            }\n        } onCancel: {\n            Task { await sessionTask.cancel() }\n        }\n    }\n}\n\nprivate extension URLSession {\n    actor SessionTask {\n        var state: State = .ready\n        private let session: URLSession\n\n        init(session: URLSession) {\n            self.session = session\n        }\n\n        func cancel() {\n            if case .executing(let task) = state {\n                task.cancel()\n            }\n            state = .cancelled\n        }\n    }\n}\n\n// MARK: Data\n\nextension URLSession.SessionTask {\n    func data(for request: URLRequest, completionHandler: @Sendable @escaping (Data?, URLResponse?, Error?) -> Void) {\n        if case .cancelled = state {\n            completionHandler(nil, nil, CancellationError())\n            return\n        }\n\n        let task = session.dataTask(with: request, completionHandler: completionHandler)\n\n        state = .executing(task)\n        task.resume()\n    }\n}\n\nextension URLSession.SessionTask {\n    enum State {\n        case ready\n        case executing(URLSessionTask)\n        case cancelled\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

对我的代码片段的一些小观察:

\n
    \n
  • 我给出这些名称是为了避免与 iOS 15 方法名称冲突,但添加了deprecated消息以通知开发人员在放弃 iOS 13/14 支持后使用 iOS 15 演绎版。

    \n
  • \n
  • data(from:delegate:)我偏离了 SE-0300\xe2\x80\x99s 示例,遵循和方法的模式data(for:delegate:)(使用Data和 a返回元组URLResponse)。

    \n
  • \n
  • 需要来同步对 的访问(原始示例actor中没有)。URLSessionTask

    \n
  • \n
  • 请注意,根据SE-0304,关于withTaskCancellationHandler

    \n
    \n

    如果任务在调用时已被取消withTaskCancellationHandler,则在执行操作块之前立即调用取消处理程序。

    \n
    \n

    因此,actor上面使用一个state变量来确定请求是否已经被取消,并且立即恢复,CancellationError如果已经取消则抛出 a 。

    \n
  • \n
\n

但所有这些都与当前的问题无关。简而言之,使用withTaskCancellationHandler.

\n

例如,以下是我在任务组中启动的五个图像请求,由Charles监控:

\n

在此输入图像描述

\n

这里是相同的请求,但这次我取消了整个任务组(并且取消成功地为我停止了相关的网络请求):

\n

在此输入图像描述

\n

(显然x轴刻度是不同的。)

\n
\n

如果您需要下载演绎版(以换行downloadTask),您可以补充上述内容:

\n
extension URLSession {\n    @available(iOS, deprecated: 15, message: "Use `download(from:delegate:)` instead")\n    @available(macOS, deprecated: 12, message: "Use `download(from:delegate:)` instead")\n    func download(with url: URL) async throws -> (URL, URLResponse) {\n        try await download(with: URLRequest(url: url))\n    }\n\n    @available(iOS, deprecated: 15, message: "Use `download(for:delegate:)` instead")\n    @available(macOS, deprecated: 12, message: "Use `download(for:delegate:)` instead")\n    func download(with request: URLRequest) async throws -> (URL, URLResponse) {\n        let sessionTask = SessionTask(session: self)\n\n        return try await withTaskCancellationHandler {\n            try await withCheckedThrowingContinuation { continuation in\n                Task {\n                    await sessionTask.download(for: request) { location, response, error in\n                        guard let location, let response else {\n                            continuation.resume(throwing: error ?? URLError(.badServerResponse))\n                            return\n                        }\n\n                        // since continuation can happen later, let\xe2\x80\x99s figure out where to store it ...\n\n                        let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())\n                            .appendingPathComponent(UUID().uuidString)\n                            .appendingPathExtension(request.url!.pathExtension)\n\n                        // ... and move it to there\n\n                        do {\n                            try FileManager.default.moveItem(at: location, to: tempURL)\n                        } catch {\n                            continuation.resume(throwing: error)\n                            return\n                        }\n\n                        continuation.resume(returning: (tempURL, response))\n                    }\n                }\n            }\n        } onCancel: {\n            Task { await sessionTask.cancel() }\n        }\n    }\n}\n\nextension URLSession.SessionTask {\n    func download(for request: URLRequest, completionHandler: @Sendable @escaping (URL?, URLResponse?, Error?) -> Void) {\n        if case .cancelled = state {\n            completionHandler(nil, nil, CancellationError())\n            return\n        }\n\n        let task = session.downloadTask(with: request, completionHandler: completionHandler)\n\n        state = .executing(task)\n        task.resume()\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n