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



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 в ближайших проектах!
  • +26
  • 36.8k
  • 4
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 4

  • UFO just landed and posted this here
      –1
      Хм, интересно. Думал, что при самостоятельном убийстве приложения, система убивает демона, и от приложения совсем ничего не остается. Тогда непонятно кто вызывает делегатный метод. Надо будет попробовать, но вообще то, что не всегда срабатывает — не страшно. Ничего критического не произойдет, а пользователь захотел убить приложение — сам виноват :)
        0
        О том, что догрузка прекратится, если убить приложение самостоятельно, несколько раз упоминали в докладе «What’s New with Multitasking» на WWDC 2013 (5 минута 10 секунда и 50 минута 10 секунда):

        Also, users will be able to swipe up to remove applications, and so just as before in iOS 6, that will stop the application from running but now in iOS 7, it will also stop that application from running in the background.

        And we've made this consistent across a number of APIs, but you guys should be aware that that is what's that will be what's happening when the application is removed.


        If the user removes your application from app switcher, your application will no longer run until it's then relaunched by the user.

        So if your you know, you get feedback from users of saying, «Hey, I removed it and then I was expecting update.» This why.
        0
        Попробовал выстроить подобие очереди, ограничив HTTPMaximumConnectionsPerHost = 1, но ничего не вышло. Не подскажете направление, в котором поискать?

        Only users with full accounts can post comments. Log in, please.