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

注意:

  1. expectedContentLength 在 chunked 传输编码下返回 NSURLResponseUnknownLength(-1),此时无法计算进度,需做判断。
  2. 代理方法在子线程调用,更新 UI 必须回主线程。
  3. 下载完成或失败后必须 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];
}

注意:

  1. boundary 必须随机生成(如 NSUUID),不能用固定字符串,否则可能与文件内容冲突导致解析失败。
  2. 大文件上传时,将整个文件读入 NSData 再拼接 body 会导致内存暴涨,应使用 NSInputStream 作为 HTTPBodyStream 或 NSURLSessionUploadTask。
  3. 实际开发推荐使用 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。

posted @ 2015-08-23 09:47  Mr.陳  阅读(1120)  评论(0)    收藏  举报