You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift4中URL数组循环下载PDF及后台下载进度追踪问题

解决PDF后台下载+跨页面进度追踪的方案

你的递归下载思路方向是对的,但问题出在下载任务和ViewController绑定太紧密,导致VC销毁后任务追踪丢失,而且没利用Alamofire的后台会话能力。下面给你一套完整的实现方案:

第一步:抽离独立的下载任务管理器(单例)

把下载逻辑从ViewController里抽出来,做成单例类,这样不管页面怎么切换,任务都能在后台持续运行,而且所有页面都能访问任务状态。

import Alamofire
import Combine

class PDFDownloadManager {
    static let shared = PDFDownloadManager()
    
    // 后台会话配置
    private let session: Session = {
        let configuration = URLSessionConfiguration.background(withIdentifier: "com.yourApp.pdfDownloadSession")
        configuration.isDiscretionary = false // 非 discretionary 任务会立即启动
        configuration.sessionSendsLaunchEvents = true // 后台完成时唤醒App
        return Session(configuration: configuration)
    }()
    
    // 存储下载任务信息:key是任务ID,value是进度、状态、目标URL等
    private var downloadTasks: [String: DownloadTaskInfo] = [:]
    // 待下载的URL队列
    private var pendingURLs: [URL] = []
    // 当前正在运行的任务
    private var currentTask: DownloadRequest?
    
    // 用Combine发布进度更新,方便页面订阅
    let progressSubject = PassthroughSubject<[String: DownloadTaskInfo], Never>()
    
    // 下载任务信息结构体
    struct DownloadTaskInfo {
        let url: URL
        var progress: Double
        var status: DownloadStatus
        var localPath: URL?
    }
    
    enum DownloadStatus: String {
        case waiting, downloading, completed, failed
    }
    
    private init() {
        // 监听后台会话完成回调
        session.backgroundCompletionHandler = { [weak self] in
            self?.progressSubject.send(self?.downloadTasks ?? [:])
        }
    }
    
    // 添加下载任务(支持批量添加)
    func addDownloadURLs(_ urls: [URL]) {
        pendingURLs.append(contentsOf: urls)
        // 如果当前没有任务,启动第一个
        if currentTask == nil {
            startNextTask()
        }
        progressSubject.send(downloadTasks)
    }
    
    // 启动下一个任务
    private func startNextTask() {
        guard let url = pendingURLs.popLast() else {
            // 队列空了,任务全部完成
            currentTask = nil
            progressSubject.send(downloadTasks)
            return
        }
        
        // 生成唯一任务ID(用URL的hash或者UUID)
        let taskID = url.absoluteString
        
        // 初始化任务信息
        downloadTasks[taskID] = DownloadTaskInfo(url: url, progress: 0, status: .downloading, localPath: nil)
        progressSubject.send(downloadTasks)
        
        // 创建下载请求
        currentTask = session.download(url)
            .downloadProgress { [weak self] progress in
                guard let self = self else { return }
                self.downloadTasks[taskID]?.progress = progress.fractionCompleted
                self.progressSubject.send(self.downloadTasks)
            }
            .responseData { [weak self] response in
                guard let self = self else { return }
                switch response.result {
                case .success(let data):
                    // 保存PDF到本地
                    let localPath = self.savePDF(data, from: url)
                    self.downloadTasks[taskID]?.status = .completed
                    self.downloadTasks[taskID]?.localPath = localPath
                case .failure(let error):
                    print("下载失败:\(error.localizedDescription)")
                    self.downloadTasks[taskID]?.status = .failed
                }
                // 当前任务完成,启动下一个
                self.startNextTask()
            }
    }
    
    // 保存PDF到Documents目录
    private func savePDF(_ data: Data, from url: URL) -> URL? {
        let fileName = url.lastPathComponent
        guard let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
            return nil
        }
        let localPath = documentsDir.appendingPathComponent(fileName)
        do {
            try data.write(to: localPath)
            return localPath
        } catch {
            print("保存失败:\(error.localizedDescription)")
            return nil
        }
    }
    
    // 获取所有任务状态
    func getAllTaskInfos() -> [String: DownloadTaskInfo] {
        return downloadTasks
    }
}

