iOS开发Swift

Swift – 实现大文件的后台上传功能(附样例)

    我之前写过一篇文章介绍如何实现大文件的后台下载(点击查看),本文接着演示后台上传功能。也就是说当程序退到后台(比如按下 home 键、或者切到其它应用上)时,当前的上传任务不会立刻停止,而是会继续进行。

1,实现原理

(1)URLSessionConfiguration 有如下三种模式:

  • default:默认会话模式(使用的是基于磁盘缓存的持久化策略)
  • ephemeral:暂时会话模式(该模式不使用磁盘保存任何数据。而是保存在 RAM 中,因此当程序使会话无效,这些缓存的数据就会被自动清空。)
  • background:后台会话模式(该模式可以在后台完成上传和下载)

(2)之前我们使用的都是 default 模式,要实现后台上传就必须使用 background 模式:

  • 当 app 被终止时,系统会接管上传任务。
  • 等到上传完成或需要 app 关注时,系统又会在后台唤醒 app(注意是后台唤醒,该 app 不会切换到前台显示)。
  • 然后 app 再进行后续处理。

特别注意:如果应用被强制关闭(双击 home 调出后台列表,并将其上滑关闭),那么后台下载就不再起作用了。

2,准备工作

(1)为方便使用,首先我们封装一个后台上传的工具类(UploadManager.swift),具体内容如下:

  • 创建一个 background session 用于后台上传(需指定一个 identifier
  • 实现 URLSessionDelegateURLSessionDownloadDelegate 相关的代理协议方法(比如上传进度反馈,上传完成后的提示等)
import UIKit
import MobileCoreServices
 
class UploadManager: NSObject, URLSessionDelegate, URLSessionTaskDelegate,
URLSessionDataDelegate {
     
    //单例模式
    static var shared = UploadManager()
     
    //上传进度回调
    var onProgress: ((Float) -> ())?
     
    //background session
    lazy var session:URLSession = {
        //只执行一次
        let config = URLSessionConfiguration.background(withIdentifier:
            "background-session")
        let currentSession = URLSession(configuration: config, delegate: self,
                                        delegateQueue: nil)
        return currentSession
    }()
     
    //上传代理方法,监听上传进度
    func urlSession(_ session: URLSession, task: URLSessionTask,
                    didSendBodyData bytesSent: Int64, totalBytesSent: Int64,
                    totalBytesExpectedToSend: Int64) {
        //获取进度
        let written = (Float)(totalBytesSent)
        let total = (Float)(totalBytesExpectedToSend)
        let pro = written/total
        if let onProgress = onProgress {
            onProgress(pro)
        }
    }
     
    //上传代理方法,传输完毕后服务端返回结果
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        let str = String(data: data, encoding: String.Encoding.utf8)
        print("服务端返回结果:\(str!)")
    }
     
    //上传代理方法,上传结束
    func urlSession(_ session: URLSession, task: URLSessionTask,
                    didCompleteWithError error: Error?) {
        print("上传结束!")
    }
     
    //session完成事件
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        //主线程调用
        DispatchQueue.main.async {
            if let appDelegate = UIApplication.shared.delegate as? AppDelegate,
                let completionHandler = appDelegate.backgroundSessionCompletionHandler {
                appDelegate.backgroundSessionCompletionHandler = nil
                //调用此方法告诉操作系统,现在可以安全的重新suspend你的app
                completionHandler()
            }
        }
    }
     
    //创建请求
    static func createRequest(url: URL,
                              parameters: [String: String]?,
                              files: [(name:String, path:String)]) -> URLRequest{
        //分隔线
        let boundary = "Boundary-\(UUID().uuidString)"
         
        //上传地址
        var request = URLRequest(url: url)
        //请求类型为POST
        request.httpMethod = "POST"
        request.setValue("multipart/form-data; boundary=\(boundary)",
            forHTTPHeaderField: "Content-Type")
         
        //创建表单body
        request.httpBody = try! createBody(with: parameters, files: files, boundary: boundary)
        return request
    }
     
    //创建表单body
    static func createBody(with parameters: [String: String]?,
                           files: [(name:String, path:String)],
                           boundary: String) throws -> Data {
        var body = Data()
         
        //添加普通参数数据
        if parameters != nil {
            for (key, value) in parameters! {
                // 数据之前要用 --分隔线 来隔开 ,否则后台会解析失败
                body.append("--\(boundary)\r\n")
                body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n")
                body.append("\(value)\r\n")
            }
        }
         
        //添加文件数据
        for file in files {
            let url = URL(fileURLWithPath: file.path)
            let filename = url.lastPathComponent
            let data = try Data(contentsOf: url)
            let mimetype = mimeType(pathExtension: url.pathExtension)
             
            // 数据之前要用 --分隔线 来隔开 ,否则后台会解析失败
            body.append("--\(boundary)\r\n")
            body.append("Content-Disposition: form-data; "
                + "name=\"\(file.name)\"; filename=\"\(filename)\"\r\n")
            body.append("Content-Type: \(mimetype)\r\n\r\n") //文件类型
            body.append(data) //文件主体
            body.append("\r\n") //使用\r\n来表示这个这个值的结束符
        }
         
        // --分隔线-- 为整个表单的结束符
        body.append("--\(boundary)--\r\n")
        return body
    }
     
    //根据后缀获取对应的Mime-Type
    static func mimeType(pathExtension: String) -> String {
        if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension,
                                                           pathExtension as NSString,
                                                           nil)?.takeRetainedValue() {
            if let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?
                .takeRetainedValue() {
                return mimetype as String
            }
        }
        //文件资源类型如果不知道,传万能类型application/octet-stream,服务器会自动解析文件类
        return "application/octet-stream"
    }
}
 
