简介:Swift开发者在从服务器获取并存储文件时,常需要一套可靠安全的下载流程,这份工程正是围绕这一需求整理的实战参考。它重点展示了如何发起下载请求、处理响应数据、监听文件下载进度以及取消下载任务,适合对iOS网络编程已有基础、希望快速掌握Alamofire下载用法的中初级开发者。压缩包共包含一百七十七个文件,其中七十四个Swift源文件给出主要功能代码,十二个属性列表和六个工程配置文件辅助还原项目环境,整体大小约四百六十六千字节,目录结构清晰,目前已有三百八十人学习下载。通过对照工程实现,可以理解下载完成后的文件保存路径写法、进度回调的接入方式以及任务取消机制,并能够基于本地示例项目继续扩展网络错误处理、权限选择等真实业务场景。对想在应用中安全保存远程文件的开发者而言,这套代码能帮助快速绕过常见坑点,减少调试时间。
1. Alamofire 下载文件:数据流式落盘,真实场景下的第一选择
很多接触 Alamofire 的开发者,第一次下载大文件都会写AF.request(url).responseData,然后看着内存占用飙到几百 MB 开始怀疑人生。真正该用的其实是download这一族 API:数据不经过内存,由 URLSession 的 downloadTask 直接流式写入临时文件,你再通过 destination 闭包把它移到目标目录。这里要讲的就是这件事:Alamofire 怎么下载文件、进度怎么拿、断点续传怎么恢复、后台下载怎么活下来、下载完的文件怎么管理。适合要下载视频、离线包、安装包,并且对进度条、续传和后台任务有要求的 iOS/macOS 开发者。读完你能获得一份可以直接改的下载器骨架,以及几份血泪经验换来的排坑清单。
2. Alamofire 下载链路拆解:为什么 download 比 data request 省内存
2.1 下载的底层是 URLSession 的 downloadTask,Alamofire 只是把推土机开过来
Alamofire 的download系列 API 本质上是 URLSessiondownloadTask的封装,这一点直接决定了它的内存表现。dataTask会把服务端响应体完整地累积到内存的 Data 缓冲区里,文件多大,内存峰值就有多大;而downloadTask会把响应体分块写入磁盘上的临时文件,App 内存里只有一个持续的写入流和一个进度对象。对 200 MB 的安装包来说,前者峰值轻松冲到 300 MB 以上,后者通常稳定在几十 MB 以内。
理解这层关系还有个实际用途:当你需要排查下载慢、连接被重置、断网续传这类问题时,先想想 URLSession 原生会怎么表现,再回来看 Alamofire 层的配置,问题往往出在 session 配置而不是 Alamofire 代码本身。Alamofire 的Session就是 URLSession 的增强代理,所有 delegate 回调都会被它接管并翻译成 Alamofire 的事件模型。
我一般会把 Alamofire 下载看成三件事:请求的构造与拦截(interceptor)、传输过程的进度与事件回调(EventMonitor)、落盘路径的决策(destination)。三件事里,前两件和普通请求差不多,第三件是下载独有的。
2.2 最小下载代码:destination 闭包、请求参数与两个关键选项
先看最小可用版本。下载一个 zip 包到 Documents 目录,代码可以精简成这样:
import Alamofire let url = "https://example.com/downloads/demo.zip" let destination: DownloadRequest.Destination = { temporaryURL, response in let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let fileURL = documents.appendingPathComponent("demo.zip") // 返回目标地址和落盘选项 return (fileURL, [.removePreviousFile, .createIntermediateDirectories]) } AF.download(url, method: .get, parameters: nil, headers: nil, interceptor: nil, to: destination) .validate() .response { response in if let error = response.error { print("下载失败: \(error)") } else if let fileURL = response.fileURL { print("下载完成: \(fileURL)") } }这段代码里最容易被忽略的是destination闭包。它的输入是临时文件地址和 HTTP 响应,输出是一个元组:目标文件 URL 和落盘选项。这里用了两个选项:.removePreviousFile表示目标位置已存在同名的旧文件时先删掉再写入,避免文件覆盖失败;.createIntermediateDirectories表示目标目录不存在时自动创建。如果你不提供 destination,Alamofire 会把文件留在临时目录,系统随时可能清理,所以这个闭包基本是必写的。
validate()也很关键。默认情况下 HTTP 404 或 500 也会走完下载流程,响应体是一个错误页面,文件照样落盘。加上validate()后,非 2xx 状态码会直接触发 error 分支,避免把垃圾文件当成成功结果。
2.3 Session 配置:默认单例与自定义 Session 怎么选
Alamofire 的AF是默认 Session 单例,内部使用 URLSessionConfiguration 的默认配置。对普通下载够用,但遇到两种场景必须换成自定义 Session:一是需要后台下载,二是需要修改信任策略、代理或超时参数。
let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.downloader.background") configuration.timeoutIntervalForRequest = 60 let session = Session(configuration: configuration) session.download(url, to: destination) .response { response in // 处理结果 }这里background(withIdentifier:)创建的是后台会话配置,系统会在 App 进入后台或进程被挂起后继续传输任务。identifier 必须全局唯一且每次启动保持同一个值,否则系统无法把上一个进程遗留的任务对接到新会话。需要说明的是,后台会话的下载任务只有在系统自主调度时才会传输,速度表现和前台不同,适合大文件离线下载,不适合对实时性要求高的场景。
自定义 Session 还承担着事件闭合的职责。后台下载完成时,系统会回调 AppDelegate 的handleEventsForBackgroundURLSession,你必须保存传入的 completionHandler,并在 Alamofire 的sessionDidFinishEventsForBackgroundURLSession闭包里调用它。这个细节我在避坑章节会专门展开。
下面是一个快速选型参考表:
| 场景 | 使用方式 | 注意事项 |
|---|---|---|
| 前台小文件下载 | AF 默认单例 | 代码最简单 |
| 前台大文件加进度条 | AF 默认单例 + downloadProgress | 注意不要用 dataRequest |
| 需要后台继续下载 | 自定义 background Session | identifier 要固定 |
| 内网自签名证书 | 自定义 Session + ServerTrustManager | 信任策略要单独配 |
3. 带进度和断点续传的下载器:一份可以直接改的 Swift 实现
3.1 进度回调:fractionCompleted 换算与回调队列选择
下载进度的核心是downloadProgress,它暴露的是 Swift 标准库的Progress对象。实际开发中你基本只会用到两个字段:completedUnitCount和totalUnitCount,两者相除就是进度百分比。Alamofire 还直接提供了fractionCompleted,等价于前两者的比值。
AF.download(url, to: destination) .downloadProgress(queue: .main) { progress in let percent = Int(progress.fractionCompleted * 100) // 更新 UI 上的进度条或百分比文案 print("下载进度: \(percent)%") }注意queue参数。默认情况下进度回调在内部串行队列执行,如果你直接在里面刷新 UILabel 或 SwiftUI 的 @State,会触发主线程更新检查的警告甚至崩溃。这里显式传.main,把回调派发到主线程,是最省事的做法。如果进度刷新频率太高导致 UI 卡顿,可以在回调里做节流,比如仅在百分比整数变化时才刷新,或用DispatchSourceTimer合并刷新。
还有一个容易被忽略的点:downloadProgress必须在请求发起后立即链式调用,它内部不会影响传输本身,只是挂一个观察者。如果你在下载完成之后才调用,闭包可能永远不会触发,因为任务已经结束了。
3.2 断点续传:resumeData 的保存、恢复与边界
断点续传是下载功能里最容易被"玄学"对待的部分。Alamofire 5 的做法和 URLSession 保持一致:取消下载时通过cancel(producingResumeData: true)让系统生成一段 resumeData;恢复时用这段数据重新发起请求,服务器会从断点处继续传输。
private var resumeData: Data? func start() { if let resumeData = resumeData { // 有断点数据,走恢复流程 downloadRequest = AF.download(resumingWith: resumeData, to: destination) } else { // 全新下载 downloadRequest = AF.download(urlString, to: destination) } downloadRequest? .downloadProgress(queue: .main) { [weak self] progress in self?.progress = progress.fractionCompleted } .response { [weak self] response in if let error = response.error { // 取消或失败时把 resumeData 存下来,供下次恢复 if let data = response.resumeData { self?.resumeData = data } self?.handleError(error) } else if let fileURL = response.fileURL { // 下载成功,清掉断点数据 self?.resumeData = nil self?.handleSuccess(fileURL) } } } func pause() { // 生成 resumeData 的动作在系统内部异步进行 downloadRequest?.cancel(producingResumeData: true) }这段逻辑有几个关键参数需要理解。resumingWith:接受的是之前取消时生成的 resumeData,不是 URL,也不是 Range 头。恢复请求发出后,服务器如果支持 Range,会返回 206 Partial Content,URLSession 自动从断点续传;如果服务器不支持 Range,可能会返回 200 并重新传输整个文件,这个行为对调用方是透明的,但会导致进度回退,需要在前端做提示。
response.resumeData的获取时机也容易踩坑。cancel(producingResumeData:)调用后,resumeData 不会立即出现在内存变量里,而是随响应回调返回。所以上面代码里,暂停操作只是触发取消,真正的保存发生在 response 闭包里。如果你在 pause() 方法里同步读取 self.resumeData,大概率拿到 nil。
3.3 下载器骨架:把进度、状态、错误暴露给 UI 层
把上面两块拼起来,就是一个能直接放进项目的下载器。这里用 Swift 枚举表示状态,配合 SwiftUI 和 UIKit 都能用:
final class Downloader: ObservableObject { enum State { case idle case downloading(Double) case paused(Double) case finished(URL) case failed(Error) } @Published private(set) var state: State = .idle private var downloadRequest: DownloadRequest? private var resumeData: Data? private let urlString: String private let destination: DownloadRequest.Destination init(urlString: String, destination: @escaping DownloadRequest.Destination) { self.urlString = urlString self.destination = destination } func start() { if let resumeData = resumeData { downloadRequest = AF.download(resumingWith: resumeData, to: destination) } else { downloadRequest = AF.download(urlString, to: destination) } downloadRequest? .downloadProgress(queue: .main) { [weak self] progress in self?.state = .downloading(progress.fractionCompleted) } .response { [weak self] response in if let error = response.error { if let data = response.resumeData { self?.resumeData = data } self?.state = .failed(error) } else if let fileURL = response.fileURL { self?.resumeData = nil self?.state = .finished(fileURL) } } } func pause() { downloadRequest?.cancel(producingResumeData: true) state = .paused(currentProgress) } private var currentProgress: Double { if case .downloading(let progress) = state { return progress } return 0 } }这个骨架把进度、暂停、失败、完成全部收敛到一个枚举里,UI 层只需要 switch 这个 state 就能渲染不同界面。值得注意的边界是.paused状态:cancel之后 response 闭包还会再触发一次,此时 error 是AFError.explicitlyCancelled,如果不用 state 挡住,UI 会闪一下失败态。实际项目中我通常把 explicitlyCancelled 单独处理,不当作真正失败上报,避免弹窗骚扰用户。
4. Alamofire 下载避坑指南:四个会当场翻车的现场
4.1 内存峰值异常高:dataRequest 下载大文件直接打爆内存
现象:用AF.request(url).responseData下载一个视频文件,Xcode 的内存占用曲线直接起飞,下载到一半收到内存警告。
原因:responseData是 dataTask 模式,整个文件会被完整加载进 Data 对象,再一次性给你。下载行为和文件落盘是两件事,文件越大,峰值内存越高,这不是 Alamofire 的 bug,而是 API 选错了。
解决:换成AF.download。如果你的代码里已经用了 dataRequest 且不方便改架构,至少把响应体分块读入文件。但最省事、最可靠的做法永远是 download 系列 API,它天生走磁盘流式写入。这是我在代码评审里看到频率最高的问题,没有之一。
4.2 后台下载挂起:background 会话、事件闭包与冷启动重建
现象:App 退到后台,大文件下载任务过几分钟就停了;上滑杀掉 App 后任务彻底消失,重新打开 App,文件还停在 60%。
原因:默认的 URLSessionConfiguration 是前台会话,App 进入后台后系统会挂起网络传输。即使你创建了 background 会话,如果 App 进程被系统回收,新启动的进程必须用同一个 identifier 重新创建 session,并且要在application(_:handleEventsForBackgroundURLSession:completionHandler:)里保存 completionHandler,等所有任务事件派发结束后手动调用,否则系统会认为会话没有结束,后续任务不派发。
解决:下载器内部单独维护一个后台 Session,identifier 固定不变;AppDelegate 里实现对应回调并转发给下载器:
func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { // identifier 匹配你自己定义的值 DownloadManager.shared.backgroundCompletionHandler = completionHandler }在 Session 的事件闭包里闭合:
session.sessionDidFinishEventsForBackgroundURLSession = { _ in DispatchQueue.main.async { DownloadManager.shared.backgroundCompletionHandler?() DownloadManager.shared.backgroundCompletionHandler = nil } }这里最容易翻车的点是:backgroundCompletionHandler 必须在主线程调用且调用一次后清空,否则下一次后台任务完成后系统回调不会触发,下载完成的 UI 状态永远不更新。
4.3 HTTP 200 但文件只有几 KB:状态码成功不等于下载正确
现象:下载显示成功,文件也落盘了,但大小只有 3 KB,打开一看是个 JSON 错误提示,比如签名过期或参数校验失败。
原因:服务端对这类业务错误返回的是 HTTP 200 + 错误体,而不是 4xx。validate()只能拦状态码,拦不住业务语义。
解决:下载完成后对比 Content-Length 和实际文件大小。Alamofire 的 response 闭包里拿得到response.response?.expectedContentLength,结合 FileManager 拿到实际字节数,不一致就删掉文件并报告错误。对于更严谨的校验,可以在 HTTP 头里带上文件哈希,下载完算一遍 SHA-256 比对,这部分我在第 5 章给出代码。
4.4 断点续传恢复失败:服务器不认 Range 时要自动降级
现象:暂停后续传,进度条从 80% 跳回 0,或者干脆报错,文件一直下载不完。
原因:部分服务器或 CDN 节点不支持 Range 请求,或者返回的 206 响应里没有正确的 Content-Range 头。URLSession 遇到这种情况会尝试全量重新下载,如果服务器连 Range 都不接受,恢复请求会失败。
解决:这类问题没有完全通用的魔法。一个可行方案是在恢复前发一个探测请求,检查响应头里的Accept-Ranges字段;服务端如果返回none或缺失,就丢弃 resumeData 走全量下载。另一个方案是接受"恢复失败就重下"的现实,但把重下逻辑做成自动的:response 闭包里如果遇到 resumed 后失败,且错误类型是URLError相关,自动清空 resumeData 并重新创建下载请求。需要提醒的是,断点续传的 resumeData 是黑匣子,它和服务器协商的字节范围我们看不到,不要在日志里过度解读它的内容。
5. 下载后的文件管理:目录选型、SHA-256 校验与过期清理
5.1 沙盒目录选择:Documents、Caches 与 tmp 的取舍
下载完成只是第一步,文件放哪决定了它何时会被系统清掉、是否占用备份空间。iOS 沙盒里三个常见目录有明确区别:
| 目录 | 是否会被系统清理 | 是否参与 iCloud 备份 | 适合场景 |
|---|---|---|---|
| Documents | 不清理 | 是 | 用户可见的离线包、导出文件 |
| Caches | 可能清理 | 否 | 可再下载的缓存内容 |
| tmp | 随时清理 | 否 | 临时中转文件 |
我的习惯是:用户主动触发的下载,比如"下载视频到本地",放 Documents 下的Downloads子目录,文件名用原始文件名加时间戳前缀,避免重名;自动缓存类的下载,比如在线播放的离线策略,放 Caches,系统压力大时会自动清理。对于超大可恢复文件,落地过程中先用临时目录存储,校验通过后再移动到正式目录,避免半截子文件污染正式区域。
5.2 完整性校验:Content-Length 对比与流式 SHA-256
只依赖状态码判断下载成功,在 4.3 的场景里会翻车。更可靠的做法是在响应闭包里拿expectedContentLength和磁盘实际大小做比对;对要求更高的场景,计算 SHA-256 和服务器下发的哈希做比对。注意大文件千万不要用Data(contentsOf:)一次性读入内存再算哈希,那会复现第 4.1 节的悲剧。正确姿势是分块读入:
import CryptoKit func sha256OfFile(at url: URL) -> String? { guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } defer { try? handle.close() } var hasher = SHA256() // 分块读取,避免大文件把内存打满 while true { let chunk = handle.readData(ofLength: 1024 * 1024) guard !chunk.isEmpty else { break } hasher.update(data: chunk) } return hasher.finalize().map { String(format: "%02x", $0) }.joined() }这段代码里readData(ofLength:)每次读 1 MB,边读边喂给哈希器,内存占用稳定在几 MB 级别。对 1 GB 的视频文件也能安全计算。拿到哈希后和服务器返回的字符串比对,注意服务器可能返回大写或带冒号分隔的格式,统一转小写并去掉分隔符再比较。
5.3 清理策略:按时间与按目录大小双条件清理
下载功能上线一段时间后,磁盘占用会成为新的问题。最常见策略是双条件清理:超过指定天数的文件直接删除,目录总大小超过阈值时按修改时间从旧到新删除。
func clearDownloads( in directory: URL, olderThan days: TimeInterval, maxTotalSize: Int64 ) { let keys: [URLResourceKey] = [.contentModificationDateKey, .totalFileAllocatedSizeKey] let options: FileManager.DirectoryEnumerationOptions = [.skipsHiddenFiles] guard let urls = FileManager.default.enumerator( at: directory, includingPropertiesForKeys: keys, options: options )?.allObjects as? [URL] else { return } var staleURLs: [URL] = [] var totalSize: Int64 = 0 let cutoff = Date().addingTimeInterval(-days) for url in urls { let values = try? url.resourceValues(forKeys: Set(keys)) let date = values?.contentModificationDate ?? .distantPast let size = Int64(values?.totalFileAllocatedSize ?? 0) totalSize += size if date < cutoff { staleURLs.append(url) } } // 先清过期文件,再按体积从旧到新清理 staleURLs.forEach { try? FileManager.default.removeItem(at: $0) } if totalSize > maxTotalSize { let sorted = urls.sorted { lhs, rhs in let l = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast let r = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast return l < r } var freed: Int64 = 0 for url in sorted { guard totalSize - freed > maxTotalSize else { break } let size = (try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0 try? FileManager.default.removeItem(at: url) freed += Int64(size) } } }这段代码有两个细节值得说:一是用totalFileAllocatedSizeKey而不是fileSizeKey,因为磁盘上占用的空间往往大于文件实际字节数,按前者清理更符合真实磁盘占用;二是按修改时间排序时用了可选值兜底,避免某个文件取不到日期时抛异常。清理逻辑建议放在 App 启动或进入前台时触发,不要在下载回调里同步执行,避免造成卡顿。
6. 冷启动自动续传:把下载状态做成可恢复的本地状态机
进程被系统回收是移动端下载绕不开的终局问题。后台会话能保住系统级任务的传输,但 App 一旦被用户上滑杀掉,任务会进入"待恢复"状态,下次启动时如果不主动处理,这个任务就永久搁浅了。我的做法是给下载器加一层持久化状态记录,冷启动后自动恢复所有未完成任务。
核心思路是:每次下载发起或暂停时,把任务信息序列化到 UserDefaults;启动时读取记录并创建同 identifier 的 background Session,把 resumeData 重新交给AF.download(resumingWith:);任务完成后删除对应记录。
struct PendingDownload: Codable { let url: String let destinationPath: String let resumeData: Data? let createdAt: Date } func savePendingDownloads(_ tasks: [PendingDownload]) { let encoder = JSONEncoder() if let data = try? encoder.encode(tasks) { UserDefaults.standard.set(data, forKey: "pendingDownloads") } } func restorePendingDownloads() { guard let data = UserDefaults.standard.data(forKey: "pendingDownloads"), let tasks = try? JSONDecoder().decode([PendingDownload].self, from: data) else { return } for task in tasks { let destination: DownloadRequest.Destination = { _, _ in (URL(fileURLWithPath: task.destinationPath), [.removePreviousFile, .createIntermediateDirectories]) } if let resumeData = task.resumeData { AF.download(resumingWith: resumeData, to: destination) .response { response in if response.error == nil, let fileURL = response.fileURL { removePendingDownload(task) } } } } }恢复任务的时机建议放在application(_:didFinishLaunchingWithOptions:)里,且必须在创建 Session 之后立刻执行。这里有个细节:恢复下载要使用后台会话,因为用户刚杀进程重新打开时,前台会话无法接续系统遗留的后台任务。恢复完成后及时清理记录,否则每次启动都会带着一堆已完成的脏数据重试。
这个状态机是我做过几个下载器之后慢慢攒出来的习惯,早期版本把 resumeData 存在内存里,进程一死就全丢,后来改成持久化后,用户无论怎么杀进程,回来都能从断点继续,体验差别非常明显。补充一个容易忽略的操作习惯:每次修改下载器逻辑后,用真机做一次"下载到一半上滑杀 App → 重新打开"的完整回归,模拟器上的后台行为和真机差距很大,这一条希望帮到你。
本文还有配套的精品资源,点击获取