Swift--URLsession后台下载

2,198 阅读7分钟

前言

URLSession是一个可以响应发送或者接受HTTP请求的关键类。首先使用全局的 URLSession.shareddownloadTask 来创建一个简单的下载任务:

let url = URL(string: "https://mobileappsuat.pwchk.com/MobileAppsManage/UploadFiles/20190719144725271.png")
let request = URLRequest(url: url!)
let session = URLSession.shared
let downloadTask = session.downloadTask(with: request,
       completionHandler: { (location:URL?, response:URLResponse?, error:Error?)
        -> Void in
        print("location:\(location)")
        let locationPath = location!.path
        let documnets:String = NSHomeDirectory() + "/Documents/1.png"
        let fileManager = FileManager.default
        try! fileManager.moveItem(atPath: locationPath, toPath: documnets)
        print("new location:\(documnets)")
    })
downloadTask.resume()

可以看到这里的下载是前台下载,也就是说如果程序退到后台(比如按下 home 键、或者切到其它应用程序上),当前的下载任务便会立刻停止,这个样话对于一些较大的文件,下载过程中用户无法切换到后台,对用户来说是一种不太友好的体验。下面来看一下在后台下载的具体实现:

URLsession后台下载

我们可以通过URLSessionConfiguration类新建URLSession实例,而URLSessionConfiguration这个类是有三种模式的:

URLSessionConfiguration 的三种模如下式:

  • default:默认会话模式(使用的是基于磁盘缓存的持久化策略,通常使用最多的也是这种模式,在default模式下系统会创建一个持久化的缓存并在用户的钥匙串中存储证书)
  • ephemeral:暂时会话模式(该模式不使用磁盘保存任何数据。而是保存在 RAM 中,所有内容的生命周期都与session相同,因此当session会话无效时,这些缓存的数据就会被自动清空。)
  • background:后台会话模式(该模式可以在后台完成上传和下载。)

注意:background模式与default模式非常相似,不过background模式会用一个独立线程来进行数据传输。background模式可以在程序挂起,退出,崩溃的情况下运行task。也可以在APP下次启动的时候,利用标识符来恢复下载。

下面先来创建一个后台下载的任务background session,并且指定一个 identifier

let urlstring = URL(string: "https://dldir1.qq.com/qqfile/QQforMac/QQ_V6.5.5.dmg")!

// 第一步:初始化一个background后台模式的会话配置configuration
let configuration = URLSessionConfiguration.background(withIdentifier: "com.Henry.cn")
 
// 第二步:根据配置的configuration,初始化一个session会话
let session = URLSession.init(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main)

// 第三步:传入URL,创建downloadTask下载任务,开始下载
session.downloadTask(with: url).resume()

接下来实现session的下载代理URLSessionDownloadDelegateURLSessionDelegate的方法:

extension ViewController:URLSessionDownloadDelegate{
    // 下载代理方法,下载结束
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        // 下载完成 - 开始沙盒迁移
        print("下载完成地址 - \(location)")
        let locationPath = location.path
        //拷贝到用户目录
        let documnets = NSHomeDirectory() + "/Documents/" + "com.Henry.cn" + ".dmg"
        print("移动到新地址:\(documnets)")
        //创建文件管理器
        let fileManager = FileManager.default
        try! fileManager.moveItem(atPath: locationPath, toPath: documnets)

    }
    //下载代理方法,监听下载进度
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        print(" bytesWritten \(bytesWritten)\n totalBytesWritten \(totalBytesWritten)\n totalBytesExpectedToWrite \(totalBytesExpectedToWrite)")
        print("下载进度: \(Double(totalBytesWritten)/Double(totalBytesExpectedToWrite))\n")
    }
}

设置完这些代码之后,还不能达到后台下载的目的,还需要在AppDelegate中开启后台下载的权限,实现handleEventsForBackgroundURLSession方法:

class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    //用于保存后台下载的completionHandler
    var backgroundSessionCompletionHandler: (() -> Void)?
    func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
        self.backgroundSessionCompletionHandler = completionHandler
    }
}

实现到这里已基本实现了后台下载的功能,在应用程序切换到后台之后,session 会和 ApplicationDelegate 做交互,session 中的task还会继续下载,当所有的task完成之后(无论下载失败还是成功),系统都会调用ApplicationDelegateapplication:handleEventsForBackgroundURLSession:completionHandler:回调,在处理事件之后,在 completionHandler参数中执行 闭包,这样应用程序就可以获取用户界面的刷新。

如果我们查看handleEventsForBackgroundURLSession这个api的话,会发现苹果文档要求在实现下载完成后需要实现URLSessionDidFinishEvents的代理,以达到更新屏幕的目的。

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    print("后台任务")
    DispatchQueue.main.async {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let backgroundHandle = appDelegate.backgroundSessionCompletionHandler else { return }
        backgroundHandle()
    }
}

如果没有实现此方法的话⚠️️:后台下载的实现是不会有影响的,只是在应用程序由后台切换到前台的过程中可能会造成卡顿或者掉帧,同时可能在控制台输出警告:

Alamofire后台下载