第二步:配置后台会话回调(AppDelegate)

要让App在后台完成下载后能更新状态,需要在AppDelegate里处理后台会话的回调:

import UIKit
import Alamofire

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
        // 把completionHandler传给下载管理器
        PDFDownloadManager.shared.session.setBackgroundCompletionHandler(completionHandler)
    }
}

第三步:在ViewController中订阅进度更新

不管是下载VC还是主表单VC,都可以通过Combine来获取实时进度:

下载ViewController示例

import UIKit
import Combine

class DownloadViewController: UIViewController {
    @IBOutlet weak var progressTableView: UITableView!
    
    private var cancellables = Set<AnyCancellable>()
    private var taskInfos: [PDFDownloadManager.DownloadTaskInfo] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupProgressSubscription()
        progressTableView.reloadData()
    }
    
    private func setupProgressSubscription() {
        PDFDownloadManager.shared.progressSubject
            .receive(on: DispatchQueue.main)
            .sink { [weak self] taskDict in
                self?.taskInfos = Array(taskDict.values)
                self?.progressTableView.reloadData()
            }
            .store(in: &cancellables)
        
        // 初始化获取当前任务状态
        let initialTasks = PDFDownloadManager.shared.getAllTaskInfos()
        taskInfos = Array(initialTasks.values)
    }
}

// 实现TableView数据源,显示每个任务的进度和状态
extension DownloadViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return taskInfos.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ProgressCell", for: indexPath)
        let taskInfo = taskInfos[indexPath.row]
        cell.textLabel?.text = taskInfo.url.lastPathComponent
        cell.detailTextLabel?.text = "\(Int(taskInfo.progress * 100))% - \(taskInfo.status.rawValue)"
        // 这里可以添加UIProgressView显示具体进度条
        return cell
    }
}

主表单ViewController示例

在主表单里可以添加按钮打开下载VC,或者直接显示总进度:

import UIKit
import Combine

class MainFormViewController: UIViewController {
    @IBOutlet weak var downloadStatusLabel: UILabel!
    
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupStatusSubscription()
    }
    
    private func setupStatusSubscription() {
        PDFDownloadManager.shared.progressSubject
            .receive(on: DispatchQueue.main)
            .sink { [weak self] taskDict in
                let completedCount = taskDict.values.filter { $0.status == .completed }.count
                let totalCount = taskDict.count
                self?.downloadStatusLabel.text = "已完成 \(completedCount)/\(totalCount) 个PDF"
            }
            .store(in: &cancellables)
    }
    
    @IBAction func openDownloadVC(_ sender: UIButton) {
        let downloadVC = storyboard?.instantiateViewController(withIdentifier: "DownloadViewController") as! DownloadViewController
        present(downloadVC, animated: true)
    }
    
    // 触发下载的方法
    @IBAction func startDownload(_ sender: UIButton) {
        let pdfURLs = [
            URL(string: "https://example.com/pdf1.pdf")!,
            URL(string: "https://example.com/pdf2.pdf")!
            // 更多URL...
        ]
        PDFDownloadManager.shared.addDownloadURLs(pdfURLs)
    }
}

关键要点说明

  • 单例管理器:解耦下载逻辑和UI,保证任务在后台持续运行,不受ViewController生命周期影响。
  • 后台会话:Alamofire的background session由系统托管下载任务,即使App退到后台也能继续执行。
  • 进度同步:用Combine发布进度事件,所有需要显示进度的页面都能订阅,实时更新UI。
  • 任务追踪:每个下载任务都有唯一ID和状态记录,就算关闭下载VC再打开,也能恢复显示所有任务的进度和状态。

这样改造后,你就能实现后台下载、跨页面查看进度的需求了,而且任务追踪的问题也彻底解决,每个任务的状态都持久保存在管理器中,随时可以获取。

内容的提问来源于stack exchange,提问作者Anonymous-E

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.22 08:04:51