Alamofire-后台下载

2,322 阅读8分钟

上一篇文章提到了后台下载,下面看看在Alamofire中是如何处理后台下载的。首先使用原生写法来实现一个后台下载任务,在使用Alamofire来实现,通过对比来看看Alamofire的优势。

数据源地址:testapi.onapp.top/public/vide…

一、URLSession后台下载

首先需要创建会话并设置会话参数:

//1、配置请求参数
let configuration = URLSessionConfiguration.background(withIdentifier: "com.yahibo.background_id")
let session = URLSession.init(configuration: configuration,delegate: self,delegateQueue: OperationQueue.main)
//2、设置数据源
let videoUrl = "http://onapp.yahibo.top/public/videos/video.mp4"
let url = URL.init(string: videoUrl)!
//3、创建一个下载任务,并发起请求
session.downloadTask(with: url).resume()
  • 配置会话为background模式,开启后台下载功能
  • 创建下载任务并执行resume启动任务
  • 会话初始化设置代理后,任务回调只走代理方法,不会通过闭包进行数据回调,如果使用闭包回传也会报错提示
session.downloadTask(with: url) { (url, response, error) in
    print(url)
    print(response)
    print(error)
}.resume()
错误信息:Completion handler blocks are not supported in background sessions. Use a delegate instead.

在后台会话中不支持block块回调数据,要求使用代理,因此在后台下载中,我们直接使用代理方法来处理数据。代理方法如下:

extension Alamofire2Controller: URLSessionDownloadDelegate{
    //1、下载进度
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        print("下载进度:\(Double(totalBytesWritten)/Double(totalBytesExpectedToWrite))")
    }
    //2、下载完成
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        let locationPath = location.path
        print("下载完成:\(location.path)")
        //存储到用户目录
        let documents = NSHomeDirectory() + "/Documents/my.mp4"
        print("存储位置:\(documents)")
        //复制视频到目标地址
        let fileManager = FileManager.default
        try!fileManager.moveItem(atPath: locationPath, toPath: documents)
    }
}

实现了对下载任务进度的监听,下载任务完成的监听,在文件下载完成时首先会保存在沙盒中tmp文件下,该文件只存储临时数据,使用完后会自动清理,因此需要将tmp中下载的文件复制到Documents文件夹中存储。

通过打印的路径查看文件下载情况,以上操作实际并没有真正完成后台下载,应用返回后台,下载任务就已停止,进入前台才能看到下载完成,界面不能够及时更新。

下载进度:0.3653140762324527
下载进度:0.4018703091059228
2019-08-19 15:23:14.237923+0800 AlamofireDemo[849:9949] An error occurred on the xpc connection requesting pending callbacks for the background session: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.nsurlsessiond" UserInfo={NSDebugDescription=connection to service named com.apple.nsurlsessiond}
下载完成:/Users/hibo/Library/Developer/CoreSimulator/Devices/404EDFDD-735E-454B-A576-70268D8A17C0/data/Containers/Data/Application/E3175312-D6B8-4576-9B84-4EBD7751A4C0/Library/Caches/com.apple.nsurlsessiond/Downloads/com.yahibo.background_id/CFNetworkDownload_eo4RMO.tmp
存储位置:/Users/hibo/Library/Developer/CoreSimulator/Devices/404EDFDD-735E-454B-A576-70268D8A17C0/data/Containers/Data/Application/E3175312-D6B8-4576-9B84-4EBD7751A4C0/Documents/20190819152314.mp4

上篇文章有提到,苹果官方要求在进行后台任务下载时需要实现两个代理方法,来及时通知系统更新界面。

1、在AppDelegate中实现

var backgroundCompletionHandler: (()->Void)? = nil
//设置此处开启后台下载权限
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
    self.backgroundCompletionHandler = completionHandler
}
  • 开启后台下载权限,实现代理方法即为开通

2、在上面Alamofire2Controller扩展中实现代理方法

//后台任务下载回调
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    print("后台任务下载回来")
    DispatchQueue.main.async {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let backgroundHandle = appDelegate.backgroundCompletionHandler else { return }
        backgroundHandle()
    }
}
  • 后台任务完成会调用该方法,在该方法内部调用AppDelegate中的闭包,通知系统更新界面,否则会出现掉帧

添加以上方法再次运行下载,退出前台,等待几秒钟能够看到在控制台是有后台下载完成回调打印的,在该情况下,我们再次进入前台,我们的页面实际上已经被更新了。至此我们就完成了一个后台下载的功能。

总结:后台下载任务需要实现四个代理方法

控制器:

  • URLSessionDownloadTask:获取下载进度
  • didFinishDownloadingTo:下载完成处理下载文件
  • urlSessionDidFinishEvents:后台下载完成调用,提示系统及时更新界面,执行Application中的闭包函数