//扩展Data
extension Data {
    //增加直接添加String数据的方法
    mutating func append(_ string: String, using encoding: String.Encoding = .utf8) {
        if let data = string.data(using: encoding) {
            append(data)
        }
    }
}

(2)同时 AppDelegate.swift 中要做如下修改:

  • 后台上传完毕后会调用 handleEventsForBackgroundURLSession 方法。
  • 我们在此用提供的 identifier 创建新的 URLSessionConfiguration 和 URLSession 对象。
  • 然后将新的 session 对象重新连接到先前的任务,并调用相应的 delegate

注意:在前面 UploadManager 里的 urlSessionDidFinishEvents() 这个 session 代理方法中,我们需要要在主线程里调用 AppDelegate 里保存的 completionHandler。这样就会告诉操作系统,现在可以安全的重新 suspend 我们的 app 了。

import UIKit
 
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
     
    //用于保存后台上传的completionHandler
    var backgroundSessionCompletionHandler: (() -> Void)?
 
    var window: UIWindow?
     
    //后台上传完毕后会调用(我们将其交由上传工具类做后续处理)
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
 
        //用于保存后台上传的completionHandler
        backgroundSessionCompletionHandler = completionHandler
         
        //创建upload session
        let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
        let uploadSession = URLSession(configuration: configuration,
                                          delegate: UploadManager.shared,
                                          delegateQueue: nil)
         
        //指定upload session
        UploadManager.shared.session = uploadSession
    }
 
    func application(_ application: UIApplication, didFinishLaunchingWithOptions
        launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }
 
    func applicationWillResignActive(_ application: UIApplication) {
    }
 
    func applicationDidEnterBackground(_ application: UIApplication) {
    }
 
    func applicationWillEnterForeground(_ application: UIApplication) {
    }
 
    func applicationDidBecomeActive(_ application: UIApplication) {
    }
 
    func applicationWillTerminate(_ application: UIApplication) {
    }
}
3,使用样例

(1)客户端代码(ViewController.swift

import UIKit
 
class ViewController: UIViewController {
     
    @IBAction func startUpload(_ sender: Any) {
         
        //传递的参数
        let parameters = [
            "value1": "hangge.com",
            "value2": "1234"
        ]
         
        //传递的文件
        let files = [
            (
                name: "file1",
                path:Bundle.main.path(forResource: "test1", ofType: "zip")!
            ),
            (
                name: "file2",
                path:Bundle.main.path(forResource: "test3", ofType: "zip")!
            )
        ]
         
        //上传地址
        let url = URL(string: "http://www.hangge.com/upload.php")!
         
        //创建request
        let request = UploadManager.createRequest(url: url, parameters: parameters, files: files)
         
        //创建上传任务
        let uploadTask = UploadManager.shared.session.uploadTask(withStreamedRequest: request)
         
        //使用resume方法启动任务
        uploadTask.resume()
         
        //实时打印出上传进度
        UploadManager.shared.onProgress = { (progress) in
            OperationQueue.main.addOperation {
                print("上传进度:\(progress)")
            }
        }
    }
}

(2)服务端代码(upload.php):

<?
$value1 = $_POST["value1"];
$value2 = $_POST["value2"];
  
move_uploaded_file($_FILES["file1"]["tmp_name"],
    $_SERVER["DOCUMENT_ROOT"]."/uploadFiles/" . $_FILES["file1"]["name"]);
 
move_uploaded_file($_FILES["file2"]["tmp_name"],
    $_SERVER["DOCUMENT_ROOT"]."/uploadFiles/" . $_FILES["file2"]["name"]);
 
echo "\r\n两个参数为:".$value1.",".$value2;
echo "\r\n两个文件为:". $_FILES["file1"]["name"].",".$_FILES["file2"]["name"];
?>

(3)运行效果:

  • 点击界面上的按钮开始上传,可以看到控制台中不断地打印出上传进度。
  • 按下 home 键将应用退到后台。虽然控制台不再继续打印上传进度,但事实上后台仍在继续上传数据,并在上传完毕后自动调用相关方法。
注意timeoutIntervalForResource和timeoutIntervalForRequest的区别

timeoutIntervalForResource是表示数据没有在指定的时间里面加载完,默认值是7天。

timeoutIntervalForRequest是表示在下载过程中,如果某段时间之内一直都没有接收到数据,那么就认为超时。

举个例子就是,如果你要下一个10G的数据,timeoutIntervalForResource设置成7天的话,你的网速特别慢:0.1k/s,7天都没下载完,那就超时了。虽然整个过程中,你一直在源源不断地下载。

如果你要下一个10G的数据,timeoutIntervalForRequest设置为20秒的话,下的过程中有超过20s的时间段并没有数据过来,那么这时候就也算超时。

 let config = URLSessionConfiguration.background(withIdentifier: ("\(Date().timeIntervalSince1970)_\(partInfoId)"))
        config.timeoutIntervalForRequest = 30
        config.timeoutIntervalForResource = 300
        
        let session = URLSession(configuration: config, delegate: appDelegateComm, delegateQueue: OperationQueue.main)