Pull to refresh

Почему NSURLSession лучше, чем NSURLConnection

Development for iOS *Objective C *
Sandbox


iOS 7 официально вышла в сентябре, тогда Apple предоставила разработчикам новый способ работы с сетью — NSURLSession. Это достаточно фундаментальная вещь, потому в случае необходимости поддержки iOS 6 и ниже, распараллеливать код относительно версии системы будет крайне проблематично. Но тем не менее, время идет, и уже сейчас по разным данным от 75 до 85 процентов пользователей перешло на последнюю iOS, потому я бы советовал попробовать NSURLSession уже в следующем проекте.

По замыслу Apple, NSURLSession должна сменить NSURLConnection, и тут действительно возникает вопрос: «а зачем все это надо?» Потому сразу плюсы по сравнению с NSURLConnection:
  1. Загрузка и отправка данных в бэкграунде
  2. Возможность останавливать и продолжать загрузку
  3. Мы можем использовать блоки и делегаты одновременно, так, например, блоки используем для получения данных и обработки ошибок, а делегатный метод — для прохождения аутентификации
  4. У сессии есть специальный конфигурационный контейнер, в который можно уложить все нужные свойства для всех тасков(запросов) в сессии, а также, например, хэдеры для всех запросов в сессии
  5. Можно использовать приватное хранилище для куков, кэша и прочего
  6. Получаем более строгий и структурированный код, в отличие от набора беспорядочных NSURLConnection



Покажу, что новый способ совсем не страшный и что его действительно стоит использовать. Итак приступим, ключевым классом является NSURLSession, как ясно из названия, он создает некую сессию, для загрузки/выгрузки данных через HTTP. Существует три типа сессии: default — это то, что раньше делал NSURLConnection, ephemeral — в ней ничего не кэшируется и все хранится в оперативной памяти(напоминает приватный режим в браузере), download — результат представляется в виде файлов.

NSURLSessionConfiguration


Свойствами сессии управляет класс NSURLSessionConfiguration, в котором есть огромное множество параметров, помимо выбора типа сессии: возможность загрузки через мобильную сеть, куки, кэш, прокси, безопасность. Есть одно интересное свойство discretionary — оно позволяет отдать загрузку на усмотрение системы (когда будет wi-fi и много заряда батареи).

NSURLSession


Задав конфигурацию сессии, создаем саму сессию, принимая конфигурацию в конструкторе. Данные получаем привычными двумя способами: устанавливаем делегата или ловим данные в completion блоке (о них чуть позже).

NSURLTask


Является минимальной задачей, то что до это было NSURLConnection. Сам по себе класс абстрактный, но у него есть 3 подкласса: NSURLSessionDataTask, NSURLSessionUploadTask (подкласс первого) и NSURLSessionDownloadTask, впрочем, и у них нет собственного конструктора. Все они создаются самой сессией c completion блоком или без (вполне логично, что в первом случае делегат сессии не нужен). Выглядит все это несколько экзотично:
NSURLSessionDownloadTask *downloadTask = [ourSession downloadTaskWithRequest:simpleNSURLRequest];


Блоки и делегаты


Вообще сам процесс загрузки сильно напоминает работу с NSURLConnection, быстренько рассмотрим два пути работы с сессиями.

Через делегаты:
Сессии задаем делегата во время создания.
[NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];

После чего все делегатные методы (в том числе и тасков) вызываются в делегате.

Через блоки:
Достаточно лишь создавать таски с помощью
 -(NSURLSessionDownloadTask *)downloadTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURL *location, NSURLResponse *response, NSError *error))completionHandler

Опять же ничего нового, все это нам знакомо по NSURLConnection -sendAsynchronousRequest:queue:completionHandler:
В этом случае мы можем добавить делегатный метод для прохождения аутентификации при необходимости.

Примеры


Разобрались с общей схемой, отложим теорию, время посмотреть примеры!

Остановка/продолжение загрузки.

