iOS开发基础42-文件下载与上传:大文件流式下载、断点续传、multipart 上传与 SSZipArchive
iOS 文件下载与上传深度解析:大文件流式下载、断点续传、multipart 上传与 SSZipArchive
本文系统梳理 iOS 文件处理:小文件下载、大文件下载的三种方案(NSMutableData/NSFileHandle/NSOutputStream)及 NSURLSessionDownloadTask 推荐方案、SSZipArchive 压缩解压、MIMEType 获取、multipart/form-data 文件上传、Range 断点续传与 NSURLSession 原生断点续传。
一、小文件下载
小文件(图片、小音频等,通常几 MB 以内)可直接用 NSURLSession Data Task 下载,数据一次性加载到内存:
NSURL *url = [NSURL URLWithString:@"https://example.com/file.png"];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error || !data) {
NSLog(@"下载失败: %@", error);
return;
}
UIImage *image = [UIImage imageWithData:data];
dispatch_async(dispatch_get_main_queue(), ^{
self.imageView.image = image;
});
}] resume];
小文件下载数据全部在内存中,大文件(视频、压缩包)不能用此方式,否则内存暴涨导致崩溃。
二、大文件下载
大文件下载必须边下载边写入磁盘,不能全部缓存在内存。有三种实现方式:
方案对比
| 方案 | 内存占用 | 说明 |
|---|---|---|
| NSMutableData 拼接 | 高(等于文件大小) | 不推荐,内存暴涨 |
| NSFileHandle 写入文件 | 低(仅当前数据包) | 手动管理文件句柄 |
| NSOutputStream 写入文件 | 低(仅当前数据包) | 输出流,自动管理 |
| NSURLSessionDownloadTask | 最低(系统管理) | 推荐,系统自动写入临时文件 |
1. NSMutableData(不推荐)
@property (nonatomic, strong) NSMutableData *fileData;
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
[self.fileData appendData:data]; // 全部数据在内存,大文件崩溃
}
2. NSFileHandle(手动写入文件)
@interface ViewController () <NSURLSessionDataDelegate>
@property (nonatomic, strong) NSFileHandle *fileHandle;
@property (nonatomic, copy) NSString *filePath;
@property (nonatomic, assign) long long totalLength;
@property (nonatomic, assign) long long currentLength;
@property (weak, nonatomic) IBOutlet UIProgressView *progressView;
@end
@implementation ViewController
- (void)startDownload {
NSURL *url = [NSURL URLWithString:@"https://example.com/large.mp4"];
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
NSURLSessionDataTask *task = [session dataTaskWithURL:url];
[task resume];
}
#pragma mark - NSURLSessionDataDelegate
// 接收到服务器响应
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
// 创建文件
NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
self.filePath = [caches stringByAppendingPathComponent:response.suggestedFilename ?: @"download"];
[[NSFileManager defaultManager] createFileAtPath:self.filePath contents:nil attributes:nil];
// 创建文件句柄
self.fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.filePath];
// 文件总大小(chunked 传输时可能为 NSURLResponseUnknownLength = -1)
self.totalLength = response.expectedContentLength;
self.currentLength = 0;
completionHandler(NSURLSessionResponseAllow); // 允许继续接收数据
}
// 接收到数据(调用多次)
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
// 写入文件末尾
[self.fileHandle seekToEndOfFile];
[self.fileHandle writeData:data];
self.currentLength += data.length;
// 更新进度(代理方法在子线程,UI 更新需回主线程)
if (self.totalLength > 0) {
CGFloat progress = (CGFloat)self.currentLength / self.totalLength;
dispatch_async(dispatch_get_main_queue(), ^{
self.progressView.progress = progress;
});
}
}
// 下载完成
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
[self.fileHandle closeFile];
if (error) {
NSLog(@"下载失败: %@", error);
} else {
NSLog(@"下载完成: %@", self.filePath);
}
}
@end
注意:
expectedContentLength在 chunked 传输编码下返回NSURLResponseUnknownLength(-1),此时无法计算进度,需做判断。- 代理方法在子线程调用,更新 UI 必须回主线程。
- 下载完成或失败后必须
closeFile关闭文件句柄。
3. NSOutputStream(输出流写入)
@property (nonatomic, strong) NSOutputStream *outputStream;
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
self.filePath = [caches stringByAppendingPathComponent:response.suggestedFilename ?: @"download"];
// 创建输出流(append:YES 追加写入)
self.outputStream = [NSOutputStream outputStreamToFileAtPath:self.filePath append:YES];
[self.outputStream open];
self.totalLength = response.expectedContentLength;
completionHandler(NSURLSessionResponseAllow);
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
// write 返回实际写入的字节数,可能小于 maxLength,需检查
NSInteger written = [self.outputStream write:data.bytes maxLength:data.length];
if (written < 0) {
NSLog(@"写入失败");
return;
}
self.currentLength += written;
// 更新进度...
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
[self.outputStream close];
}
outputStream write:maxLength:返回实际写入字节数,可能因缓冲区满而小于 data.length,需根据返回值处理剩余数据(简单场景可忽略,但严谨实现需循环写入)。
4. NSURLSessionDownloadTask(推荐)
NSURLSession 提供专门的下载任务,系统自动将数据写入临时文件,内存占用最低,支持后台下载和断点续传:
NSURL *url = [NSURL URLWithString:@"https://example.com/large.mp4"];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] downloadTaskWithURL:url completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"下载失败: %@", error);
return;
}
// location 是临时文件路径,下载完成后系统会自动删除,必须移动到目标位置
NSString *caches = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
NSString *destPath = [caches stringByAppendingPathComponent:response.suggestedFilename];
NSURL *destURL = [NSURL fileURLWithPath:destPath];
NSError *moveError = nil;
[[NSFileManager defaultManager] moveItemAtURL:location toURL:destURL error:&moveError];
if (!moveError) {
NSLog(@"下载完成: %@", destPath);
}
}];
[task resume];
DownloadTask 的
location是临时文件,completionHandler 返回后系统会删除,必须在 block 内移动到目标路径。
带进度的 DownloadTask(需实现代理):
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
CGFloat progress = (CGFloat)totalBytesWritten / totalBytesExpectedToWrite;
dispatch_async(dispatch_get_main_queue(), ^{
self.progressView.progress = progress;
});
}
三、文件压缩与解压缩(SSZipArchive)
SSZipArchive 是 iOS 最常用的 Zip 压缩解压库,基于 minizip,需通过 CocoaPods 集成:pod 'SSZipArchive'。
压缩文件
#import <SSZipArchive.h>
NSArray *files = @[@"/path/to/file1.txt", @"/path/to/file2.jpg"];
NSString *zipPath = @"/path/to/archive.zip";
// 压缩(无密码)
BOOL success = [SSZipArchive createZipFileAtPath:zipPath withFilesAtPaths:files];
// 压缩(带密码)
BOOL success = [SSZipArchive createZipFileAtPath:zipPath withFilesAtPaths:files withPassword:@"password"];
解压缩文件
NSString *zipPath = @"/path/to/archive.zip";
NSString *destPath = @"/path/to/destination";
// 解压(无密码)
BOOL success = [SSZipArchive unzipFileAtPath:zipPath toDestination:destPath];
// 解压(带密码 + 进度)
[SSZipArchive unzipFileAtPath:zipPath toDestination:destPath overwrite:YES password:@"password" error:nil delegate:self];
// 进度代理
- (void)zipArchive:(SSZipArchive *)zipArchive extractEntry:(NSString *)entry uncompressedSize:(uint64_t)uncompressedSize compressedSize:(uint64_t)compressedSize {
NSLog(@"解压中: %@", entry);
}
四、获取文件 MIMEType
MIMEType 用于标识文件类型(如 image/png、video/mp4),文件上传时需设置 Content-Type。
方式一:根据文件扩展名(UTI,推荐)
#import <MobileCoreServices/MobileCoreServices.h>
+ (NSString *)mimeTypeForFileAtPath:(NSString *)path {
if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
return @"application/octet-stream";
}
NSString *extension = [path pathExtension];
if (!extension.length) {
return @"application/octet-stream";
}
// 通过 UTI 获取 MIMEType
CFStringRef UTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension,
(__bridge CFStringRef)extension,
NULL);
CFStringRef MIMEType = UTTypeCopyPreferredTagWithClass(UTI, kUTTagClassMIMEType);
CFRelease(UTI);
if (!MIMEType) {
return @"application/octet-stream"; // 未知类型默认二进制流
}
return (__bridge_transfer NSString *)MIMEType;
}
iOS 14+ 推荐使用
UniformTypeIdentifiers框架的UTType:#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h> UTType *type = [UTType typeWithFilenameExtension:extension]; NSString *mimeType = type.preferredMIMEType ?: @"application/octet-stream";
方式二:通过网络响应获取
发送 HEAD 请求获取响应头中的 Content-Type(不下载文件体):
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"HEAD"; // 只请求头,不下载内容
[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
NSString *mimeType = response.MIMEType;
}] resume];
不推荐用同步请求获取 MIMEType(阻塞线程),HEAD 请求更轻量。
五、文件上传(multipart/form-data)
文件上传使用 multipart/form-data 格式,请求体由 boundary 分隔多个部分(文件参数 + 普通参数)。
1. 构建请求
- (void)uploadFile:(NSData *)fileData fileName:(NSString *)fileName mimeType:(NSString *)mimeType {
NSURL *url = [NSURL URLWithString:@"https://api.example.com/upload"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"POST";
// 生成随机 boundary(避免与文件内容冲突)
NSString *boundary = [NSString stringWithFormat:@"Boundary-%@", [[NSUUID UUID] UUIDString]];
[request setValue:[NSString stringWithFormat:@"multipart/form-data; boundary=%@", boundary]
forHTTPHeaderField:@"Content-Type"];
// 构建请求体
NSMutableData *body = [NSMutableData data];
// 文件参数
[body appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"file\"; filename=\"%@\"\r\n", fileName] dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:[[NSString stringWithFormat:@"Content-Type: %@\r\n\r\n", mimeType] dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:fileData];
[body appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
// 普通参数(非文件)
[body appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:[@"Content-Disposition: form-data; name=\"userId\"\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:[@"12345" dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]];
// 结束标记
[body appendData:[[NSString stringWithFormat:@"--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
request.HTTPBody = body;
// 发送请求
[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"上传失败: %@", error);
} else {
NSLog(@"上传成功");
}
}] resume];
}
注意:
- boundary 必须随机生成(如
NSUUID),不能用固定字符串,否则可能与文件内容冲突导致解析失败。- 大文件上传时,将整个文件读入
NSData再拼接 body 会导致内存暴涨,应使用NSInputStream作为HTTPBodyStream或NSURLSessionUploadTask。- 实际开发推荐使用 AFNetworking 的
multipartFormRequestWithMethod:自动构建请求体。
2. 大文件上传(NSInputStream,避免内存暴涨)
// 用 input stream 代替 HTTPBody,文件不全部加载到内存
request.HTTPBodyStream = [NSInputStream inputStreamWithFileAtPath:filePath];
[request setValue:@([[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:nil].fileSize).stringValue
forHTTPHeaderField:@"Content-Length"];
六、断点续传
断点续传允许下载中断后从已下载位置继续,无需重新下载整个文件。
1. Range 请求头(手动实现)
通过 HTTP Range 请求头指定从哪个字节开始下载,服务器返回 206 Partial Content:
- (void)resumeDownloadFromByte:(long long)startByte url:(NSURL *)url {
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
// 从 startByte 字节开始下载到文件末尾
[request setValue:[NSString stringWithFormat:@"bytes=%lld-", startByte] forHTTPHeaderField:@"Range"];
NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]
delegate:self
delegateQueue:nil];
[[session dataTaskWithRequest:request] resume];
}
// 接收到响应时检查状态码
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
if (httpResponse.statusCode == 206) {
NSLog(@"服务器支持断点续传,从指定位置继续");
// 打开已有文件,准备追加写入
self.fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.filePath];
[self.fileHandle seekToEndOfFile];
} else if (httpResponse.statusCode == 200) {
NSLog(@"服务器不支持 Range,重新下载整个文件");
// 创建新文件
}
completionHandler(NSURLSessionResponseAllow);
}
断点续传前提:服务器必须支持 Range 请求并返回
206 Partial Content。若服务器返回 200,说明不支持断点续传,只能重新下载。需检查Accept-Ranges: bytes响应头判断服务器是否支持。
2. NSURLSession 原生断点续传(推荐)
NSURLSessionDownloadTask 原生支持断点续传,取消时生成 resumeData,恢复时用 resumeData 创建新任务:
@property (nonatomic, strong) NSURLSessionDownloadTask *downloadTask;
@property (nonatomic, strong) NSData *resumeData;
// 开始下载
- (void)startDownload {
NSURL *url = [NSURL URLWithString:@"https://example.com/large.mp4"];
self.downloadTask = [[NSURLSession sharedSession] downloadTaskWithURL:url];
[self.downloadTask resume];
}
// 暂停(取消并保存 resumeData)
- (void)pauseDownload {
[self.downloadTask cancelByProducingResumeData:^(NSData *resumeData) {
self.resumeData = resumeData; // 保存续传数据
self.downloadTask = nil;
}];
}
// 恢复下载
- (void)resumeDownload {
if (!self.resumeData) {
[self startDownload];
return;
}
self.downloadTask = [[NSURLSession sharedSession] downloadTaskWithResumeData:self.resumeData];
[self.downloadTask resume];
self.resumeData = nil;
}
cancelByProducingResumeData:仅在下载任务已接收部分数据且服务器支持 Range 时才能生成 resumeData,否则回调中 resumeData 为 nil。
七、Swift 版本对照
大文件下载(DownloadTask)
let url = URL(string: "https://example.com/large.mp4")!
URLSession.shared.downloadTask(with: url) { location, response, error in
guard let location = location, error == nil else { return }
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let dest = caches.appendingPathComponent(response?.suggestedFilename ?? "file")
try? FileManager.default.moveItem(at: location, to: dest)
}.resume()
断点续传
var downloadTask: URLSessionDownloadTask?
var resumeData: Data?
func pause() {
downloadTask?.cancel { data in
self.resumeData = data
}
}
func resume() {
if let data = resumeData {
downloadTask = URLSession.shared.downloadTask(withResumeData: data)
} else {
downloadTask = URLSession.shared.downloadTask(with: url)
}
downloadTask?.resume()
}
MIMEType(iOS 14+ UTType)
import UniformTypeIdentifiers
func mimeType(for path: String) -> String {
let ext = (path as NSString).pathExtension
let type = UTType(filenameExtension: ext)
return type?.preferredMIMEType ?? "application/octet-stream"
}
SSZipArchive
import SSZipArchive
// 压缩
SSZipArchive.createZipFile(atPath: zipPath, withFilesAtPaths: [file1, file2])
// 解压
SSZipArchive.unzipFile(atPath: zipPath, toDestination: destPath)
八、总结
- 小文件下载:NSURLSession DataTask,数据全部在内存,适合几 MB 以内文件。
- 大文件下载:必须边下载边写磁盘。NSMutableData 拼接不推荐(内存暴涨);NSFileHandle 和 NSOutputStream 手动写入文件(内存低);NSURLSessionDownloadTask 推荐(系统自动写入临时文件,内存最低,支持后台和断点续传)。
- 大文件下载注意:
expectedContentLength在 chunked 传输时为 -1 无法计算进度;代理方法在子线程,UI 更新需回主线程;下载完成必须关闭文件句柄/输出流;DownloadTask 的临时文件需在 completionHandler 内移动到目标路径。 - 压缩解压:SSZipArchive(CocoaPods 集成),支持密码和进度代理。
- MIMEType:通过 UTI(
UTTypeCreatePreferredIdentifierForTag)根据扩展名获取,iOS 14+ 推荐UniformTypeIdentifiers.UTType;未知类型默认application/octet-stream。 - 文件上传:multipart/form-data 格式,boundary 必须随机生成(NSUUID);大文件上传用
HTTPBodyStream或NSURLSessionUploadTask避免内存暴涨;推荐 AFNetworking 自动构建。 - 断点续传:手动实现用
Range: bytes=X-请求头,服务器需返回 206 Partial Content;推荐 NSURLSession 原生断点续传(cancelByProducingResumeData:+downloadTaskWithResumeData:),仅在服务器支持 Range 时有效。 - NSURLConnection:本文所有 NSURLConnection 相关 API 在 iOS 9 起已废弃,新项目统一使用 NSURLSession。

浙公网安备 33010602011771号