objective-cdownloadnsurlsession

Running multiple NSURLSessionDataTask sequently and tracking their progress


Hi all I was just wondering how can I make serial download with NSURLSessionTask in order? what am I looking for is to download the first time once it finished go to the next one but no matter how I try it still goes parallel and not in order. I have tried DISPATCH_QUEUE_SERIAL and dispatch_group_t.

The only way is working is this but the problem is it doesn't call the delegate methods since it calls the completion handler so I can't update the user about the progress. one more thing is I can't use NSURLSessionDownloadTask I have to use "DataTask" .

here is latest code I was trying with no result

-(void)download1{

self.task1 = [ self.session dataTaskWithURL:[NSURL URLWithString:@"https://example.com/file.zip"]];
[self.task1 resume];
}
-(void)download2 {

self.task2 = [self.session dataTaskWithURL:[NSURL URLWithString:@"https://example.com/file.z01"]];

}

-(void)download3 {

self.task3 = [self.session dataTaskWithURL:[NSURL URLWithString:@"https://example.com/file.z02"]];

}

-(void)download:(id)sender {

[self testInternetConnection];

dispatch_queue_t serialQueue = dispatch_queue_create("serial", DISPATCH_QUEUE_SERIAL);
dispatch_sync(serialQueue, ^{
    [self download1];
});

dispatch_sync(serialQueue, ^{
    [self download2];
    [self.task2 resume];
    
});

dispatch_sync(serialQueue, ^{
    [self download3];
    [self.task3 resume];
});



}

Im having only one UIProgressView , and a UILabel to update during the download of each file. Thanks in advance.


