断点续传Demo实现
基于我们的DownloadManager.swift
代码,让我详细解释断点续传需要实现的核心功能:
断点续传的核心实现要素
1. 后台会话配置
private func setupBackgroundSession() {let config = URLSessionConfiguration.background(withIdentifier: "com.test.download")config.isDiscretionary = false // 立即下载,不等待最佳时机config.sessionSendsLaunchEvents = true // 支持后台启动应用backgroundSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
}
作用:
- 支持应用进入后台或被杀死后继续下载
- 系统会在下载完成后重新启动应用
2. 断点数据保存机制
private var resumeData: Data? // 关键:保存断点续传数据// 暂停时保存断点信息
func pauseDownload() {downloadTask?.cancel { [weak self] resumeData inself?.resumeData = resumeData // 保存断点数据self?.downloadState = .paused}
}
resumeData 包含:
- 已下载的字节数
- 服务器响应信息
- 临时文件路径
- HTTP头信息
3. 智能下载启动
func startDownload(url: URL) {// 检查是否有断点数据if let resumeData = resumeData {// 从断点继续下载downloadTask = backgroundSession.downloadTask(withResumeData: resumeData)} else {// 全新下载downloadTask = backgroundSession.downloadTask(with: url)}downloadTask?.resume()
}
4. 三个关键的URLSession代理方法
进度更新
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {// bytesWritten: 本次写入的字节数// totalBytesWritten: 累计已下载字节数// totalBytesExpectedToWrite: 文件总大小downloadedSize = totalBytesWrittentotalSize = totalBytesExpectedToWritelet progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
}
断点恢复回调
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {// fileOffset: 恢复下载的起始位置// expectedTotalBytes: 文件总大小downloadedSize = fileOffset // 更新已下载大小totalSize = expectedTotalBytes
}
下载完成处理
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {// location: 系统提供的临时文件路径// 需要将文件移动到最终位置let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]let destinationURL = documentsPath.appendingPathComponent(fileName)try FileManager.default.moveItem(at: location, to: destinationURL)downloadState = .completedresumeData = nil // 清除断点数据
}
5. 状态管理系统
enum DownloadState {case idle // 空闲状态case downloading // 下载中case paused // 已暂停case completed // 下载完成case failed // 下载失败
}
6. 错误处理机制
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {if let error = error {downloadState = .failed// 即使出错,resumeData仍然有效,可以继续下载}
}
断点续传的技术原理
HTTP Range 请求
当使用resumeData
创建下载任务时,系统会自动发送HTTP Range请求:
GET /file.zip HTTP/1.1
Range: bytes=1048576-
表示从第1048576字节开始下载。
服务器支持要求
服务器必须支持:
Accept-Ranges: bytes
响应头Content-Range
响应头ETag
或Last-Modified
头(验证文件完整性)
文件完整性验证
// 实际应用中应该验证文件是否被修改
// 通过ETag或Last-Modified头进行验证
实际应用中的扩展
持久化存储
// 将resumeData保存到磁盘,应用重启后恢复
func saveResumeData() {if let resumeData = resumeData {try? resumeData.write(to: getResumeDataURL())}
}func loadResumeData() {if let data = try? Data(contentsOf: getResumeDataURL()) {resumeData = data}
}
多任务支持
// 支持多个文件同时下载
private var downloadTasks: [String: URLSessionDownloadTask] = [:]
private var resumeDataDict: [String: Data] = [:]
下载队列管理
// 控制并发下载数量
private let maxConcurrentDownloads = 3
private var downloadQueue: [URL] = []
DownloadManager
实现了断点续传的所有核心功能,包括状态管理、进度跟踪、错误处理和用户控制,是一个完整的断点续传解决方案。
//
// DownloadManager.swift
// test
//
// Created by Ricard.li on 2025/6/14.
//import Foundationprotocol DownloadManagerDelegate: AnyObject {func downloadProgress(_ progress: Float, totalSize: Int64, downloadedSize: Int64)func downloadCompleted(filePath: String)func downloadFailed(error: Error)func downloadPaused()func downloadResumed()
}class DownloadManager: NSObject {weak var delegate: DownloadManagerDelegate?private var downloadTask: URLSessionDownloadTask?private var backgroundSession: URLSession!private var resumeData: Data?private var downloadURL: URL?// 文件信息private var totalSize: Int64 = 0private var downloadedSize: Int64 = 0// 下载状态enum DownloadState {case idlecase downloadingcase pausedcase completedcase failed}private var downloadState: DownloadState = .idleoverride init() {super.init()setupBackgroundSession()}private func setupBackgroundSession() {let config = URLSessionConfiguration.background(withIdentifier: "com.test.download")config.isDiscretionary = falseconfig.sessionSendsLaunchEvents = truebackgroundSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)}// 开始下载func startDownload(url: URL) {guard downloadState != .downloading else { return }downloadURL = urldownloadState = .downloading// 检查是否有之前的断点数据if let resumeData = resumeData {downloadTask = backgroundSession.downloadTask(withResumeData: resumeData)} else {downloadTask = backgroundSession.downloadTask(with: url)}downloadTask?.resume()delegate?.downloadResumed()}// 暂停下载func pauseDownload() {guard downloadState == .downloading else { return }downloadTask?.cancel { [weak self] resumeData inself?.resumeData = resumeDataself?.downloadState = .pausedDispatchQueue.main.async {self?.delegate?.downloadPaused()}}}// 恢复下载func resumeDownload() {guard downloadState == .paused, let resumeData = resumeData else { return }downloadState = .downloadingdownloadTask = backgroundSession.downloadTask(withResumeData: resumeData)downloadTask?.resume()delegate?.downloadResumed()}// 取消下载func cancelDownload() {downloadTask?.cancel()resumeData = nildownloadState = .idledownloadedSize = 0totalSize = 0}// 获取下载进度func getProgress() -> Float {guard totalSize > 0 else { return 0.0 }return Float(downloadedSize) / Float(totalSize)}// 获取下载状态func getCurrentState() -> DownloadState {return downloadState}// 获取文件大小信息func getSizeInfo() -> (total: Int64, downloaded: Int64) {return (totalSize, downloadedSize)}
}// MARK: - URLSessionDownloadDelegate
extension DownloadManager: URLSessionDownloadDelegate {func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {// 下载完成,移动文件到Documents目录let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]let fileName = downloadURL?.lastPathComponent ?? "downloaded_file"let destinationURL = documentsPath.appendingPathComponent(fileName)do {// 如果文件已存在,先删除if FileManager.default.fileExists(atPath: destinationURL.path) {try FileManager.default.removeItem(at: destinationURL)}try FileManager.default.moveItem(at: location, to: destinationURL)downloadState = .completedresumeData = nilDispatchQueue.main.async {self.delegate?.downloadCompleted(filePath: destinationURL.path)}} catch {downloadState = .failedDispatchQueue.main.async {self.delegate?.downloadFailed(error: error)}}}func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {// 更新下载进度downloadedSize = totalBytesWrittentotalSize = totalBytesExpectedToWritelet progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)DispatchQueue.main.async {self.delegate?.downloadProgress(progress, totalSize: totalBytesExpectedToWrite, downloadedSize: totalBytesWritten)}}func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {// 恢复下载时调用downloadedSize = fileOffsettotalSize = expectedTotalByteslet progress = Float(fileOffset) / Float(expectedTotalBytes)DispatchQueue.main.async {self.delegate?.downloadProgress(progress, totalSize: expectedTotalBytes, downloadedSize: fileOffset)}}
}// MARK: - URLSessionTaskDelegate
extension DownloadManager: URLSessionTaskDelegate {func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {if let error = error {// 检查是否是用户取消操作(包括暂停)let nsError = error as NSErrorif nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {// 如果是取消操作,不处理为错误// 暂停操作会通过pauseDownload()方法的回调处理return}// 其他错误才标记为失败downloadState = .failedDispatchQueue.main.async {self.delegate?.downloadFailed(error: error)}}}
}// MARK: - 辅助方法
extension DownloadManager {// 格式化文件大小static func formatFileSize(_ size: Int64) -> String {let formatter = ByteCountFormatter()formatter.allowedUnits = [.useMB, .useKB, .useBytes]formatter.countStyle = .filereturn formatter.string(fromByteCount: size)}// 计算下载速度func calculateDownloadSpeed(downloadedBytes: Int64, timeInterval: TimeInterval) -> String {let speed = Double(downloadedBytes) / timeIntervallet formatter = ByteCountFormatter()formatter.allowedUnits = [.useMB, .useKB, .useBytes]formatter.countStyle = .filereturn formatter.string(fromByteCount: Int64(speed)) + "/s"}
}
//
// ViewController.swift
// test
//
// Created by Ricard.li on 2025/6/14.
//import UIKit
import SnapKitclass ViewController: UIViewController {private let downloadManager = DownloadManager()// UI组件private lazy var titleLabel: UILabel = {let label = UILabel()label.text = "断点续传演示"label.font = UIFont.boldSystemFont(ofSize: 24)label.textAlignment = .centerlabel.textColor = .blackreturn label}()private lazy var urlTextField: UITextField = {let textField = UITextField()textField.placeholder = "请输入下载链接"textField.borderStyle = .roundedRecttextField.font = UIFont.systemFont(ofSize: 16)textField.text = "https://media.w3.org/2010/05/sintel/trailer.mp4"return textField}()private lazy var progressView: UIProgressView = {let progress = UIProgressView(progressViewStyle: .default)progress.progressTintColor = .systemBlueprogress.trackTintColor = .systemGray5progress.progress = 0.0return progress}()private lazy var progressLabel: UILabel = {let label = UILabel()label.text = "0%"label.font = UIFont.systemFont(ofSize: 16)label.textAlignment = .centerlabel.textColor = .systemBluereturn label}()private lazy var statusLabel: UILabel = {let label = UILabel()label.text = "等待开始"label.font = UIFont.systemFont(ofSize: 14)label.textAlignment = .centerlabel.textColor = .systemGrayreturn label}()private lazy var sizeLabel: UILabel = {let label = UILabel()label.text = "0 MB / 0 MB"label.font = UIFont.systemFont(ofSize: 14)label.textAlignment = .centerlabel.textColor = .systemGrayreturn label}()private lazy var speedLabel: UILabel = {let label = UILabel()label.text = "下载速度: 0 KB/s"label.font = UIFont.systemFont(ofSize: 14)label.textAlignment = .centerlabel.textColor = .systemGrayreturn label}()private lazy var startButton: UIButton = {let button = UIButton(type: .system)button.setTitle("开始下载", for: .normal)button.backgroundColor = .systemBluebutton.setTitleColor(.white, for: .normal)button.layer.cornerRadius = 8button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)button.addTarget(self, action: #selector(startDownload), for: .touchUpInside)return button}()private lazy var pauseButton: UIButton = {let button = UIButton(type: .system)button.setTitle("暂停", for: .normal)button.backgroundColor = .systemOrangebutton.setTitleColor(.white, for: .normal)button.layer.cornerRadius = 8button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)button.addTarget(self, action: #selector(pauseDownload), for: .touchUpInside)button.isEnabled = falsereturn button}()private lazy var resumeButton: UIButton = {let button = UIButton(type: .system)button.setTitle("继续", for: .normal)button.backgroundColor = .systemGreenbutton.setTitleColor(.white, for: .normal)button.layer.cornerRadius = 8button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)button.addTarget(self, action: #selector(resumeDownload), for: .touchUpInside)button.isEnabled = falsereturn button}()private lazy var cancelButton: UIButton = {let button = UIButton(type: .system)button.setTitle("取消", for: .normal)button.backgroundColor = .systemRedbutton.setTitleColor(.white, for: .normal)button.layer.cornerRadius = 8button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)button.addTarget(self, action: #selector(cancelDownload), for: .touchUpInside)button.isEnabled = falsereturn button}()private lazy var buttonStackView: UIStackView = {let stackView = UIStackView(arrangedSubviews: [startButton, pauseButton, resumeButton, cancelButton])stackView.axis = .horizontalstackView.distribution = .fillEquallystackView.spacing = 12return stackView}()// 用于计算下载速度private var lastDownloadedSize: Int64 = 0private var lastUpdateTime: Date = Date()override func viewDidLoad() {super.viewDidLoad()setupUI()setupDownloadManager()}private func setupUI() {view.backgroundColor = .systemBackground// 添加所有UI组件view.addSubview(titleLabel)view.addSubview(urlTextField)view.addSubview(progressView)view.addSubview(progressLabel)view.addSubview(statusLabel)view.addSubview(sizeLabel)view.addSubview(speedLabel)view.addSubview(buttonStackView)// 设置约束titleLabel.snp.makeConstraints { make inmake.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(30)make.centerX.equalToSuperview()}urlTextField.snp.makeConstraints { make inmake.top.equalTo(titleLabel.snp.bottom).offset(30)make.leading.trailing.equalToSuperview().inset(20)make.height.equalTo(44)}progressView.snp.makeConstraints { make inmake.top.equalTo(urlTextField.snp.bottom).offset(40)make.leading.trailing.equalToSuperview().inset(20)make.height.equalTo(8)}progressLabel.snp.makeConstraints { make inmake.top.equalTo(progressView.snp.bottom).offset(12)make.centerX.equalToSuperview()}statusLabel.snp.makeConstraints { make inmake.top.equalTo(progressLabel.snp.bottom).offset(12)make.centerX.equalToSuperview()}sizeLabel.snp.makeConstraints { make inmake.top.equalTo(statusLabel.snp.bottom).offset(8)make.centerX.equalToSuperview()}speedLabel.snp.makeConstraints { make inmake.top.equalTo(sizeLabel.snp.bottom).offset(8)make.centerX.equalToSuperview()}buttonStackView.snp.makeConstraints { make inmake.top.equalTo(speedLabel.snp.bottom).offset(40)make.leading.trailing.equalToSuperview().inset(20)make.height.equalTo(50)}}private func setupDownloadManager() {downloadManager.delegate = self}@objc private func startDownload() {guard let urlString = urlTextField.text, !urlString.isEmpty,let url = URL(string: urlString) else {showAlert(title: "错误", message: "请输入有效的下载链接")return}downloadManager.startDownload(url: url)updateButtonStates(downloading: true)lastDownloadedSize = 0lastUpdateTime = Date()}@objc private func pauseDownload() {downloadManager.pauseDownload()}@objc private func resumeDownload() {downloadManager.resumeDownload()updateButtonStates(downloading: true)lastUpdateTime = Date()}@objc private func cancelDownload() {downloadManager.cancelDownload()resetUI()}private func updateButtonStates(downloading: Bool) {startButton.isEnabled = !downloadingpauseButton.isEnabled = downloadingresumeButton.isEnabled = falsecancelButton.isEnabled = downloading}private func resetUI() {progressView.progress = 0.0progressLabel.text = "0%"statusLabel.text = "等待开始"sizeLabel.text = "0 MB / 0 MB"speedLabel.text = "下载速度: 0 KB/s"startButton.isEnabled = truepauseButton.isEnabled = falseresumeButton.isEnabled = falsecancelButton.isEnabled = false}private func showAlert(title: String, message: String) {let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)alert.addAction(UIAlertAction(title: "确定", style: .default))present(alert, animated: true)}
}// MARK: - DownloadManagerDelegate
extension ViewController: DownloadManagerDelegate {func downloadProgress(_ progress: Float, totalSize: Int64, downloadedSize: Int64) {DispatchQueue.main.async {self.progressView.progress = progressself.progressLabel.text = String(format: "%.1f%%", progress * 100)let totalSizeStr = DownloadManager.formatFileSize(totalSize)let downloadedSizeStr = DownloadManager.formatFileSize(downloadedSize)self.sizeLabel.text = "\(downloadedSizeStr) / \(totalSizeStr)"// 计算下载速度let currentTime = Date()let timeInterval = currentTime.timeIntervalSince(self.lastUpdateTime)if timeInterval > 1.0 { // 每秒更新一次速度let speedBytes = downloadedSize - self.lastDownloadedSizelet speed = Double(speedBytes) / timeIntervallet speedStr = DownloadManager.formatFileSize(Int64(speed))self.speedLabel.text = "下载速度: \(speedStr)/s"self.lastDownloadedSize = downloadedSizeself.lastUpdateTime = currentTime}}}func downloadCompleted(filePath: String) {DispatchQueue.main.async {self.statusLabel.text = "下载完成"self.speedLabel.text = "下载完成"self.resetUI()self.showAlert(title: "下载完成", message: "文件已保存到: \(filePath)")}}func downloadFailed(error: Error) {DispatchQueue.main.async {self.statusLabel.text = "下载失败"self.resetUI()self.showAlert(title: "下载失败", message: error.localizedDescription)}}func downloadPaused() {DispatchQueue.main.async {self.statusLabel.text = "已暂停"self.startButton.isEnabled = falseself.pauseButton.isEnabled = falseself.resumeButton.isEnabled = trueself.cancelButton.isEnabled = true}}func downloadResumed() {DispatchQueue.main.async {self.statusLabel.text = "下载中..."self.updateButtonStates(downloading: true)}}
}