Day15-NSURLSession 断点续传 + 网络封装

YVTU

一、NSURLSession 下载机制原理

类型回顾:

  • NSURLSessionDataTask: 用于请求和响应小数据。
  • NSURLSessionDownloadTask: 用于下载大文件,支持断点续传。
  • NSURLSessionUploadTask: 用于上传数据。

对于断点续传NSURLSessionDownloadTask 是唯一支持此功能的类型。


二、断点续传机制详解

1. 如何中断任务生成 Resume Data

1
2
3
4
[downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
// 将 resumeData 写入本地
[resumeData writeToFile:@"xxx.resume" atomically:YES];
}];

Resume Data 生成条件:

  • 任务必须是 downloadTask。
  • 必须使用 cancelByProducingResumeData
  • Session 配置必须是 defaultSessionConfiguration,不是 background
  • 网络中断导致取消并不会自动生成 resumeData。

2. Resume Data 本质是什么?

  • resumeData 实际是一个带有 HTTP Range 请求头的二进制 plist 数据,内容包含:
    • 请求 URL
    • 上次下载的 Range
    • 已下载的文件路径
    • 请求 Header
    • 临时文件信息等

你可以用如下代码解析 resumeData(调试用):

1
2
3
NSError *error = nil;
NSDictionary *resumeDict = [NSPropertyListSerialization propertyListWithData:resumeData options:0 format:nil error:&error];
NSLog(@"Resume Info: %@", resumeDict);

三、Resume Data 的问题与兼容性

iOS 10-13 常见问题:

  • iOS 10-11 resumeData 会“损坏”,Apple Bug。
  • iOS 13 后 Resume Data 更加严格:URL、Header 一点点改动就不能用。
  • resumeData 不能跨机器或跨进程恢复。

校验 Resume Data 可用性(推荐封装方法中加判断):

1
2
3
4
5
- (BOOL)isValidResumeData:(NSData *)data {
if (data.length == 0) return NO;
NSDictionary *dict = [NSPropertyListSerialization propertyListWithData:data options:0 format:nil error:nil];
return dict[@"NSURLSessionResumeInfoTempFileName"] != nil;
}

四、网络封装架构设计(DownloadManager)

1. 设计目标

  • 支持多任务同时下载
  • 支持任务暂停、恢复、取消
  • 下载进度可观察
  • 稳定保存 resumeData
  • 支持后台下载可选(非断点续传)

2. 数据结构设计

1
2
3
4
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSURLSessionDownloadTask *> *downloadTasks;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSData *> *resumeDataDict;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *progressDict;
@property (nonatomic, strong) NSURLSession *session;

3. 方法定义

1
2
3
4
- (void)startDownloadWithURL:(NSURL *)url identifier:(NSString *)identifier;
- (void)pauseDownloadWithIdentifier:(NSString *)identifier;
- (void)resumeDownloadWithIdentifier:(NSString *)identifier;
- (void)cancelDownloadWithIdentifier:(NSString *)identifier;

4. DownloadManager.m 实现要点

创建下载任务

1
2
3
4
5
6
7
- (void)startDownloadWithURL:(NSURL *)url identifier:(NSString *)identifier {
if (self.downloadTasks[identifier]) return;

NSURLSessionDownloadTask *task = [self.session downloadTaskWithURL:url];
self.downloadTasks[identifier] = task;
[task resume];
}

暂停任务

1
2
3
4
5
6
7
8
9
10
- (void)pauseDownloadWithIdentifier:(NSString *)identifier {
NSURLSessionDownloadTask *task = self.downloadTasks[identifier];
[task cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
if (resumeData) {
self.resumeDataDict[identifier] = resumeData;
[resumeData writeToFile:[self resumeDataPathForIdentifier:identifier] atomically:YES];
}
[self.downloadTasks removeObjectForKey:identifier];
}];
}

恢复任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (void)resumeDownloadWithIdentifier:(NSString *)identifier {
NSData *resumeData = self.resumeDataDict[identifier];
if (!resumeData) {
resumeData = [NSData dataWithContentsOfFile:[self resumeDataPathForIdentifier:identifier]];
}

NSURLSessionDownloadTask *task = nil;
if ([self isValidResumeData:resumeData]) {
task = [self.session downloadTaskWithResumeData:resumeData];
} else {
// fallback:重新下载
NSURL *url = [self downloadURLForIdentifier:identifier];
task = [self.session downloadTaskWithURL:url];
}

self.downloadTasks[identifier] = task;
[task resume];
}

代理方法处理下载完成/进度

1
2
3
4
5
6
7
8
9
10
11
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
didWriteData:(int64_t)bytesWritten
totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {

NSString *identifier = [self identifierForTask:downloadTask];
CGFloat progress = (CGFloat)totalBytesWritten / totalBytesExpectedToWrite;
self.progressDict[identifier] = @(progress);

// 回调通知 UI
}
1
2
3
4
5
6
7
8
9
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
didFinishDownloadingToURL:(NSURL *)location {
NSString *identifier = [self identifierForTask:downloadTask];
NSURL *destinationURL = [self finalFilePathForIdentifier:identifier];
[[NSFileManager defaultManager] moveItemAtURL:location toURL:destinationURL error:nil];

[self.downloadTasks removeObjectForKey:identifier];
[self.resumeDataDict removeObjectForKey:identifier];
}

五、后台下载与断点续传的冲突

后台下载使用:

1
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"bg.download"];

限制:

  • 不支持 cancelByProducingResumeData,取消任务不会生成 resumeData。
  • 暂停任务只能取消,不能 resume。
  • 适合非断点续传的被动文件下载,比如 OS 更新、邮件附件等。

六、进阶优化建议

1. 文件分片多线程下载(适合大文件,如视频)

  • 先获取文件大小(HEAD 请求)
  • 分成多个 Range 任务并发下载
  • 自行合并片段

2. 添加 SHA/MD5 校验

  • 防止 resume 后的数据不一致
  • 下载完成校验数据一致性

3. 使用数据库记录下载任务状态(可选:SQLite/FMDB)

  • identifier、状态、进度、路径
  • 恢复 App 后能自动恢复任务

七、工具/类库推荐