Вся схема достаточно сильно напоминает работу через NSURLConnection, но, в отличие от него, мы можем просто отменить любой download таск. Также при отмене будет вызван делегатный метод URLSession:task:didCompleteWithError:, так что там можно будет провести все необходимые манипуляции с UI. Причем можно не только отменить, но и просто остановить.
  [self.resumableTask cancelByProducingResumeData:^(NSData *resumeData) {
        partialDownload = resumeData;
        self.resumableTask = nil;
    }];

    //отдаем эти данные новому таску и запускаем дальше
    if(partialDownload) {
        self.resumableTask = [inProcessSession downloadTaskWithResumeData:partialDownload];
    } else {
        ...
    }
    [self.resumableTask resume];

Останавливая таск можно сохранить все полученные данные, а уже после отдать его новому download таску.

Загрузка в файл

Еще одна вещь, которую хотелось бы разобрать, это download таски. Напомню, они позволяют загруженное сразу же укладывать в файл.

через блок:
NSURLSessionConfiguration* sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfig];
NSURL* downloadTaskURL = [NSURL URLWithString:@"http://upload.wikimedia.org/wikipedia/commons/1/14/Proton_Zvezda_crop.jpg"];
[[session downloadTaskWithURL: downloadTaskURL
         completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
           NSFileManager *fileManager = [NSFileManager defaultManager];
           
           NSArray *urls = [fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask];
           NSURL *documentsDirectory = [urls objectAtIndex:0];
           
           NSURL *originalUrl = [NSURL URLWithString:[downloadTaskURL lastPathComponent]];
           NSURL *destinationUrl = [documentsDirectory URLByAppendingPathComponent:[originalUrl lastPathComponent]];
           NSError *fileManagerError;
           
           [fileManager removeItemAtURL:destinationUrl error:NULL];
           //ключевая  строчка!
           [fileManager copyItemAtURL:location toURL:destinationUrl error:&fileManagerError];
           
         }] resume];


через делегатный метод:
NSURLSessionConfiguration* sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfig delegate:self delegateQueue:nil];
NSURL* downloadTaskURL = [NSURL URLWithString:@"http://upload.wikimedia.org/wikipedia/commons/1/14/Proton_Zvezda_crop.jpg"];
[[session downloadTaskWithURL:downloadTaskURL] resume];

//теперь ловим окончание загрузки
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location {
  //аналогично обрабатываем
}


Надо сказать, что мы получаем в переменную location адрес на нашем устройстве:
file:///private/var/mobile/Applications/{appUUID}/tmp/CFNetworkDownload_fileID.tmp, после чего сохраняем файл в более безопасное место, в примере file:///var/mobile/Applications/{appUUID}/Documents/Proton_Zvezda_crop.jpg

Посылаем конечное число запросов за раз

Иногда у нас возникает необходимость ограничить число одновременных запросов, например — 5. В этом случае нам надо просто указать максималное количество подключений:
sessionConfig.HTTPMaximumConnectionsPerHost = 5;

Далее будет пример, чтобы попробовать, лучше забирать файлы побольше, советую также симулировать загрузку через 3g (Settings -> Developer -> Network link conditioner -> Choose a profile -> 3g -> Enable)

- (void) methodForNSURLSession{
  NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
  _tasksArray = [[NSMutableArray alloc] init];
  sessionConfig.HTTPMaximumConnectionsPerHost = 5;
  sessionConfig.timeoutIntervalForResource = 0;
  sessionConfig.timeoutIntervalForRequest = 0;
  NSURLSession* session = [NSURLSession sessionWithConfiguration:sessionConfig delegate:self delegateQueue:nil];

  // download tasks
  //  [self createDataTasksWithSession:session];

  // data tasks
  [self createDownloadTasksWithSession:session];
}

- (void) createDownloadTasksWithSession:(NSURLSession *)session{
  for (int i = 0; i < 10; i++) {
    NSURLSessionDownloadTask *sessionDownloadTask = [session downloadTaskWithURL: [NSURL URLWithString:@"https://discussions.apple.com/servlet/JiveServlet/showImage/2-20930244-204399/iPhone%2B5%2BProblem2.jpg"]];
    [_tasksArray addObject:sessionDownloadTask];
    [sessionDownloadTask addObserver:self forKeyPath:@"countOfBytesReceived" options:NSKeyValueObservingOptionOld context:nil];
    [sessionDownloadTask resume];
  }
}

