一个 NSURLSession 分片上传任务统计进度的解决思路

在 iOS 中使用 NSURLSession 执行上传任务时经常遇到分片的问题,此时每片任务通过 - URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 方法每次获取到的 totalByetesExpected 参数就只是一片任务的大小,而不是文件总共的大小。所以使用 totalBytesSent/totabBytesExpectesToSend 算出的值也就不是这个文件上传任务真正的进度。

我在程序中使用一个 NSMutableArray *uploadingQueue 来管理每个文件的上传,文件上传开始时将任务添加到这个 uploadingQueue 中,并在下载完成后将这个文件上传任务从 uploadingQueue。

由于一个文件任务被分为多片,因此 uploadingQueue 中存储的自然也不能是一个 NSURLSessionUploadTask。通过建立一个 NSMutableArray *uploadingTask 来将所有 NSURLSessionUploadTask 管理起来。每次任务开始时创建出所有所需要的 NSURLSessionUploadTask 并全部添加进到 uploadingTask 数组中,并将 uploadingTask 添加到 uploadingQueue 中。结束时将这个 uploadingTask 从 uploadingQueue 移除。

为了实现整个文件任务的进度统计,还要添加一个 NSMutableDictionary *uploadingTasksInfoDict 用来记录每一个文件任务的信息,key 使用 NSURLSessionUploadTask 的 taskDescription 属性,为此我们需要将一个文件的所有分片 NSURLSessionUploadTask 的 taskDescription 设置成一致的,我使用了这个文件在服务器上的路径作为 taskDescription。

这样做的目的在于系统在调用 - URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 时,每次会传进来 task,使用 task.taskDescription 可以轻松的在 uploadingTasksInfoDict 中访问到这个文件的条目,修改信息。保证每一个文件的各个分片任务的 taskDescription 相同,并且保证每个文件的任务不相同,因此设置文件在服务器上的路径是比较合适的。

所以字典的 value 需要存储的信息有:文件的总大小,文件已经上传了的大小,一个指向文件 uploadingTask 数组的指针。

存储文件的总大小是为了计算文件的进度,每一次 - URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 被调用时,通过 taskDescription 在 uploadingTasksInfoDict 中找到这些信息,对已经上传了的大小进行更改,就可以通过已经上传了的大小,总大小的比值计算出上传进度。

存储指向自己 uploadingTask 数组的指针是为了方便的调用到这个数组,方便从 uploadingQueue 中移除。

下面用代码进行一些演示:

    //声明 uploadingQueue 并初始化。
    NSMutableArray *uploadingQueue; 
    uploadingQueue = [NSMutableArray array]; 

    //创建文件任务的信息字典
    NSMutableDictionary *uploadingTasksInfoDict;
    uploadingTasksInfoDict = [NSMuableDictionary dictionary];

    //声明每个文件的 uploaingTask 并进行初始化。
    NSMutableArray *uploadingTask;
    uploadingTask = [NSMutableArray array]; 

    //将文件分片后的 NSData 分别创建成不同的分片任务 frameTask,并设置 taskDescription 为服务端的路径。
    NSURLSessionUploadTask *frameTask;
    for(int i = 0; i < 10; ++i){
         frameTask = [NSURLSession uploadTaskWithRequest:request fromData:bodyData completionHandler:completionHandler]; 
        frameTask.taskDescription = filePath; 

        //将此分片任务加入 uploadingTask 数组中。
        [uploadingTask addObject: frameTask]; 
    }

    //创建单个文件的信息字典,并加入 uploadingTasksInfoDict,key 为 taskDescription。
    //其中 uploadingTask 为指向 uploadingTask 的指针。
    //fileSize 为整个文件的大小,需要自行计算。
    //sizeDidSent 设置为0,以后每次对这个数字进行叠加。加 @是因为这里需要一个 ObjC 的对象。
    //创建字典时加入 mutableCopy 是因为使用 @{} 创建的字典是 NSDictionary 类型,需要将其转换为 NSMutableDictionary,不然后续无法更改的。
    NSMutableArray *uploadingTaskInfoDict;
    uploadingTaskInfoDict = [@{
                                @"uploadingTask":uploadingTask,
                                @"fileSize":fileSize,
                                @"sizeDidSent": @0
                            } mutableCopy];
    [uploadingTasksInfoDict setObject: uploadingTaskInfoDict forKey: frameTask.taskDescription];

这样在开启任务时的任务就完成了。 下来在 NSURLSessionTaskDelegate 的方法 - URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 中对 sizeDidSent 进行叠加:

    - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didSendBodyData:(int64_t)bytesSent totalBytesSent:(int64_t)totalBytesSent totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend{

        //通过 taskDescription 取出字典信息。
        NSMutableDictionary *uploadingTaskInfoDict = self.uploadingTasksInfoDict[task.taskDescription];

        //获取到当前已经上传的大小,结果为 NSNumber,转换为 int 型。
        NSNumber *sizeDidSentInNSNumber = uploadingTaskInfo[@"sizeDidSent"];
        int64_t sizeDidSentInInt = [sizeDidSentInNSNumber intValue];

        //获取文件的总大小,结果为 NSNumber,转换为 double 型。
        NSNumber *fileSizeInNSNumber = uploadingTaskInfo[@"fileSize"];
        double fileSizeInDouble = [fileSizeInNSNumber doubleValue];

        //改变已经上传的大小,并修改字典中的值。
        sizeDidSentInInt = sizeDidSentInInt + bytesSent;
        uploadingTaskInfo[@"sizeDidSent"] = [NSNumber numberWithInt: sizeDidSentInInt];

        //计算进度
        double progress = sizeDidSentInInt/fileSizeInDouble;
    }

这里就完成了进度的计算、改变。 后来还有要做的就是每一块儿完成时开始下一块儿任务,建议不要同时下载多块,因为 Dictionary 可能会碰到多个线程同时访问的问题 :)

在文件上传完成后最后收尾时需要做的:

    //将 uploadingTask 从 uploadingQueue 中移除
    //需要想办法将 taskDescription 传入,这个需要根据具体情况来解决,不做演示。
    NSMutableDictionary *uploadingTaskInfoDict = self.uploadingTasksInfoDict[task.taskDescription];

    NSMutableArray *uploadingTask = uploadingTaskInfoDict[@"uploadingTask"];
    [self.uploadingQueue removeObject: uploadingTask];

到这里一个任务的上传就完成了,比较复杂,但是比较完美的解决了多片任务计算进度的问题。是自己在工程中碰到的问题,想出的解决方案。网上搜了许久没有结果,如果有更好的办法欢迎交流 :D。

最后需要提出一点,假如下载的对象和展示界面的 ViewController 是分开的,就涉及到要从下载对象更新 ViewController 的 view 的问题。

下面介绍一下自己的做法,为了方便,将 ViewController 称为 ViewController,下载管理的对象称为 DownloadManager。

以前使用的是每一次调用一次 DownloadManager 的 - URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 方法 就发送一个通知,同时将 ViewController 注册为这个通知的 Observer,以此来进行相应,进行 UI 的更新。后来发现这样效率特别低,NSNotification 的机制使得这个方法不适用于大量、快速的操作(调用 - URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 的频率是非常高的)。

因此后来才用了 delegate 的方法,在 ViewController 中声明 @protcol,由 DownloadManager 来实现这个 @protocol 的方法,以此方法来更新 UI,这样效率会高不少。