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会崩溃。
正确的做法:
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的切换器快照,用户看到的就是旧界面。
4.8 避坑指南
最后,总结几个我踩过的坑:
- 不要重复创建相同identifier的session:如果App重启后,系统会尝试恢复之前的后台session。如果你用相同的identifier创建新session,会导致回调混乱。建议在App启动时只创建一次。
- 后台下载不支持dataTask:只能用downloadTask或uploadTask。我见过有人用dataTask做后台下载,结果App一进后台就失败。
- 临时文件路径只在回调中有效:出了回调方法,文件就可能被删除。所以一定要在回调里完成移动操作。
- 后台下载的进度回调可能不准确:尤其是小文件,可能直接跳到100%。别太依赖进度条做精确逻辑。
嗯,这一章的内容就这些。说白了,后台下载小文件的核心就三步:创建后台session、发起downloadTask、在回调里移动文件并切主线程更新UI。你想想看,是不是比想象中简单?
下一章我们会讲大文件断点续传,那才是真正考验功底的地方。到时候见。