3、URLSession进阶:URLSessionDelegate、后台下载与上传、认证挑战处理

好,咱们继续深入。上一章我们把URLSession的基本用法捋了一遍,说白了就是「发请求、收数据」这套标准流程。但实际项目中,哪有这么简单?

你想想看,大文件下载要不要进度回调?App退到后台了下载任务怎么办?遇到HTTPS证书验证怎么处理?这些才是真正考验网络层功底的地方。我个人习惯把URLSession的进阶用法分成三个维度:代理回调、后台任务、安全认证。今天咱们一个一个啃。

3.1 URLSessionDelegate:掌控每一个网络事件

默认的dataTask(with:completionHandler:)虽然方便,但有个致命问题——你拿不到中间状态。比如下载一个100MB的视频,你只能等全部完成才知道结果。这在用户体验上简直是灾难。

这时候就需要URLSessionDelegate出场了。它就像网络层的「监听器」,每个关键节点都会回调你。

3.1.1 核心代理方法

我常用的代理方法就这几个,记住它们基本够用:

代理方法 触发时机 典型用途
urlSession(_:didReceive:completionHandler:) 收到服务器响应时 检查状态码、决定是否继续
urlSession(_:didReceive:) 收到认证挑战时 处理HTTPS证书、基本认证
urlSession(_:task:didCompleteWithError:) 任务完成或失败时 统一处理错误、清理资源
urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:) 下载进度更新时 更新UI进度条

关键点:代理方法是串行调用的,不会并发。这意味着你可以在回调里安全地更新UI,不用额外加锁。我在项目中吃过这个亏——一开始以为要自己加锁,后来发现URLSession内部已经处理好了。

3.1.2 实战:带进度的下载器

来,直接上代码。这是我项目里一个简化版的下载器:

class ProgressDownloader: NSObject, URLSessionDownloadDelegate {
    
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.default
        // 注意:delegate queue 传 nil 会使用串行队列
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()
    
    // 下载进度回调
    func urlSession(_ session: URLSession, 
                    downloadTask: URLSessionDownloadTask, 
                    didWriteData bytesWritten: Int64, 
                    totalBytesWritten: Int64, 
                    totalBytesExpectedToWrite: Int64) {
        
        let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
        DispatchQueue.main.async {
            // 更新UI进度条
            self.progressHandler?(progress)
        }
    }
    
    // 下载完成回调
    func urlSession(_ session: URLSession, 
                    downloadTask: URLSessionDownloadTask, 
                    didFinishDownloadingTo location: URL) {
        // location 是临时文件路径,需要立即移动到持久目录
        let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let destinationURL = documentsPath.appendingPathComponent(downloadTask.response?.suggestedFilename ?? "file")
        
        do {
            try FileManager.default.moveItem(at: location, to: destinationURL)
            print("下载完成:\(destinationURL)")
        } catch {
            print("文件移动失败:\(error)")
        }
    }
}

避坑指南:我曾经在didFinishDownloadingTo里直接使用临时文件路径,结果读取时发现文件已经没了。记住:这个临时文件只在回调作用域内有效,你必须立即把它移到持久目录。

3.2 后台下载与上传:App退出了也不怕

嗯,这里要重点讲。后台传输是URLSession最强大的特性之一,也是坑最多的地方。

3.2.1 后台Session配置

说白了,你只需要把URLSessionConfiguration改成.background(withIdentifier:)就行。但有几个细节要注意:

  • 标识符必须唯一:每个后台Session需要一个唯一标识符,App重启后靠它恢复任务
  • 代理必须保留:后台Session的代理对象必须一直存活,否则收不到回调
  • 系统会接管:App被杀死后,系统会继续下载,下载完成后再唤醒App
let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.example.download.background")
backgroundConfig.isDiscretionary = true  // 系统决定最佳下载时机(省电模式)
backgroundConfig.sessionSendsLaunchEvents = true  // 允许后台唤醒App

