使用URLSession执行POST和文件上传请求的方法及实例

264 阅读6分钟

多年来,Foundation内置的URLSession API已经成长为一个多功能的、非常强大的网络工具,以至于通常不再需要第三方库来以简单直接的方式执行标准的HTTP网络调用。

虽然URLSession 带来的许多便利API都集中在用于获取数据的GET 请求上,但在这篇文章中,让我们看看其他的HTTP方法也可以被使用--特别是如何在没有任何外部依赖的情况下执行不同种类的POST 请求。

数据和上传任务

也许使用URLSession 来执行POST 请求的最简单的方法是使用其各种dataTask API(支持委托和基于闭合的回调,以及Combine)的基于URLRequest 的重载。在许多其他方面,URLRequest ,使我们能够定制一个给定的网络调用应该使用什么httpMethod ,以及其他有用的参数,如发送什么httpBody 数据,以及使用什么cachePolicy 。这里有一个例子:

struct Networking {
    var urlSession = URLSession.shared

    func sendPostRequest(
        to url: URL,
        body: Data,
        then handler: @escaping (Result<Data, Error>) -> Void
    ) {
        // To ensure that our request is always sent, we tell
        // the system to ignore all local cache data:
        var request = URLRequest(
            url: url,
            cachePolicy: .reloadIgnoringLocalCacheData
        )
        
        request.httpMethod = "POST"
        request.httpBody = body

        let task = urlSession.dataTask(
            with: request,
            completionHandler: { data, response, error in
                // Validate response and call handler
                ...
            }
        )

        task.resume()
    }
}

根据我们的POST 请求被发送到的服务器,我们可能还想进一步配置我们的URLRequest 实例,例如,给它一个Content-Type 头。

另外,我们可以选择使用uploadTask API来创建我们的请求任务,这既可以让我们在应用程序处于后台的时候上传数据,又可以提供内置的支持,将主体数据直接附加到任务本身。

struct Networking {
    var urlSession = URLSession.shared

    func sendPostRequest(
        to url: URL,
        body: Data,
        then handler: @escaping (Result<Data, Error>) -> Void
    ) {
        var request = URLRequest(
            url: url,
            cachePolicy: .reloadIgnoringLocalCacheData
        )
        
        request.httpMethod = "POST"

        let task = urlSession.uploadTask(
            with: request,
            from: body,
            completionHandler: { data, response, error in
                // Validate response and call handler
                ...
            }
        )

        task.resume()
    }
}

观察进度更新

虽然上述两种方法中的任何一种在发送较小的数据作为POST 请求的一部分时都能完全正常工作,但有时我们可能想上传一个可能相当大的文件(即使是简单的东西,如图片,也可能是几兆字节大小)。在这样做的时候,我们可能想给用户提供某种形式的实时进度更新,因为否则我们的应用程序的用户界面可能会显得很慢甚至没有反应。

不幸的是,没有一个基于闭合的或由Combine驱动的URLSession API为观察请求的持续进展提供直接支持,但幸运的是,我们可以使用良好的老式委托模式很容易地实现这一点。

为了证明这一点,让我们创建一个FileUploader 类(它需要是Objective-C的NSObject 的子类)。然后我们将使用一个自定义的 URLSession 实例,而不是shared ,因为这将使我们成为这个会话的委托人。然后,我们将定义一个API,让我们从一个给定的本地URL上传文件,我们将让API的调用者传递两个闭包--一个用于处理进度事件,以及一个标准的完成处理。最后,我们将根据每个上传任务的ID将所有进度事件处理程序存储在一个字典中,这样我们以后就可以在我们的委托协议实现中调用这些闭包。

class FileUploader: NSObject {
    // We'll define a few type aliases to make our code easier to read:
    typealias Percentage = Double
    typealias ProgressHandler = (Percentage) -> Void
    typealias CompletionHandler = (Result<Void, Error>) -> Void

    // Creating our custom URLSession instance. We'll do it lazily
    // to enable 'self' to be passed as the session's delegate:
    private lazy var urlSession = URLSession(
        configuration: .default,
        delegate: self,
        delegateQueue: .main
    )

    private var progressHandlersByTaskID = [Int : ProgressHandler]()

