【iOS】關于大文件(如視頻)的分片上傳

前言

到目前為止,作為一個iOS開發人員,在開發這條路上也走了2年多時間了,之前一直忙于工作中的各種項目的開發。之前在一個項目里要做音視頻相關模塊,那個時候就開始著手研究相關的音視頻的播放,還有配套的上傳下載的相關內容,于是便有了本片文章的內容。

運行效果

話不多說,直接上demo的運行效果,下面是進行上傳任務時的效果。
image

使用方法

初始化


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    /*在App的啟動回調方法里面初始化上傳管里類*/
    [[CWFileUploadManager shardUploadManager] config:[self setUpRequest] maxTask:3];

    return YES;
}

    /*提供一個可供管理器初始化的參數*/
- (NSMutableURLRequest *)setUpRequest{
    NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@/upload/file",CURRENT_API]];
    NSMutableURLRequest* request=[NSMutableURLRequest requestWithURL:url];
    request.HTTPMethod=@"POST";
    [request setValue:[NSString stringWithFormat:@"multipart/form-data; boundary=%@",@"1a2b3c"] forHTTPHeaderField:@"Content-Type"];
    [request setValue:@"v1" forHTTPHeaderField:@"api_version"];
    return request;
}

新建上傳任務并開始上傳

//新建上傳任務 @param filePath 待上傳文件的地址
CWUploadTask *task = [[CWFileUploadManager shardUploadManager] createUploadTask:filePath];
//開始上傳
[task taskResume];

設計思路

這里必須提到一些常規的上傳方式,基本上項目中用到的上傳比較多的是圖片上傳,而且一般最多不會超過9張(例如微信),所以在不太講究的情況下可以直接使用將圖片讀取為NSData類型加載到內存中,然后在使用NSURLSessionTask派生出的一些子類(例如NSURLSessionUploadTask)直接調用系統提供的api進行上傳就行了。這種類型的文件上傳由于單個文件的體積太小,即使上傳失敗了再重復上傳也不礙事,所以也不用考慮續傳的問題。
視頻這種大文件則不能忽略上面這些問題,由于文件體積的增大,由量變產生的質變之后再使用上面的常規方式上傳則會產生一系列風險。首先是斷點續傳的問題,例如視頻上傳的過程中如果由于網絡或者服務端的一些原因而上傳失敗,這個時候在進行重新上傳顯然實不可取的,用戶也難以接受。再者一次性將文件讀取到內存中也會因為文件過大導致內存暴漲,這樣app就有崩潰閃退的風險。因此文件的分片再上傳也就成了順理成章的方案。
  • 文件分片