Application:

  • backgroundCompletionHandler:后台下载完成接收通知消息的闭包

从多年的开发经验来看(太装了😂),以上这种实现方式其实不是理想结果,功能代码分散。下面就看一下Alamofire是如何实现的。

二、Alamofire后台下载

Alamofire.request(url,method: .post,parameters: ["page":"1","size":"20"]).responseJSON {
    (response) in
    switch response.result{
    case .success(let json):
        print("json:\(json)")
        break
    case .failure(let error):
        print("error:\(error)")
        break
    }
}

在以上代码中,Alamofire可以直接通过request发送请求,同样在框架中也存在download方法来完成下载任务。查看官方文档

//下载文件
Alamofire.download(url, to: { (url, response) -> (destinationURL: URL, options: DownloadRequest.DownloadOptions) in
    let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    let fileURL = documentsURL.appendingPathComponent("\(self.currentDateStr()).mp4")
    return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
})
.downloadProgress { (progress) in
    print(progress)
}.response(queue: DispatchQueue.global(qos: .utility), completionHandler: { (response) in
    print("完成下载:\(response)")
})
  • DownloadRequest.DownloadOptions:设置下载文件的存储地
  • downloadProgress:获取下载进度

以上虽然可以下载我们需要的文件,但是不能在后台下载。首先官方指出:

The Alamofire.download APIs should also be used if you need to download data while your app is in the background. For more information, please see the Session Manager Configurations section.

需要我们手动配置会话为background模式,而在以上使用的download中实际上使用的是default模式,并不能支持后台下载。如下代码:

public static let `default`: SessionManager = {
    let configuration = URLSessionConfiguration.default
    configuration.httpAdditionalHeaders = SessionManager.defaultHTTPHeaders

    return SessionManager(configuration: configuration)
}()

通过官方文档和源码的查看,实际上我们只需要重新设置会话的配置信息就可以了。

修改会话模式

let configuration = URLSessionConfiguration.background(withIdentifier:"com.yahibo.background_id")
configuration.httpAdditionalHeaders = SessionManager.defaultHTTPHeaders
sessionManager = SessionManager(configuration: configuration)

以上sessionManager需要设置为一个单例对象,以便于在后台下载模式中接收Appdelegate的代理闭包函数,通过闭包通知系统及时更新界面。代码如下:

struct BackgroundManager {
    static let shared = BackgroundManager()
    let manager: SessionManager = {
        let configuration = URLSessionConfiguration.background(withIdentifier:"com.yahibo.background_id")
        configuration.httpAdditionalHeaders = SessionManager.defaultHTTPHeaders
        return SessionManager(configuration: configuration)
    }()
}

下面就开始实现下载功能:

BackgroundManager.shared.manager.download(url) { (url, response) -> (destinationURL: URL, options: DownloadRequest.DownloadOptions) in
        let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let fileURL = documentsURL.appendingPathComponent("\(self.currentDateStr()).mp4")
        return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
    }.downloadProgress(queue: DispatchQueue.global(qos: .utility)) { (progress) in
        print(progress)
    }.response(queue: DispatchQueue.global(qos: .utility), completionHandler: { (response) in
        print("完成下载:\(response)")
    })
  • 同上直接调用download方法来下载,并存储数据

应苹果要求我们还需要调用handleEventsForBackgroundURLSession中的的代码块,通知系统及时更新界面,在SessionManager中如何做连接呢。代码如下:

//设置此处开启后台下载权限
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
    BackgroundManager.shared.manager.backgroundCompletionHandler = completionHandler
}
  • SessionManager中已经备好了需要的backgroundCompletionHandler代码块声明,以便接收闭包,调用闭包

简单几步就实现了我们想要的后台下载功能了,编码简洁,逻辑清晰。这里我们只在Application中实现了开启后台下载权限的代理,但并没有在控制器中设置delegate和实现urlSessionDidFinishEvents代理方法,这里不难猜测URLSessionDownloadTask、didFinishDownloadingTo、urlSessionDidFinishEvents代理方法应该是在SessionManager中实现,统一管理再以闭包的形式回传到当前界面。下面就看一下SessionManager是不是这么实现的。

三、SessionManager源码探索

首先顺着SessionManager的创建找到类中的初始化方法:

public init(
    configuration: URLSessionConfiguration = URLSessionConfiguration.default,
    delegate: SessionDelegate = SessionDelegate(),
    serverTrustPolicyManager: ServerTrustPolicyManager? = nil)
{
    self.delegate = delegate
    self.session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)

    commonInit(serverTrustPolicyManager: serverTrustPolicyManager)
}