- (void) createDataTasksWithSession:(NSURLSession *)session{
  for (int i = 0; i < 10; i++) {
    NSURLSessionDataTask *sessionDataTask = [session dataTaskWithURL: [NSURL URLWithString:@"https://discussions.apple.com/servlet/JiveServlet/showImage/2-20930244-204399/iPhone%2B5%2BProblem2.jpg"]];
    [_tasksArray addObject:sessionDataTask];
    [sessionDataTask addObserver:self forKeyPath:@"countOfBytesReceived" options:NSKeyValueObservingOptionOld context:nil];
    [sessionDataTask resume];
  }
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{
  if([[change objectForKey:@"old"] integerValue] == 0){
    NSLog(@"task %d: started", [_tasksArray indexOfObject: object]);
  }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error{
  NSLog(@"task %d: finished!", [_tasksArray indexOfObject:task]);
}


Пример достаточно простой и прозрачный, но заострю ваше внимание на одном моменте:
sessionConfig.timeoutIntervalForResource = 0;
sessionConfig.timeoutIntervalForRequest = 0;

Согласно документации:
timeoutIntervalForRequest — время, которое отводится на загрузку каждого таска
timeoutIntervalForResource — время, которое отводится на загрузку всех запросов
и тут у нас возникает проблема, дело в том, что в момент, когда мы начинаем таск ([task resume]) счетчик timeoutIntervalForRequest начал тикать, и никого не волнует, что тасков у нас 100, а одновременно работать может только 5. По этой причине получается, что значения этих параметров должно быть одинаковым, ведь таски, которые будут вызваны последними, могут закончиться так и не получив не бита данных.

Потому нам ничего не остается кроме как установить обе переменные в одинаковые значения, также можно выставить в 0, в этом случае счетчик будет идти до бесконечности.

Да, конечно можно изобрести велосипед и самостоятельно следить за количеством тасков, но хочется ведь вариант «из коробки». Тут, на мой взгляд, инженеры Apple не до конца додумали.

Отслеживание загрузки

У download тасков есть специальный делегатный метод:
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
  double progress = (double)totalBytesWritten / (double)totalBytesExpectedToWrite;
  NSLog(@"download: %@ progress: %f", downloadTask, progress);
  dispatch_async(dispatch_get_main_queue(), ^{
    self.progressView.progress = progress;
  });
}

Для остальных же тасков можно воспользоваться KVO как в предыдущем примере.

Загрузка в бэкграунде

Ну и в конце разберемся с примером загрузки в бэкграунде, пример повторяет демо из wwdc'13 705. Лично меня демка потрясла. Запускаем загрузку картинки, выходим из приложения, возвращаемся — картинка загружена и уже уложена, причем это видно даже в мультитаск менюшке (та, что по двойному нажатию на домашнюю кнопку). Но более того, если мы в момент загрузки уроним приложение — загрузка закончится и все вернется будто ничего не произошло! Да еще и после загрузки обновляется наш UI прям в бэкграунде, и меняется снапшот в многозадачном меню. Единственный случай, когда загрузка не заканчивается — это когда пользователь сам убивает приложение, но тут уж ничего не поделаешь, хозяин — барин.

Почему же такая «магия» работает? Все дело в том, что когда приложение запускает бэкграунд процесс — система создает демона, который занимается передачей данных в приложение. Оно и логично, нам нужно что-то, что будет жить независимо от приложения. По этой причине нам не страшны ни остановка, ни крэш приложения. После окончания загрузки, демон «будит» приложение, после чего мы можем восстановить сессию и получить все данные. Создание новой сессии со старым идентификатором «подключит» нас к существующей бэкграунд сессии.

Теперь разберем основные моменты, сам тестовый проект можно забрать здесь.

Сначала в синглтоновом стиле создаем сессию:
- (NSURLSession *)backgroundSession{
  static NSURLSession *session = nil;
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    // для каждой бэкграунд сессии надо создавать свой уникальный ключ, к счастью не для таска
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfiguration:@"com.dev.BackgroundDownloadTest.BackgroundSession"];
    [config setAllowsCellularAccess:YES];
    session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
  });
  return session;
}