文件分片這個功能我設計了一個單獨的類cwfilestreamseparation去完成它提供文件路徑讓類的初始化方法返回一個記錄文件分片信息的對象
-(instancetype)initFileOperationAtPath:(NSString*)path forReadOperation:(BOOL)isReadOperation {

if (self = [super init]) {
    self.isReadOperation = isReadOperation;
    if (_isReadOperation) {
        if (![self getFileInfoAtPath:path]) {
            return nil;
        }
        self.readFileHandle = [NSFileHandle fileHandleForReadingAtPath:path];
        [self cutFileForFragments];
    } else {
        NSFileManager *fileMgr = [NSFileManager defaultManager];
        if (![fileMgr fileExistsAtPath:path]) {
            [fileMgr createFileAtPath:path contents:nil attributes:nil];
        }

        if (![self getFileInfoAtPath:path]) {
            return nil;
        }

        self.writeFileHandle = [NSFileHandle fileHandleForWritingAtPath:path];
    }
}

return self;

}
通過方法對文件進行切割
//切分文件片段
-(void)cutFileForFragments {
NSUInteger offset = CWStreamFragmentMaxSize;
// 塊數
NSUInteger chunks = (_fileSize%offset==0)?(_fileSize/offset):(_fileSize/(offset) + 1);

NSMutableArray<CWStreamFragment *> *fragments = [[NSMutableArray alloc] initWithCapacity:0];
for (NSUInteger i = 0; i < chunks; i ++) {

    CWStreamFragment *fFragment = [[CWStreamFragment alloc] init];
    fFragment.fragmentStatus = NO;
    fFragment.fragmentId = [[self class] fileKey];
    fFragment.fragementOffset = i * offset;

    if (i != chunks - 1) {
        fFragment.fragmentSize = offset;
    } else {
        fFragment.fragmentSize = _fileSize - fFragment.fragementOffset;
    }

    [fragments addObject:fFragment];
}

self.streamFragments = fragments;
將切割文件產生的片段信息保存到類CWStreamFragment里
@property (nonatomic,copy)NSString          *fragmentId;    //片的唯一標識
@property (nonatomic,assign)NSUInteger      fragmentSize;   //片的大小
@property (nonatomic,assign)NSUInteger      fragementOffset;//片的偏移量
@property (nonatomic,assign)BOOL            fragmentStatus; //上傳狀態 YES上傳成功

通過以上操作產生出一個CWFileStreamSeparation類的實例對象,這個對象將作為上傳任務的數據模型。

上傳任務

這里新建一個上傳任務的類CWUploadTask來實例化每個上傳任務對象,通過上傳任務對象去管理操作每個上傳任務,CWUploadTask會根據一個CWFileStreamSeparation模型來實例化。
+(instancetype)initWithStreamModel:(CWFileStreamSeparation *)fileStream
{
CWUploadTask *task = [CWUploadTask new];
task.fileStream = fileStream;
task.isSuspendedState = NO;
task.url = [CWFileUploadManager shardUploadManager].url;
return task;
}

由于上傳任務是一個持續與服務端交互的動作,因此在設計之初先要與服務端做各種約定,這里我與服務端約定在上傳前先上傳文件信息用于服務端做驗證,同時服務端會返回任務上傳的初始參數。這個返回的json數據里面的參數只有一個記錄文件分片編號的參數是會根據當前上傳的片段去做相應的更改,因此我建立了一個CWUploadTask的Category來專門實現這個獲取參數與驗證文件的網絡請求方法。
-(void)checkParamFromServer:(CWFileStreamSeparation *_Nonnull)fileStream
paramCallback:(void(^ _Nullable)(NSString *_Nonnull chunkNumName,NSDictionary *_Nullable param))paramBlock
{
NSString *uploadFileInfoUrl=[NSString stringWithFormat:@"%@/upload/checkFileChunk",CURRENT_API];

NSURL *url = [NSURL URLWithString:uploadFileInfoUrl];

NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
NSString *args = [NSString stringWithFormat:@"bizId=%@&fileName=%@&saveName=%@&chunks=%@",fileStream.bizId,fileStream.fileName,fileStream.md5String,[NSString stringWithFormat:@"%zd",fileStream.streamFragments.count]];
NSLog(@"%@",args);
request.HTTPMethod = @"POST";//設置請求類型
[request setValue:@"v1" forHTTPHeaderField:@"api_version"];
request.HTTPBody = [args dataUsingEncoding:NSUTF8StringEncoding];//設置參數
NSURLSession *session = [NSURLSession sharedSession];
//發送請求
NSURLSessionDataTask *postTask = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    if (error == nil) {
        //解析得到的數據
        NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:nil];
        if ([dict[@"code"] isEqualToString:@"500"]) {
            NSLog(@"%@",dict[@"desc"]);
            return;
        }
        NSMutableDictionary *tmpParam = [NSMutableDictionary dictionary];
        [tmpParam setDictionary:dict[@"data"]];
        paramBlock(@"chunk",tmpParam);
    }
}];

[postTask resume];
這里我直接使用了NSURLSessionDataTask做POST請求了,沒有使用AFNetworking,改寫為其它任意方式都無所謂。
獲取了參數傳遞的方式,下面就能開始上傳了
//上傳文件的核心方法
-(void)startExe{
//判斷無參數的情況下先將文件信息上傳并獲得參數
if (!_param) [self postFileInfo];
dispatch_group_t group = dispatch_group_create();
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
dispatch_queue_t queue = dispatch_queue_create(NULL, DISPATCH_QUEUE_SERIAL);

if (_fileStream.fileStatus == CWUploadStatusFinished && _successBlock) {
    _successBlock(_fileStream);
    return;
};
for (NSInteger i=0; i<_fileStream.streamFragments.count; i++) {
    CWStreamFragment *fragment = _fileStream.streamFragments[i];
    if (fragment.fragmentStatus) continue;
    dispatch_group_async(group, queue, ^{
        @autoreleasepool {
            NSData *data = [_fileStream readDateOfFragment:fragment];
            __weak typeof(self) weekSelf = self;
            [_param setObject:[NSString stringWithFormat:@"%zd",(i+1)] forKey:_chunkNumName];

            [self uploadTaskWithUrl:_url param:_param uploadData:data completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
                NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse*)response;
                if (!error && httpResponse.statusCode==200) {
                    _taskRepeatNum = 0;
                    fragment.fragmentStatus = YES;
                    _fileStream.fileStatus = CWUploadStatusUpdownloading;
                    [weekSelf archTaskFileStream];
                    weekSelf.lastParam = _param;
                    weekSelf.chunkNo = i+1;
                    dispatch_async(dispatch_get_main_queue(), ^{
                        if (_finishBlock) _finishBlock(_fileStream,nil);
                        [self sendNotionWithKey:@"CWUploadTaskExeing" userInfo:@{@"fileStream":_fileStream,@"lastParam":_lastParam,@"indexNo":@(_chunkNo)}];
                    });
                    dispatch_semaphore_signal(semaphore);
                }else{
                    if (_taskRepeatNum<REPEAT_MAX) {
                        _taskRepeatNum++;
                        [weekSelf startExe];
                    }else{
                        _fileStream.fileStatus = CWUploadStatusFailed;
                        dispatch_async(dispatch_get_main_queue(), ^{
                            if (_finishBlock) _finishBlock(_fileStream,error);
                            [self sendNotionWithKey:@"CWUploadTaskExeError" userInfo:@{@"fileStream":_fileStream,@"error":error}];
                        });
                        [weekSelf deallocSession];
                        return;
                    }
                }
            }];
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        }
    });
}

