4、后台任务实战:下载小文件:使用URLSession进行后台下载、处理下载完成回调、更新UI的注意事项

好,咱们进入实战环节。

前面讲了那么多后台任务的原理和配置,现在该动手了。这一章我们聚焦一个最典型的场景——后台下载小文件。比如用户要离线缓存几篇图文、几张图片,或者一个几十MB的PDF。这种任务不大不小,但用后台下载来做,体验会好很多。

我个人习惯把这类任务叫做「轻量级离线化」。说白了,就是让用户点一下,然后App可以切到后台,甚至锁屏,下载依然在跑。等下载完了,给个通知,用户回来就能用。

4.1 为什么选URLSession做后台下载?

你可能会问:iOS里下载文件的方式不少,为什么非要用URLSession?

嗯,这里要注意:只有URLSession支持真正的后台下载。其他方式,比如直接用Data(contentsOf:),或者用Alamofire的普通请求,一旦App进入后台,下载就会被系统挂起。而URLSession的后台配置(.background)会让系统单独开一个守护进程来帮你下载,哪怕你的App被系统杀掉了,下载依然继续。

我在项目中遇到过这种情况:用户下载一个50MB的离线地图包,切出去回微信,过了十分钟回来,下载已经完成了。这就是后台URLSession的功劳。

4.2 创建后台下载的URLSession

第一步,创建一个后台配置的session。注意,这个session的identifier必须是唯一的,通常用bundle id加后缀。

let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.backgroundDownload")
config.isDiscretionary = true  // 让系统在合适时机下载,省电
config.sessionSendsLaunchEvents = true  // 下载完成时唤醒App
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

这里有个小细节:isDiscretionary设为true,系统会在Wi-Fi环境下、电量充足时才开始下载。如果你的文件不大,用户又急着用,可以设为false。我个人建议小文件设为false,大文件设为true。

4.3 发起后台下载任务

创建好session后,发起下载任务很简单:

let url = URL(string: "https://example.com/smallfile.pdf")!
let task = session.downloadTask(with: url)
task.resume()

就这么三行。但注意,不要用dataTask,因为dataTask不支持后台下载。downloadTask会把文件下载到一个临时目录,然后回调告诉你临时文件路径。

4.4 处理下载完成回调

下载完成后,系统会回调URLSessionDownloadDelegate的方法。这里是最容易踩坑的地方。

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
    // location是临时文件路径
    // 必须立即移动到持久目录,否则会被系统删除
    let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
    let destinationURL = documentsPath.appendingPathComponent("smallfile.pdf")
    
    do {
        try FileManager.default.moveItem(at: location, to: destinationURL)
        print("下载完成,文件保存在:\(destinationURL)")
    } catch {
        print("移动文件失败:\(error)")
    }
}

我曾经犯过一个错误:在回调里只打印了路径,没有立即移动文件。结果用户打开App发现文件不存在。为什么?因为系统会在回调结束后几秒内清理临时文件。所以,拿到location后,第一时间move到自己的沙盒目录。

4.5 处理下载完成后的UI更新

文件下载完了,怎么通知UI更新?这里有个大坑:后台回调不一定在主线程

URLSession的delegate方法默认在后台线程调用。如果你直接在里面更新UI,比如刷新列表、显示图片,App会崩溃。

警告:不要在URLSession的delegate回调中直接更新UI!必须切换到主线程。

正确的做法:

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
    // 先处理文件移动(可以在后台线程)
    // ...
    
    // 然后切到主线程更新UI
    DispatchQueue.main.async {
        // 更新下载进度条为100%
        // 刷新列表显示已下载
        // 或者弹出提示
        NotificationCenter.default.post(name: .downloadCompleted, object: nil)
    }
}

我个人习惯用NotificationCenter来广播下载完成事件,这样多个页面都能响应。当然,用delegate或者closure回调也行,看你的架构。

4.6 处理下载进度

用户喜欢看到进度条在动。URLSession也提供了进度回调:

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
    let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
    DispatchQueue.main.async {
        // 更新UI上的进度条
        self.progressView.progress = progress
    }
}

注意,这个回调非常频繁,尤其是下载小文件时。我建议不要每收到一次回调就刷新UI,可以加个节流:

// 每0.5秒更新一次UI
let currentTime = Date().timeIntervalSince1970
if currentTime - lastUpdateTime > 0.5 {
    DispatchQueue.main.async {
        self.progressView.progress = progress
    }
    lastUpdateTime = currentTime
}

4.7 后台下载完成时唤醒App

如果App在后台,下载完成了怎么办?系统会唤醒App,调用AppDelegate的:

func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
    // 保存completionHandler,等所有任务完成后调用
    self.backgroundCompletionHandler = completionHandler
}

然后在URLSession的urlSessionDidFinishEvents(forBackgroundURLSession:)中调用这个handler:

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    DispatchQueue.main.async {
        if let handler = self.backgroundCompletionHandler {
            handler()
        }
    }
}

这个handler的作用是告诉系统:「我已经处理完下载事件了,你可以更新快照了。」如果不调用,系统可能会延迟更新App的切换器快照,用户看到的就是旧界面。

小技巧:可以在下载完成后,发送一个本地通知,告诉用户「下载完成,可以离线查看了」。这样即使用户没在App里,也能知道进度。

4.8 避坑指南

最后,总结几个我踩过的坑:

  • 不要重复创建相同identifier的session:如果App重启后,系统会尝试恢复之前的后台session。如果你用相同的identifier创建新session,会导致回调混乱。建议在App启动时只创建一次。
  • 后台下载不支持dataTask:只能用downloadTask或uploadTask。我见过有人用dataTask做后台下载,结果App一进后台就失败。
  • 临时文件路径只在回调中有效:出了回调方法,文件就可能被删除。所以一定要在回调里完成移动操作。
  • 后台下载的进度回调可能不准确:尤其是小文件,可能直接跳到100%。别太依赖进度条做精确逻辑。

嗯,这一章的内容就这些。说白了,后台下载小文件的核心就三步:创建后台session、发起downloadTask、在回调里移动文件并切主线程更新UI。你想想看,是不是比想象中简单?

下一章我们会讲大文件断点续传,那才是真正考验功底的地方。到时候见。