Solution

  • Per Chunk Progress

    You can wrap your operations with NSOperation instances and set up dependencies between them. It's extra-convenient for your scenario, because NSOperationQueue supports NSProgress reporting out of the box. I would still wrap the solution inside of the following interface (a minimalistic example but you can extend it as needed):

    @interface TDWSerialDownloader : NSObject
    
    @property(copy, readonly, nonatomic) NSArray<NSURL *> *urls;
    @property(strong, readonly, nonatomic) NSProgress *progress;
    
    - (instancetype)initWithURLArray:(NSArray<NSURL *> *)urls;
    - (void)resume;
    
    @end
    

    In the anonymous category of the class (implementation file) ensure that you also have a separate property to store NSOperationQueue (it will later needed to retrieve the NSProgress instance):

    @interface TDWSerialDownloader()
    
    @property(strong, readonly, nonatomic) NSOperationQueue *tasksQueue;
    @property(copy, readwrite, nonatomic) NSArray<NSURL *> *urls;
    
    @end
    

    In the constructor create the queue and make a shallow copy of the urls provided (NSURL doesn't have a mutable counterpart, unlike NSArray):

    - (instancetype)initWithURLArray:(NSArray<NSURL *> *)urls {
        if (self = [super init]) {
            _urls = [[NSArray alloc] initWithArray:urls copyItems:NO];
            NSOperationQueue *queue = [NSOperationQueue new];
            queue.name = @"the.dreams.wind.SerialDownloaderQueue";
            queue.maxConcurrentOperationCount = 1;
            _tasksQueue = queue;
        }
        return self;
    }
    

    Don't forget to expose the progress property of the queue so views can later get use of it:

    - (NSProgress *)progress {
        return _tasksQueue.progress;
    }
    

    Now the centrepiece part. You actually don't have control over which thread the NSURLSession performs the requests in, it always happens asynchronously, thus you have to synchronise manually between the delegateQueue of NSURLSession (the queue callbacks are performed in) and the NSOperationQueue inside of operations. I usually use semaphores for that, but of course there is more than one method for such a scenario. Also, if you add operations to the NSOperationQueue, it will try to run them straight away, but you don't want it, as first you need to set up dependencies between them. For this reason you should set suspended property to YES for until all operations are added and dependencies set up. Complete implementation of those ideas are inside of the resume method:

    - (void)resume {
        NSURLSession *session = NSURLSession.sharedSession;
        // Prevents queue from starting the download straight away
        _tasksQueue.suspended = YES;
        NSOperation *lastOperation;
        for (NSURL *url in _urls.reverseObjectEnumerator) {
            NSOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
                NSLog(@"%@ started", url);
                __block dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
                NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
                    NSLog(@"%@ was downloaded", url);
                    // read data here if needed
                    dispatch_semaphore_signal(semaphore);
                }];
                [task resume];
                // 4 minutes timeout
                dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC * 60 * 4));
                NSLog(@"%@ finished", url);
            }];
            if (lastOperation) {
                [lastOperation addDependency:operation];
            }
            lastOperation = operation;
            [_tasksQueue addOperation:operation];
        }
        _tasksQueue.progress.totalUnitCount = _tasksQueue.operationCount;
        
        _tasksQueue.suspended = NO;
    }
    

    Be advised that no methods/properties of TDWSerialDownloader are thread safe, so ensure you work with it from a single thread.


    Here how use of this class looks like in the client code:

    TDWSerialDownloader *downloader = [[TDWSerialDownloader alloc] initWithURLArray:@[
        [[NSURL alloc] initWithString:@"https://google.com"],
        [[NSURL alloc] initWithString:@"https://stackoverflow.com/"],
        [[NSURL alloc] initWithString:@"https://developer.apple.com/"]
    ]];
    _mProgressView.observedProgress = downloader.progress;
    [downloader resume];
    

    _mProgressView is an instance of UIProgressView class here. You also want to keep a strong reference to the downloader until all operations are finished (otherwise it may have the tasks queue prematurely deallocated).


    Per Cent Progress

    For the requirements you provided in the comments, i.e. per cent progress tracking when using NSURLSessionDataTask only, you can't rely on the NSOperationQueue on its own (the progress property of the class just tracks number of completed tasks). This is a much more complicated problem, which can be split into three high-level steps:

    1. Requesting length of the entire data from the server;
    2. Setting up NSURLSessionDataDelegate delegate;
    3. Performing the data tasks sequentially and reporting obtained data progress to the UI;

    Step 1

    This step cannot be done if you don't have control over the server implementation or if it doesn't already support any way to inform the client about the entire data length. How exactly this is done is up to the protocol implementation, but commonly you either use a partial Range or HEAD request. In my example i'll be using the HEAD request:

    NSOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
        if (!weakSelf) {
            return;
        }
        
        typeof(weakSelf) __strong strongSelf = weakSelf;
        [strongSelf p_changeProgressSynchronised:^(NSProgress *progress) {
            progress.totalUnitCount = 0;
        }];
        __block dispatch_group_t lengthRequestsGroup = dispatch_group_create();
        for (NSURL *url in strongSelf.urls) {
            dispatch_group_enter(lengthRequestsGroup);
            NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
            request.HTTPMethod = @"HEAD";
            typeof(self) __weak weakSelf = strongSelf;
            NSURLSessionDataTask *task = [strongSelf->_urlSession dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse
    *_Nullable response, NSError *_Nullable error) {
                if (!weakSelf) {
                    return;
                }
                typeof(weakSelf) __strong strongSelf = weakSelf;
                [strongSelf p_changeProgressSynchronised:^(NSProgress *progress) {
                    progress.totalUnitCount += response.expectedContentLength;
                    dispatch_group_leave(lengthRequestsGroup);
                }];
            }];
            [task resume];
        }
        dispatch_group_wait(lengthRequestsGroup, DISPATCH_TIME_FOREVER);
    }];
    

    As you can see all parts lengths need to be requested as a single NSOperation. The http requests here don't need to be performed in any particular order or even sequently, however the operation still needs to wait until all of them are done, so here is when dispatch_group comes handy.

    It's also worth mentioning that NSProgress is quite a complex object and it requires some minor synchronisation to avoid race condition. Also, since this implementation no longer can rely on built-in progress property of NSOperationQueue, we'll have to maintain our own instance of this object. With that in mind here is the property and its access methods implementation:

    @property(strong, readonly, nonatomic) NSProgress *progress;
    
    ...
    
    - (NSProgress *)progress {
        __block NSProgress *localProgress;
        dispatch_sync(_progressAcessQueue, ^{
            localProgress = _progress;
        });
        return localProgress;
    }
    
    - (void)p_changeProgressSynchronised:(void (^)(NSProgress *))progressChangeBlock {
        typeof(self) __weak weakSelf = self;
        dispatch_barrier_async(_progressAcessQueue, ^{
            if (!weakSelf) {
                return;
            }
            typeof(weakSelf) __strong strongSelf = weakSelf;
            progressChangeBlock(strongSelf->_progress);
        });
    }
    

    Where _progressAccessQueue is a concurrent dispatch queue:

    _progressAcessQueue = dispatch_queue_create("the.dreams.wind.queue.ProgressAcess", DISPATCH_QUEUE_CONCURRENT);
    

    Step 2

    Block-oriented API of NSURLSession is convenient but not very flexible. It can only report response when the request is completely finished. In order to get more granular response, we can get use of NSURLSessionDataDelegate protocol methods and set our own class as a delegate to the session instance:

    NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
    _urlSession = [NSURLSession sessionWithConfiguration:sessionConfiguration
                                                delegate:self
                                           delegateQueue:nil];
    

    In order to listen to the http requests progress inside of the delegate methods, we have to replace block-based methods with corresponding counterparts without them. I also set the timeout to 4 minutes, which is more reasonable for large chunks of data. Last but not least, the semaphore now needs to be used in multiple methods, so it has to turn into a property:

    @property(strong, nonatomic) dispatch_semaphore_t taskSemaphore;
    
    ...
    
    strongSelf.taskSemaphore = dispatch_semaphore_create(0);
    NSURLRequest *request = [[NSURLRequest alloc] initWithURL:url
                                                  cachePolicy:NSURLRequestUseProtocolCachePolicy
                                              timeoutInterval:kRequestTimeout];
    [[session dataTaskWithRequest:request] resume];
    

    And finally we can implement the delegate methods like this:

    - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
        if (error) {
            [self cancel];
            // 3.2 Failed completion
            _callback([_data copy], error);
        }
        dispatch_semaphore_signal(_taskSemaphore);
    }
    
    - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
        [_data appendData:data];
        [self p_changeProgressSynchronised:^(NSProgress *progress) {
            progress.completedUnitCount += data.length;
        }];
    }
    

    URLSession:task:didCompleteWithError: methods additionally checks for error scenarios, but it predominantly should just signal that the current request is finished via the semaphore. Another method accumulates received data and reports current progress.

    Step 3

    The last step is not really different from what we implemented for Per Chunk Progress implementation, but for sample data I decided to google for some big video-files this time:

    typeof(self) __weak weakSelf = self;
    TDWSerialDataTaskSequence *dataTaskSequence = [[TDWSerialDataTaskSequence alloc] initWithURLArray:@[
        [[NSURL alloc] initWithString:@"https://download.samplelib.com/mp4/sample-5s.mp4"],
    //    [[NSURL alloc] initWithString:@"https://error.url/sample-20s.mp4"], // uncomment to check error scenario
        [[NSURL alloc] initWithString:@"https://download.samplelib.com/mp4/sample-30s.mp4"],
        [[NSURL alloc] initWithString:@"https://download.samplelib.com/mp4/sample-20s.mp4"]
    ] callback:^(NSData * _Nonnull data, NSError * _Nullable error) {
        dispatch_async(dispatch_get_main_queue(), ^{
            if (!weakSelf) {
                return;
            }
            
            typeof(weakSelf) __strong strongSelf = weakSelf;
            if (error) {
                strongSelf->_dataLabel.text = error.localizedDescription;
            } else {
                strongSelf->_dataLabel.text = [NSString stringWithFormat:@"Data length loaded: %lu", data.length];
            }
        });
    }];
    _progressView.observedProgress = dataTaskSequence.progress;
    

    With all fancy stuff implemented this sample got a little too big to cover all peculiarities as an SO answer, so feel free to refer to this repo for the reference.