dispatch_group_notify(group, queue, ^{
    _fileStream.fileStatus = CWUploadStatusFinished;
    [self archTaskFileStream];
     if (_finishBlock) _finishBlock(_fileStream,nil);
    [self deallocSession];
});
這里對文件的片段做了循環的上傳,由于網絡請求是一個異步的操作,同時也考慮到太多并發(當然系統對于網絡請求開辟的線程個數也有限制)對于手機性能的影響,因此利用GCD信號量等待這種功能特性讓一個片段上傳完之后再進行下一個片段的上傳。

mark: 在這里有一個坑

當時在調試上傳時我發現隨著上傳任務的進行,app所占用的內存也會不斷地上漲,本來分片上傳就是要去避免內存占用過大的問題,但是調試時還是出現了內存不斷增長的問題。之后我開始著手分析問題產生的原因,首先來講它的內存是隨著上傳任務的執行時間線性的上漲的,通過這個就可以判斷內存增長的原因并不是分片讀取失敗而直接讀取大文件到內存中。雖然排除了一部分原因,但是問題還沒有解決。還是通過內存的線性增長這個特性,我隱約感覺到是每一片的上傳任務執行完之后并沒有立即的釋放內存,那么在不斷地循環上傳的過程中內存的占用就會不斷升高直到程序崩潰。后面通過調試證實了這個猜想。知道原因之后就著手解決,這里說個題外話,ARC這個機制確實好,讓我們iOS開發過程中基本沒有去寫管理內存的代碼,不過遇到這種情況就必須親自上陣來管理這一部分的內存了,在循環的方法中加了個autoreleasepool,讓執行完的任務及時的釋放內存,于是就填上了這個坑。

mark:填了個新坑

在文件分片中有生成文件的MD5摘要的方法
+ (NSString *)fileKey {
    
    CFUUIDRef uuid = CFUUIDCreate(kCFAllocatorDefault);
    CFStringRef cfstring = CFUUIDCreateString(kCFAllocatorDefault, uuid);
    const char *cStr = CFStringGetCStringPtr(cfstring,CFStringGetFastestEncoding(cfstring));
    unsigned char result[16];
    CC_MD5( cStr, (unsigned int)strlen(cStr), result );
    CFRelease(uuid);
    CFRelease(cfstring);
    
    return [NSString stringWithFormat:
            @"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%08lx",
            result[0], result[1], result[2], result[3],
            result[4], result[5], result[6], result[7],
            result[8], result[9], result[10], result[11],
            result[12], result[13], result[14], result[15],
            (unsigned long)(arc4random() % NSUIntegerMax)];
}
注意其中有CF開頭的方法和變量,這是CoreFoundation中的東西,這個框架里的對象需要自己調用CFRelease()方法進行回收,之前忘了寫,導致了內存泄漏。

已經能夠實現上傳了,接下來當然是任務管理

任務管理

考慮到實際需求,這里需要提供一個單例來作為上傳任務的管理器。這里就沒有太多可說的了,無非是提供一些容器來分門別類的操作不同狀態的上傳任務。我這里是將一些任務建立,全部任務的刪除,暫停之類的方法設計在管理類之上

//根據文件路徑創建上傳任務
- (CWUploadTask *_Nullable)createUploadTask:(NSString *_Nonnull)filePath;
/**
 刪除一個上傳任務,同時會刪除當前任務上傳的緩存數據
 
 @param fileStream 上傳文件的路徑
 */
- (void)removeUploadTask:(CWFileStreamSeparation *_Nonnull)fileStream;

/**
 暫停所有上傳任務
 */
- (void)pauseAllUploadTask;

/**
 刪除所有上傳任務
 */
- (void)removeAllUploadTask;

以及一些可能常用的對任務分類的容器
//總任務數
@property (nonatomic,readonly)NSMutableDictionary *allTasks;

//正在上傳中的任務
@property (nonatomic,readonly)NSMutableDictionary *uploadingTasks;

//正在等待上傳的任務
@property (nonatomic,readonly)NSMutableDictionary *uploadWaitTasks;

//已經上傳完的任務
@property (nonatomic,readonly)NSMutableDictionary *uploadEndTasks;
同時一些全局相關的設置也放在這個管理類中
//配置全局默認參數
/**
 @param request 默認請求頭
 @param num 最大任務數,默認為3
 */
- (void)config:(NSMutableURLRequest * _Nonnull)request maxTask:(NSInteger)num;
PS:到這里為止,整個文件分片上傳的思路就寫完了,如果這一篇東西能給大家提供一些思路那最好了,如果有什么疏漏也請各位大佬直接指出。。。

附上demo

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容