    func uploadFile(
        at fileURL: URL,
        to targetURL: URL,
        progressHandler: @escaping ProgressHandler,
        completionHandler: @escaping CompletionHandler
    ) {
        var request = URLRequest(
            url: targetURL,
            cachePolicy: .reloadIgnoringLocalCacheData
        )
        
        request.httpMethod = "POST"

        let task = urlSession.uploadTask(
            with: request,
            fromFile: fileURL,
            completionHandler: { data, response, error in
                // Validate response and call handler
                ...
            }
        )

        progressHandlersByTaskID[task.taskIdentifier] = progressHandler
        task.resume()
    }
}

接下来,让我们实现URLSessionTaskDelegate 协议,它是基础URLSessionDelegate 协议的一个专门版本,增加了一些额外的方法,使我们能够观察特定任务的事件。在这种情况下,我们只想在一个给定的URLSessionTask 的进度被更新时得到通知,这可以通过实现以下方法来实现:

extension FileUploader: URLSessionTaskDelegate {
    func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didSendBodyData bytesSent: Int64,
        totalBytesSent: Int64,
        totalBytesExpectedToSend: Int64
    ) {
        let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
        let handler = progressHandlersByTaskID[task.taskIdentifier]
        handler?(progress)
    }
}

有了上述方法,我们现在就可以使用传递到每个progressHandler 闭包中的百分比值来驱动任何我们想用来可视化上传进度的UI组件--例如ProgressViewUIProgressViewNSProgressIndicator

一个随时间变化的进度流

最后,让我们也来看看我们如何将上述FileUploader ,以使用Combine而不是多个闭包。毕竟,Combine以 "随时间变化的值 "为中心的设计非常适用于进度更新的建模,因为我们希望随时间变化发送一些百分比的值,然后以一个完成事件结束,这正是Combine发布器的作用。

虽然我们可以选择使用自定义发布器来实现这一功能,但在这种情况下,让我们使用CurrentValueSubject ,它提供了一种内置的方式来发送数值,然后被缓存并发送给每个新的订阅者。这样,我们可以将每个上传任务与一个给定的主题相关联(就像我们之前存储每个progressHandler 关闭一样),然后使用eraseToAnyPublisher API将该主题作为一个发布者返回--像这样:

class FileUploader: NSObject {
    typealias Percentage = Double
    typealias Publisher = AnyPublisher<Percentage, Error>
    
    private typealias Subject = CurrentValueSubject<Percentage, Error>

    private lazy var urlSession = URLSession(
        configuration: .default,
        delegate: self,
        delegateQueue: .main
    )

    private var subjectsByTaskID = [Int : Subject]()

    func uploadFile(at fileURL: URL,
                    to targetURL: URL) -> Publisher {
        var request = URLRequest(
            url: targetURL,
            cachePolicy: .reloadIgnoringLocalCacheData
        )
        
        request.httpMethod = "POST"

        let subject = Subject(0)
        var removeSubject: (() -> Void)?

        let task = urlSession.uploadTask(
            with: request,
            fromFile: fileURL,
            completionHandler: { data, response, error in
                // Validate response and send completion
                ...
                subject.send(completion: .finished)
                removeSubject?()
            }
        )

        subjectsByTaskID[task.taskIdentifier] = subject
        removeSubject = { [weak self] in
            self?.subjectsByTaskID.removeValue(forKey: task.taskIdentifier)
        }
        
        task.resume()
        
        return subject.eraseToAnyPublisher()
    }
}

现在剩下的就是更新我们的URLSessionTaskDelegate 实现,将每个进度值发送到与有关任务相关的主题,而不是调用一个闭包。

extension FileUploader: URLSessionTaskDelegate {
    func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didSendBodyData bytesSent: Int64,
        totalBytesSent: Int64,
        totalBytesExpectedToSend: Int64
    ) {
        let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
        let subject = subjectsByTaskID[task.taskIdentifier]
        subject?.send(progress)
    }
}

就这样,我们现在可以轻松地执行更简单的POST 请求和文件上传,并使用Combine或基于closure的API来处理进度事件。真的很好!

总结

虽然以上一系列的实现并不是一个完整的网络库,但它们希望能证明URLSession 所提供的内置功能往往是我们执行许多不同类型的请求所需要的,包括那些涉及发布数据或上传文件的请求。

我希望你觉得这篇文章很有用。