Начинаем загрузку (тут вопросов возникать не должно):
 self.downloadTask = [[self backgroundSession] downloadTaskWithURL:[NSURL URLWithString:@"https://discussions.apple.com/servlet/JiveServlet/showImage/2-20930244-204399/iPhone%2B5%2BProblem2.jpg"]];
  [self.downloadTask resume];

В делегатном методе для бэкграунд тасков сохраняем картинку и показываем ее:
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
  // save image
  // было выше
  //...
  // set image
  if (success) {
    dispatch_async(dispatch_get_main_queue(), ^{
      self.imageView.image = [UIImage imageWithContentsOfFile:[destinationPath path]];
      [self.progressView setHidden:YES];
    });
  }
}


В делегатном методе для окончания уже всех тасков отлавливаем окончание загрузки (в нашем случае будут вызываться и этот и предыдущий методы)
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
  if (error) {
    NSLog(@"error: %@ - %@", task, error);
  } else {
    NSLog(@"success: %@", task);
  }
  self.downloadTask = nil;

  //данный метод проверяет, что все таски закончены
  [self callCompletionHandlerIfFinished];
}


Теперь переместимся в AppDelegate.m
Нам надо ловить сообщения от системы, когда загрузка закончена:
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler
{
  //при помощи уведомления будем видеть, когда загрузка закончена
  UILocalNotification* locNot = [[UILocalNotification alloc] init];
  locNot.fireDate = [NSDate dateWithTimeIntervalSinceNow:1];
  locNot.alertBody = [NSString stringWithFormat:@"still alive!"];
  locNot.timeZone = [NSTimeZone defaultTimeZone];
  [[UIApplication sharedApplication] scheduleLocalNotification:locNot];
  
  //среди аргументов висит загадочный хендлер - его надо вызвать, чтобы сообщить системе о том,
  //что мы обновили UI и можно делать новый снапшот для многозадачного меню.
  //Потому сохраним его до лучших времен
  self.backgroundSessionCompletionHandler = completionHandler;
}


Возвращаемся в основной контроллер.
Восстановим сессию, если это необходимо:
- (void)viewDidLoad
{
  [super viewDidLoad];
  [self backgroundSession];
}


Метод, который вызывается в самом конце:
- (void)callCompletionHandlerIfFinished
{
  NSLog(@"call completion handler");
  [[self backgroundSession] getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
    NSUInteger count = [dataTasks count] + [uploadTasks count] + [downloadTasks count];
    if (count == 0) {
    // все таски закончены
    // теперь можем вызвать наш припрятанный хэндлер
    // и отчитаться системе об обновлении UI
      NSLog(@"all tasks ended");
      AppDelegate *appDelegate = [[UIApplication sharedApplication] delegate];
      if (appDelegate.backgroundSessionCompletionHandler == nil) return;
      void (^comletionHandler)() = appDelegate.backgroundSessionCompletionHandler;
      appDelegate.backgroundSessionCompletionHandler = nil;
      comletionHandler();
    }
  }];
}


Добавлю, что в случае, если мы не вызываем этот хэндлер, мы получим в лог предупреждение:
Warning: Application delegate received call to - application:handleEventsForBackgroundURLSession:completionHandler: but the completion handler was never called.


Также, если мы откроем многозадачное меню, мы не увидим нашего обновленного интерфейса. Собственно, этим примером демонстрируется одна из сторон многозадачного «UI», о котором нам говорили Apple.

На этом все, надеюсь, данная статья подвигнет использовать NSURLSession в ближайших проектах!
Tags:
Hubs:
Total votes 30: ↑28 and ↓2 +26
Views 39K
Comments 4
Comments Comments 4