通过上面的例子🌰会发现如果要实现一个后台下载,需要写很多的代码,同时还要注意后台下载权限的开启,完成下载之后回调的实现,漏掉了任何一步,后台下载都不可能完美的实现,下面就来对比一下,在Alamofire中是怎么实现后台下载的。

首先先创建一个ZHBackgroundManger的后台下载管理类:

struct ZHBackgroundManger {    
    static let shared = ZHBackgroundManger()

    let manager: SessionManager = {
        let configuration = URLSessionConfiguration.background(withIdentifier: "com.Henry.AlamofireDemo")
        configuration.httpAdditionalHeaders = SessionManager.defaultHTTPHeaders
        configuration.timeoutIntervalForRequest = 10
        configuration.timeoutIntervalForResource = 10
        configuration.sharedContainerIdentifier = "com.Henry.AlamofireDemo"
        return SessionManager(configuration: configuration)
    }()
}

后台下载的实现:

ZHBackgroundManger.shared.manager
    .download(self.urlDownloadStr) { (url, response) -> (destinationURL: URL, options: DownloadRequest.DownloadOptions) in
    let documentUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
    let fileUrl     = documentUrl?.appendingPathComponent(response.suggestedFilename!)
    return (fileUrl!,[.removePreviousFile,.createIntermediateDirectories])
    }
    .response { (downloadResponse) in
        print("下载回调信息: \(downloadResponse)")
    }
    .downloadProgress { (progress) in
        print("下载进度 : \(progress)")
}

并在AppDelegate做统一的处理:

func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
    ZHBackgroundManger.shared.manager.backgroundCompletionHandler = completionHandler
}

这里可能会有疑问🤔,为甚么要创建一个ZHBackgroundManger单例类?

那么下面就带着这个疑问❓来探究一下

如果点击ZHBackgroundManger.shared.manager.download这里的manager会发现这是SessionManager,那么就跟进去SessionManager的源码来看一下:

可以看到在SessionManagerdefault方法中,是对URLSessionConfiguration做了一些配置,并初始化SessionManager.

那么再来看SessionManager的初始化方法:

SessionManagerinit初始化方法中,可以看到这里把URLSessionConfiguration设置成default模式,在内容的前篇,在创建一个URLSession的后台下载的时候,我们已经知道需要把URLSessionConfiguration设置成background模式才可以。

在初始化方法里还有一个SessionDelegatedelegate,而且这个delegate被传入到URLSession中作为其代理,并且session的这个初始化也就使得以后的回调都将会由 self.delegate 来处理了。也就是SessionManager实例创建一个SessionDelegate对象来处理底层URLSession生成的不同类型的代理回调。(这又称为代理移交)。

代理移交之后,在commonInit()的方法中会做另外的一些配置信息:

在这里delegate.sessionManager被设置为自身 self,而 self其实是持有 delegate 的。而且 delegatesessionManagerweak属性修饰符。

这里这么写delegate.sessionManager = self的原因是

  • delegate在处理回调的时候可以和sessionManager进行通信
  • delegate将不属于自己的回调处理重新交给sessionManager进行再次分发
  • 减少与其他逻辑内容的依赖

而且这里的delegate.sessionDidFinishEventsForBackgroundURLSession闭包,只要后台任务下载完成就会回调到这个闭包内部,在闭包内部,回调了主线程,调用了 backgroundCompletionHandler,这也就是在AppDelegateapplication:handleEventsForBackgroundURLSession方法中的completionHandler。至此,SessionManager的流程大概就是这样。

对于上面的疑问:

  • 1. 通过源码我们可以知道SessionManager在设置URLSessionConfiguration的默认的是default模式,因为需要后台下载的话,就需要把URLSessionConfiguration的模式修改为background模式。包括我们也可以修改URLSessionConfiguration其他的配置
  • 2. 在下载的时候,应用程序进入到后台下载,如果对于上面的配置,不做成一个单例的话,或者没有被持有的情况下,在进入后台后就会被释放掉,从而会产生错误Error Domain=NSURLErrorDomain Code=-999 "cancelled"
  • 3. 而且将SessionManager重新包装成一个单例后,在AppDelegate中的代理方法中可以直接使用。

总结

  • 首先在 AppDelegateapplication:handleEventsForBackgroundURLSession的方法里,把回调闭包completionHandler传给了 SessionManagerbackgroundCompletionHandler.
  • 在下载完成的时候 SessionDelegateurlSessionDidFinishEvents代理的调用会触发 SessionManagersessionDidFinishEventsForBackgroundURLSession代理的调用
  • 然后sessionDidFinishEventsForBackgroundURLSession 执行SessionManagerbackgroundCompletionHandler的闭包.
  • 最后会来到 AppDelegateapplication:handleEventsForBackgroundURLSession的方法里的 completionHandler 的调用.

关于Alamofire后台下载的代码就分析到这里,其实通过源码发现,和利用URLSession进行后台下载原理是大致相同的,只不过利用Alamofire使代码看起来更加简介,而且Alamofire中会有很多默认的配置,我们只需要修改需要的配置项即可。