[Из песочницы] Получение удаленных данных в iOS
Это авторский перевод главы 6 Retrieving remote data из книги iOS7 in Action. В отличие от книги, весь интерфейс сделан программно, соответственно убран текст, описывающий, как делать все это в storyboard. Для простоты выбрано единственное положение экрана Portrait и целевая платформа iPhone.
Мы создадим приложение с единственной Label на экране, в которой будет отображаться случайная шутка о Чаке Норрисе, загруженная через API сайта api.icndb.com/jokes/random в момент запуска приложения.
Рис.1 Наше приложение, показывающее шутку о Чаке Норрисе.
Получение данных с помощью NSURLSession
Теория по HTTP запросу
Напомним для примера, как происходит HTTP взаимодействие. Пусть мы печатаем в браузере URL: http://google.com/?q=Hello&safe=off
.
В нем можно выделить следующие составные части:
- http – протокол, который говорит браузеру следовать HTTP стандарту при запросе
- :// отделяет протокол от домена
- google.com – домен, откуда получаем данные
- / — путь запроса, который определяет положение определяемого нами ресурса
- ? используется для отделения пути от параметра
- q=Hello&safe=off – параметры. Каждый параметр состоит из пары ключ-значение. Ключ q имеет значение Hello, ключ safe имеет значение off
При HTTP запросе мы всегда указываем метод. Метод определяет, что серверу нужно сделать с посланной нами информацией.
Вкратце, браузер соединяется с google.com и делает GET запрос, соответствующий HTTP протоколу, на корневую директорию /, передавая в качестве параметра q=Hello&safe=off.
Когда браузер распарсит URL, то HTTP запрос будет представлен вот так:
GET /?q=Hello&safe=off HTTP/1.1
Host: google.com
Content-Length: 133
(…)
Запрос состоит из строк ASCII текста. В первой строке GET метод, за ним путь с параметрами, затем HTTP версия. Затем следует заголовок, содержащий детали такие, как запрашиваемый хост и длина запроса. Заголовок отделен от тела двумя пустыми строками. Для GET тело пустое.
Рис.2 демонстрирует диаграмму запроса. Сначала создается запрос, потом устанавливается соединение к удаленному серверу, и посылается запрос простым текстом. В зависимости от размера запроса и качества сети, запрос может длиться секунды, часы и даже дни.
Рис.2 Шаги в HTTP запросе:(a) обычное HTTP взаимодействие и (b) эквиваленты для каждого шага с точки зрения Objective-C реализации.
Создаем программно интерфейс нашего приложения
Создаем Empty Application в Xcode и в нем следующие файлы:
THSAppDelegate.h
#import <UIKit/UIKit.h>
@class THSViewController;
@interface THSAppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@property (strong, nonatomic) THSViewController *viewController;
@end
THSAppDelegate.m
#import "THSAppDelegate.h"
#import "THSViewController.h"
@implementation THSAppDelegate
- (BOOL) application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.viewController = [[THSViewController alloc] initWithNibName:nil bundle:nil];
self.window.rootViewController = self.viewController;
self.window.backgroundColor = [UIColor whiteColor];
[self.window makeKeyAndVisible];
return YES;
}
@end
THSViewController.h
#import <UIKit/UIKit.h>
@interface THSViewController : UIViewController
@property (nonatomic, strong) UILabel *jokeLabel;
@end
THSViewController.m
#import "THSViewController.h"
#import "THSViewController+Interface.h"
@implementation THSViewController
- (void)viewDidLoad
{
[super viewDidLoad];
[self addLabel];
}
@end
THSViewController+Interface.h
#import "THSViewController.h"
@interface THSViewController (Interface)
- (void)addLabel;
@end
THSViewController+Interface.m
#import "THSViewController+Interface.h"
@implementation THSViewController (Interface)
- (void)addLabel
{
CGFloat width = self.view.frame.size.width - 40.0f;
CGFloat y = self.view.frame.size.height / 2.0f - 200.0f;
CGRect labelFrame = CGRectMake(20.0f, y, width, 200.0f);
self.jokeLabel = [[UILabel alloc] initWithFrame:labelFrame];
self.jokeLabel.text = @"Quotation goes here and continues and continues until I am fed up to type.";
self.jokeLabel.lineBreakMode = NSLineBreakByWordWrapping;
self.jokeLabel.textAlignment = NSTextAlignmentCenter;
self.jokeLabel.numberOfLines = 0;
self.jokeLabel.font = [UIFont systemFontOfSize:16.0f];
[self.view addSubview:self.jokeLabel];
}
@end
Создание интерфейса вынесено в категорию, чтобы не смешивать основной функционал главы с кодом лэйблы и в дальнейшем кнопок.
Запускаем приложение и видим интерфейс:
Рис.3 Стартовый интерфейс приложения.
Cоздаем Cocoa класс THSHTTPCommunication
Все UI операции выполняются в главном потоке. Если заблокировать главный поток, то будут заблокированы все события по касанию, отрисовка графики, анимация, звуки, в конечном итоге приложение зависнет. Поэтому нельзя просто прервать приложение для ожидания запроса. Для решения этой проблемы есть две техники: создать новый поток выполнения для управления двумя одновременными операциями или сконфигурировать экземпляр класса как делегат и реализовать методы, определенные в протоколе делегата.
Большинство методов в Cocoa были первоначально спроектированы как делегат-паттерн, чтобы сделать потребляющие время операции асинхронными. Это значит, что главный поток будет продолжаться до тех пор, пока операция не выполнена, после чего определенный метод будет вызван.
С iOS 5 Objective-C поддерживает блоки.
Мы будем юзать делегаты в этом примере для более глубокого понимания сетевой работы. Но помним, что в iOS7 есть синтаксический сахар, который использует блоки для упрощения выполнения HTTP запросов.
Создаем класс THSHTTPCommunication.
THSHTTPCommunication.h:
#import <Foundation/Foundation.h>
@interface THSHTTPCommunication : NSObject
@end
THSHTTPCommunication.m:
#import "THSHTTPCommunication.h"
@interface THSHTTPCommunication ()
@property(nonatomic, copy) void(^successBlock)(NSData *);
@end
@implementation THSHTTPCommunication
@end
где successBlock содержит блок, который будет вызван, когда запрос завершится.Реализуем HTTP взаимодействие
Дальше, создаем метод в THSHTTPCommunication.m, ответственный за HTTP взаимодействие. Этот метод вытягивает шутки с публичного API, называемого icndb, и возвращает информацию асинхронно с помощью блоков.
THSHTTPCommunication.m:
@implementation THSHTTPCommunication
- (void)retrieveURL:(NSURL *)url successBlock:(void(^)(NSData *))successBlock
{
// сохраняем данный successBlock для вызова позже
self.successBlock = successBlock;
// создаем запрос, используя данный url
NSURLRequest *request = [[NSURLRequest alloc] initWithURL:url];
// создаем сессию, используя дефолтную конфигурацию и устанавливая наш экземпляр класса как делегат
NSURLSessionConfiguration *conf =
[NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:conf delegate:self delegateQueue:nil];
// подготавливаем загрузку
NSURLSessionDownloadTask *task = [session downloadTaskWithRequest:request];
// устанавливаем HTTP соединение
[task resume];
}
@end
Этот метод принимает два параметра: URL, откуда мы получаем содержимое (в нашем случае это icndb API URL для получения случайных шуток) и блок, который будет вызван сразу после завершения запроса. Сначала нужно сохранить данный блок для вызова позже, когда запрос завершиться. Следующий шаг – создать объект NSURLRequst для данного URL и использовать этот запрос для установления HTTP соединения. [task resume] не заблокирует исполнение. В этом методе компилятор будет показывать warning, потому что мы еще не сообщили, что THSHTTPCommunication класс соответствует NSURLSessionDownloadDelegate протоколу. Этим и займемся далее.Делегат сессии
Мы реализуем NSURLSessionDownloadDelegate протокол, чтобы отловить некоторые из сообщений, как например когда мы получаем новый запрос.
Сначала сообщим компилятору, что THSHTTPCommunication подчиняется этому протоколу.
THSHTTPCommunication.h:
@interface THSHTTPCommunication : NSObject<NSURLSessionDownloadDelegate>
@end
NSURLSessionDownloadDelegate протокол определяет набор методов, которые экземпляр класса NSURLConnection может выполнять в процессе HTTP соединения. Мы используем URLSession:downloadTask:didFinishDownloadingToURL:
Есть еще два метода, которые могут нам понадобиться для более сложных случаев, как отслеживание процесса загрузки и возможность возобновить запрос. Названия говорят сами за себя:
URLSession:downloadTask:didResumeAtOffset:expectedTotalBytes:
URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:
Но это не все. Вдобавок NSURLSession API предоставляет три протокола это:
NSURLSessionDelegate — этот протокол определяет методы-делегаты для обработки событий уровня сессий, как аннулирование сессии или полномочий.
NSURLSessionTaskDelegate — этот протокол определяет методы-делегаты для обработки событий соединения как редиректы, ошибки и пересылка данных.
NSURLSessionDataDelegate — этот протокол определяет методы-делегаты для обработки событий уровня задач, специфичных для данных и задач загрузки.
Получаем данные из ответа
Теперь реализуем метод, который позволит получит данные из ответа в THSHTTPCommunication.m. NSURLSession вызовет этот метод как только данные станут доступны и загрузка закончится.
THSHTTPCommunication.m:
@implementation THSHTTPCommunication
…
- (void) URLSession:(NSURLSession *)session
downloadTask:(NSURLSessionDownloadTask *)downloadTask
didFinishDownloadingToURL:(NSURL *)location
{
// получаем загруженные данные из локального хранилища
NSData *data = [NSData dataWithContentsOfURL:location];
// гарантируем, что вызов successBlock происходит в главном потоке
dispatch_async(dispatch_get_main_queue(), ^{
// вызываем сохраненный ранее блок как колбэк
self.successBlock(data);
});
}
@end
Этот код – последний шаг во взаимодействии. Мы получаем полный ответ и сразу вызываем блок, который сохранили ранее. Сначала получаем локально сохраненные данные, которые мы получили с сервера. Замечаем, что хранилище представлено экземпляром класса NSURL, но в этот раз URL – это путь к файлу, в котором содежатся данных ответа, а не удаленный URL.
Нам нужно быть уверенными, что вызов successBlock колбэка происходит из главного потока. Это является обычной практикой, потому что скорее всего метод, реализующий класс, делает специфичную для главного потока задачи, такие как UI действия.
В некоторых случаях, когда получаем информацию с удаленного сервера, запрос может пройти через несколько серверов, прежде чем достигнуть пункт назначения. Рис.4 Мы пытаемся получить картинку, расположенную на http://t.co/
, но первый ответ – это редирект на сервер, содержащий картинку. Нам нужен только последний ответ (сама картинка).
Рис.4 Получение картинки по сокращенному Twitter url генерирует редирект.
Несмотря на то, что мы можем контролировать редиректы реализовав NSURLSessionTaskDelegate, мы можем позволить NSURLSession справиться со всеми деталями, что является дефолтным поведением.
Делаем доступным только что созданный метод retrieveURL:successBlock: для главного контроллера. Открываетм THSHTTPCommunication.h и добавляем декларацию метода:
THSHTTPCommunication.h:
@interface THSHTTPCommunication : NSObject <NSURLSessionDownloadDelegate>
- (void)retrieveURL:(NSURL *)url successBlock:(void(^)(NSData *))successBlock;
@end
Понимание сериализации данных и взаимодействия с сторонними службами
Мы создали приложение и написали логику для получения данных с удаленного сервера. Теперь получим случайные шутки о Чаке Норрисе, используя API, предоставляемые icndb.com. Этот API как и все сервисы, обеспечивающие взаимодействие со сторонними службами, имеют нормализованный способ форматирования информации. Поэтому нужно трансформировать этот формат во что-то простое для использования и манипуляции. Другими словами, нужен способ для конвертирования форматированных данных в объекты Objective-C.Сериализация
Рис.5 иллюстрирует как работает процесс сериализации. Видим отправитель (icndb сервер) слева и получатель (клиент) справа. Сначала шутка генерируется, icndb сохраняет ее как бинарные данные (может быть сохранено в виде базы данных, памяти, файловой системы или любой другой вид хранилища). Когда происходит запрос из приложения, информация о шутке сериализуется и посылается нам (получателю). Приложение парсит информацию и конвертирует полученные данные в нативные Objective-C объекты.
Рис.5 Архитектура сериализации и десериализации сообщений.
Есть разные способы обмена информацией, но мы сосредоточимся на наиболее широко используемом формате сериализации: JavaScript Object Notation ( JSON ). JSON – стандарный способ представления различных типов структур данных в текстовом виде. JSON определяет маленький набор правил для представления строк, чисел и булевых значений. Вместе с XML это один из наиболее используемых методов сериализации сегодня. Посмотрим на пример JSON в действии:
{
"name": "Martin Conte Mac Donell",
"age": 29,
"username": "fz"
}
Этот код представляет словарь, заключенных в {} и состоящий из пар ключ/значение. Ключи не могут повторяться. В нашем примере name, age и username – ключи, Martin Conte Mac Donell, 29 и fz – значения.Код для получения шуток
Теперь мы знаем, что JSON формат определен и как процесс сериализации работает, вернемся к приложению. Реализуем код для получения шуток. В THSViewController.m добавляем внутреннюю переменную jokeID безымянную категорию и метод retrieveRandomJokes.
THSViewController.m:
#import "THSViewController.h"
#import "THSViewController+Interface.h"
#import "THSHTTPCommunication.h"
@interface THSViewController ()
{
NSNumber *jokeID;
}
@end
@implementation THSViewController
...
- (void)retrieveRandomJokes
{
THSHTTPCommunication *http = [[THSHTTPCommunication alloc] init];
NSURL *url = [NSURL URLWithString:@"http://api.icndb.com/jokes/random"];
// получаем шутки, используя экземпляр класса THSHTTPCommunication
[http retrieveURL:url successBlock:^(NSData *response)
{
NSError *error = nil;
// десериализуем полученную информацию
NSDictionary *data = [NSJSONSerialization JSONObjectWithData:response
options:0
error:&error];
if (!error)
{
NSDictionary *value = data[@"value"];
if (value && value[@"joke"])
{
jokeID = value[@"id"];
[self.jokeLabel setText:value[@"joke"]];
}
}
}];
}
@end
Мы определяем метод retrieveRandomJokes и видим, как сериализация происходит с перспективы кода. В этом методе мы используем созданный ранее класс THSHTTPCommunication для получения данных с icndb.com. Поэтому создаем сразу экземпляр класса THSHTTPCommunication и затем вызываем retrieveURL:successBlock:, ответственный за получение данных. Как только THSHTTPCommunication получает ответ от icndb.com, он вызывает код внутри блока, переданного в виде параметра. В этой точке у нас есть доступные данные, готовые к разбору.
Когда информация получена, ее нужно понять. Нужен путь для конвертирования только что загруженного текста во что-то, с чем можно легко манипулировать. Вам нужно выделить шутку и id из ответа. Процесс конвертирования сериализованной данных (JSON) в структуры данных называется десериализацией. К счастью, начиная с iOS 5 Cocoa framework включает класс для разбора JSON. Это NSJSONSerialization класс, и разбор данных ответа – это первое, что делаем в блоке.
Ответ от icndb API – это ассоциативный массив, представленный с JSON как
{
"type": "success",
"value":
{
"id": 201,
"joke": "Chuck Norris was what Willis was talkin’ about"
}
}
Видим, что ответ представляет собой ассоциативный массив и ключ “value” содержит другой ассоциативный массив. Как только NSJSONSerialization завершит десериализацию, ассоциативный массив JSON будет сконвертирован в Objective-C NSDictionaries, массивы в NSArray, числа в NSNumber, а строки в NSString. После этого получаем объект, который можно использовать в приложении.
Возвращаясь к retrieveRandomJokes:, после десериализации присваиваем ассоциативный массив из ключа “value” десериализованного ответа словарю NSDictionary. Наконец, полученный текст шутки делаем текстом лэйблы в нашем интерфейсе.
Осталось вызвать retrieveRandomJokes: метод, когда загрузилась вьюха.
THSViewController.m:
- (void)viewDidLoad
{
[super viewDidLoad];
[self addLabel];
[self retrieveRandomJokes];
}
На этом все, теперь запустим приложение и увидим новую шутку при каждом запуске.