let backgroundSession = URLSession(configuration: backgroundConfig, delegate: self, delegateQueue: nil)

3.2.2 处理App重启后的恢复

这里有个经典问题:App被系统杀死后,下载任务还在继续。等用户再次打开App时,怎么恢复进度?

我的做法是在AppDelegate里处理:

// AppDelegate.swift
func application(_ application: UIApplication, 
                 handleEventsForBackgroundURLSession identifier: String, 
                 completionHandler: @escaping () -> Void) {
    
    // 保存 completionHandler,等所有任务完成后调用
    // 否则系统会认为后台任务未完成,导致截图变黑
    BackgroundManager.shared.completionHandler = completionHandler
    
    // 重新创建Session,系统会自动恢复未完成的任务
    _ = BackgroundManager.shared.createBackgroundSession(identifier: identifier)
}

警告:如果你不调用completionHandler,系统会一直保留你的App进程,导致电池消耗异常。我曾经有个版本忘了调,用户反馈手机发烫,查了半天才发现是这个原因。

3.3 认证挑战处理:与服务器握手

最后这部分,说实话很多开发者直接忽略了。但如果你对接的是金融、医疗类App,HTTPS证书验证是必须的。

3.3.1 认证挑战的类型

URLSession的认证挑战分为两种:

  • 服务器信任认证.serverTrust):验证服务器证书是否可信
  • 客户端证书认证.clientCertificate):客户端需要提供证书证明身份

最常见的场景是:测试环境用了自签名证书,或者证书过期了,你需要手动处理。

3.3.2 实战:处理自签名证书

我个人不建议在生产环境跳过证书验证,但开发阶段可以这样处理:

func urlSession(_ session: URLSession, 
                didReceive challenge: URLAuthenticationChallenge, 
                completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    
    // 只处理服务器信任认证
    guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
        completionHandler(.performDefaultHandling, nil)
        return
    }
    
    // 获取服务器的信任对象
    guard let serverTrust = challenge.protectionSpace.serverTrust else {
        completionHandler(.cancelAuthenticationChallenge, nil)
        return
    }
    
    // 这里可以添加自定义验证逻辑
    // 比如:检查证书是否在允许列表里
    let credential = URLCredential(trust: serverTrust)
    completionHandler(.useCredential, credential)
}

安全建议:千万不要直接写completionHandler(.performDefaultHandling, nil)就完事了。我曾经见过一个项目,所有认证挑战都直接放行,结果被安全团队打回重做。正确的做法是:只对特定域名放行,其他一律拒绝

3.3.3 证书固定(Certificate Pinning)

如果你追求更高的安全性,可以考虑证书固定。说白了就是:把服务器的公钥或证书硬编码到App里,只信任这个证书。

func handleServerTrust(serverTrust: SecTrust, host: String) -> Bool {
    // 1. 获取服务器证书
    guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
        return false
    }
    
    // 2. 获取公钥
    let publicKey = SecCertificateCopyKey(serverCertificate)
    
    // 3. 与本地存储的公钥对比
    let localPublicKey = loadLocalPublicKey(for: host)
    return publicKey == localPublicKey
}

小技巧:证书固定虽然安全,但证书过期后你需要发版更新。我建议使用公钥固定而不是证书固定,因为公钥通常不会变,即使证书续签了也能正常工作。

3.4 本章小结

好,咱们把URLSession的进阶用法捋了一遍。总结三个核心点:

  1. 代理回调:用URLSessionDelegate获取中间状态,实现进度、错误处理
  2. 后台传输:用background配置实现App退出后的下载,记得处理completionHandler
  3. 认证挑战:处理HTTPS证书验证,生产环境不要跳过,考虑证书固定

下一章我们会讲Combine与URLSession的结合,那才是真正的「网络层响应式编程」。到时候你会发现,用Combine处理网络请求,代码能简洁到让你怀疑人生。