初始化有三个初始参数,并设有缺省值,该方法返回一个新的SessionManager对象。在上面后台下载中我们只配置了configuration参数,设置为了后台下载模式。上面也提到了,在SessionManager中应该是有我们的后台下载相关的代理实现,在该函数中看到初始化了一个SessionDelegate对象,并将URLSession的代理实现指向了SessionDelegate对象,不难猜出URLSession相关的代理方法应该都在SessionDelegate类中实现。

SessionDelegate

SessionDelegate.swift中,SessionDelegate继承自NSObject,声明了所有与URLSession代理相关连的闭包函数,用来向界面回传代理事件产生的结果。

在扩展方法中实现了以下几个代理的方法:

URLSessionDelegate
URLSessionTaskDelegate
URLSessionDataDelegate
URLSessionDownloadDelegate
URLSessionStreamDelegate

下面就看一下下载相关的代理方法内部实现了哪些功能。代码如下:

extension SessionDelegate: URLSessionDownloadDelegate {

    open func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL)
    {
        if let downloadTaskDidFinishDownloadingToURL = downloadTaskDidFinishDownloadingToURL {
            downloadTaskDidFinishDownloadingToURL(session, downloadTask, location)
        } else if let delegate = self[downloadTask]?.delegate as? DownloadTaskDelegate {
            delegate.urlSession(session, downloadTask: downloadTask, didFinishDownloadingTo: location)
        }
    }

    open func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didWriteData bytesWritten: Int64,
        totalBytesWritten: Int64,
        totalBytesExpectedToWrite: Int64)
    {
        if let downloadTaskDidWriteData = downloadTaskDidWriteData {
            downloadTaskDidWriteData(session, downloadTask, bytesWritten, totalBytesWritten, totalBytesExpectedToWrite)
        } else if let delegate = self[downloadTask]?.delegate as? DownloadTaskDelegate {
            delegate.urlSession(
                session,
                downloadTask: downloadTask,
                didWriteData: bytesWritten,
                totalBytesWritten: totalBytesWritten,
                totalBytesExpectedToWrite: totalBytesExpectedToWrite
            )
        }
    }

    open func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didResumeAtOffset fileOffset: Int64,
        expectedTotalBytes: Int64)
    {
        if let downloadTaskDidResumeAtOffset = downloadTaskDidResumeAtOffset {
            downloadTaskDidResumeAtOffset(session, downloadTask, fileOffset, expectedTotalBytes)
        } else if let delegate = self[downloadTask]?.delegate as? DownloadTaskDelegate {
            delegate.urlSession(
                session,
                downloadTask: downloadTask,
                didResumeAtOffset: fileOffset,
                expectedTotalBytes: expectedTotalBytes
            )
        }
    }
}

以上三个方法用来监控下载进度,及下载是否完成,在回调内部通过闭包回调代理事件到主界面。该文件中实现了上面提到的代理的所有方法,通过声明的闭包向外界传值,在外部只需要调用闭包即可。这里和外界桥接的闭包函数返回一个self,因此能够以链式的形式,来获取代理传来的数据。如下:

open func downloadProgress(queue: DispatchQueue = DispatchQueue.main, closure: @escaping ProgressHandler) -> Self {
    downloadDelegate.progressHandler = (closure, queue)
    return self
}
  • 桥接界面与内部SessionDelegate扩展代理,完成下载进度的监听

其他桥接方法省略……

针对后台下载找到了继承自URLSessionDelegate的扩展:

extension SessionDelegate: URLSessionDelegate {
    open func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        sessionDidFinishEventsForBackgroundURLSession?(session)
    }
}

后台下载完成,会执行该方法,在该方法中,调用了外界实现的闭包,此闭包实现在SessionManager中,如下:

private func commonInit(serverTrustPolicyManager: ServerTrustPolicyManager?) {
    session.serverTrustPolicyManager = serverTrustPolicyManager

    delegate.sessionManager = self

    delegate.sessionDidFinishEventsForBackgroundURLSession = { [weak self] session in
        guard let strongSelf = self else { return }
        DispatchQueue.main.async { strongSelf.backgroundCompletionHandler?() }
    }
}
  • SessionDelegate中传入self,此处出现循环引用,这里的delegate.sessionManager使用weak修饰解决
  • 实现delegate中后台下载完成回调闭包,在此处接收后台下载完成消息
  • 在主线程中,调用backgroundCompletionHandler将消息发送至backgroundCompletionHandler的闭包实现

这里应该就清楚了,backgroundCompletionHandlerSessionManager声明的闭包,在Application中获取系统闭包实现,用来与系统通讯,告诉系统在后台及时更新界面。