[Из песочницы] Используем RestKit 0.22.x для просмотра героев Marvel
Веб-сервисы, в частности использующие REST-архитектуру, уже плотно вошли в нашу жизнь. Разрабатывая клиентское приложение под iOS, часто так или иначе приходится загружать данные с сервера и хранить/отображать их локально. При этом хочется делать это легко и непринужденно, не прибегая к изобретению собственных «велосипедов».Последняя версия известного Objective-C фреймворка RestKit для iOS и OSX значительно упрощает работу с RESTful API. Несомненно, одной из его самых ценных фич является возможность автоматического сохранения объектов в локальную БД, используя CoreData. Давайте вместе проделаем путь от получения данных от сервера до сохранения и отображения их на нашем iOS-устройстве. А чтобы нам не было скучно, в качестве примера будем работать с API всемирно известной компании по производству комиксов Marvel.
Статья представляет из себя некое подобие туториала. Предполагается, что читатель уже знаком с базовыми концепциями разработки на языке Objective-C, использованием iOS SDK, Core Data и такого понятия как блоки.

1. Получаем ключи Marvel и формулируем задачу
Для начала давайте зарегистрируемся как разработчик на сайте Marvel.После тривиальной регистрации переходим на вкладку Account и копируем наши открытый и закрытый ключи.
После этого перейдем на вкладку Interactive Documentation и посмотрим, какие данные нам любезно предоставляют создатели API. У нас есть возможность работать с базой героев, комиксов, создателей, событий и многого другого. Нам же для ознакомления достаточно будет «пощупать» что-то одно, поэтому будущее приложение будет просто загружать список персонажей, сохранять его, а также отображать описание наиболее популярных.2. Начинаем работу
Создадим новый проект в XCode. В качестве устройства выберем iPhone и не забудем оставить галочку возле поля «use Core Data» в окне мастера создания проектов.Теперь вернемся на портал и рассмотрим структуру объекта Character:
Character object
Character {
id (int, optional): The unique ID of the character resource.,
name (string, optional): The name of the character.,
description (string, optional): A short bio or description of the character.,
modified (Date, optional): The date the resource was most recently modified.,
resourceURI (string, optional): The canonical URL identifier for this resource.,
urls (Array[Url], optional): A set of public web site URLs for the resource.,
thumbnail (Image, optional): The representative image for this character.,
comics (ComicList, optional): A resource list containing comics which feature this character.,
stories (StoryList, optional): A resource list of stories in which this character appears.,
events (EventList, optional): A resource list of events in which this character appears.,
series (SeriesList, optional): A resource list of series in which this character appears.
}
Что из этого нам может понадобиться? Пожалуй, ограничимся идентификатором, именем, картинкой и описанием. Давайте перейдем к нашему *.xcdatamodeld файлу в XCode и создадим сущность Character, которая логически будет соответствовать (хоть и частично) нашему удаленному объекту.
Я специально создал два идентификатора: первый, charID, будет служить для хранения «родного Marvel«овского» id на будущее, второй же, innerID, будет необходим для локального использования. Атрибуты charDescription и name соотвествуют удаленным параметрам description и name соответственно.Обратите внимание, что я также создал два атрибута thumbnailImageData и thumbnailURLString, хотя они не соответствуют ни одному параметру оригинальной структуры. Это вызвано тем, что в JSON-ответе thumbnail типа Image и в реальности соответствует словарю. Вот пример объекта thumbnail из реального ответа:
«thumbnail»: { «path»: «http://i.annihil.us/u/prod/marvel/i/mg/8/c0/4ce5a0e31f109», «extension»: «jpg» } В дальнейшем будет показано, как мы будем работать с этим.Теперь для правильной работы с сущностями Core Data необходимо также создать Objective-C класс, который будет ее представлять. Создадим класс Character, который будет наследоавться от NSManagedObject. Вот его объявление:
@interface Character: NSManagedObject { NSDictionary *_thumbnailDictionary; } @property (nonatomic, retain) NSString *name; @property (nonatomic, retain) NSNumber *charID; @property (nonatomic, retain) NSNumber *innerID; @property (nonatomic, retain) NSString *charDescription; @property (nonatomic, retain) NSData *thumbnailImageData; @property (nonatomic, retain) NSString *thumbnailURLString; @property NSDictionary *thumbnailDictionary;
// Получает число всех героев из базы + (NSInteger)allCharsCountWithContext:(NSManagedObjectContext *)managedObjectContext; // Возвращает героя по его innerID. + (Character *)charWithManagedObjectContext:(NSManagedObjectContext *)context andInnerID:(NSInteger)charInnerID; @end Здесь, помимо очевидных соотвествий, появилось свойство thumbnailDictionary, которое я добавил для более удобной работы с объектом thumbnail, о котором я писал немного выше. Также я добавил два вспомогательных метода класса, чтобы не создавать в проекте дополнительных классов. 3. Модель для работы с RestKit Подключим к нашему проекту RestKit (далее — RK). Как это сделать, подробно расписано здесь (или здесь, если Вы — любитель CocoaPods).Следующим шагом станет создание класса-обертки GDMarvelRKObjectManager (наследник NSObject), который будет работать с RK, в частности с такими классами, как RKObjectManager и RKManagedObjectStore. Этот класс можно и не создавать, однако мы пойдем на это, чтобы немного разгрузить код в нашем будущем главном вью-контроллере.
Немного о классах RK. RKManagedObjectStore инкапсулирует всю работу с Core Data, так что в дальнейшем не будет необходимости работать с NSManagedObjectContext или NSManagedObjectModel напрямую. RKObjectManager предоставляет централизованный интерфейс для отправки запросов и получения ответов, используя маппинг (соответствие) объектов. Например, нужные значения, полученные в JSON-ответе, при успешном маппинге будут автоматически присваиваться всем свойствам нашего объекта. Не этого ли мы так хотели в начале статьи? Не забудьте включить заголовок RK #import
@implementation GDMarvelRKObjectManager { RKObjectManager *objectManager; RKManagedObjectStore *managedObjectStore; } Давайте рассмотрим, что нам необходимо настроить, чтобы все работало, как надо.Для начала в — (id)init методе добавим инициализацию нужных объектов RK: // Инициализация AFNetworking HTTPClient NSURL *baseURL = [NSURL URLWithString:@«http://gateway.marvel.com/»]; AFHTTPClient *client = [[AFHTTPClient alloc] initWithBaseURL: baseURL]; //Инициализация RKObjectManager objectManager = [[RKObjectManager alloc] initWithHTTPClient: client]; Теперь наши запросы будут отправляться. Что насчет работы с Core Data? Давайте создадим метод, который бы конфигурировал объект типа RKManagedObjectStore. — (void)configureWithManagedObjectModel:(NSManagedObjectModel *)managedObjectModel { if (! managedObjectModel) return; managedObjectStore = [[RKManagedObjectStore alloc] initWithManagedObjectModel: managedObjectModel]; NSError *error; if (! RKEnsureDirectoryExistsAtPath (RKApplicationDataDirectory (), &error)) RKLogError (@«Failed to create Application Data Directory at path '%@': %@», RKApplicationDataDirectory (), error); NSString *path = [RKApplicationDataDirectory () stringByAppendingPathComponent:@«RKMarvel.sqlite»]; if (![managedObjectStore addSQLitePersistentStoreAtPath: path fromSeedDatabaseAtPath: nil withConfiguration: nil options: nil error:&error]) RKLogError (@«Failed adding persistent store at path '%@': %@», path, error); [managedObjectStore createManagedObjectContexts]; objectManager.managedObjectStore = managedObjectStore; } Последняя строка очень важна. Она связывает между собой два наших главных RK-объекта: objectManager и managedObjectStore.Итак, наша дальнейшая задача — создать в нашем классе GDMarvelRKObjectManager интерфейс для двух главных действий: добавление маппинга (соответствия) между сущностью Core Data и удаленным объектом, а также получение этих объектов от удаленного сервера.Первая задача реализуется в следующем методе:
— (void)addMappingForEntityForName:(NSString *)entityName andAttributeMappingsFromDictionary:(NSDictionary *)attributeMappings andIdentificationAttributes:(NSArray *)ids andPathPattern:(NSString *)pathPattern { if (! managedObjectStore) return; RKEntityMapping *objectMapping = [RKEntityMapping mappingForEntityForName: entityName inManagedObjectStore: managedObjectStore]; // Указываем, какие атрибуты должны мапиться. [objectMapping addAttributeMappingsFromDictionary: attributeMappings]; // Указываем, какие атрибуты являются идентификаторами. Важно для того, чтобы не было дубликатов в локальной базе. objectMapping.identificationAttributes = ids; // Создаем дескриптор ответа, ориентируясь на формат ответов нашего сервера и добавляем его в менеджер. RKResponseDescriptor *characterResponseDescriptor = [RKResponseDescriptor responseDescriptorWithMapping: objectMapping method: RKRequestMethodGET pathPattern:[NSString stringWithFormat:@»%@%@», MARVEL_API_PATH_PATTERN, pathPattern] keyPath:@«data.results» statusCodes:[NSIndexSet indexSetWithIndex:200]]; [objectManager addResponseDescriptor: characterResponseDescriptor]; } Тут нас интересуют несколько параметров у метода responseDescriptorWithMapping:… Во-первых — параметр pathPattern. Получается путем конкатенации макроса MARVEL_API_PATH_PATTERN (со значением @«v1/public/») и входного параметра pathPattern, который в нашем примере будет равен @«characters». Если же мы захотим получить не список персонажей, а, допустим, список комиксов, то передавать мы будем строку @«comics», которая уже в теле метода вновь соединится с @«v1/public/».Второе неочевидное значение — это параметр @«data.results» для параметра keyPath. Откуда оно взялось? Все очень просто: Marvel оборачивают все свои ответы в однотипную обертку, и все станет на свои места, когда мы посмотрим на ее структуру:
Characters wrapper { «code»: «int», «status»: «string», «copyright»: «string», «attributionText»: «string», «attributionHTML»: «string», «data»: { «offset»: «int», «limit»: «int», «total»: «int», «count»: «int», «results»: [ { «id»: «int», «name»: «string», «description»: «string», «modified»: «Date», «resourceURI»: «string», «urls»: [ { «type»: «string», «url»: «string» } ], «thumbnail»: { «path»: «string», «extension»: «string» }, «comics»: { «available»: «int», «returned»: «int», «collectionURI»: «string», «items»: [ { «resourceURI»: «string», «name»: «string» } ] }, «stories»: { «available»: «int», «returned»: «int», «collectionURI»: «string», «items»: [ { «resourceURI»: «string», «name»: «string», «type»: «string» } ] }, «events»: { «available»: «int», «returned»: «int», «collectionURI»: «string», «items»: [ { «resourceURI»: «string», «name»: «string» } ] }, «series»: { «available»: «int», «returned»: «int», «collectionURI»: «string», «items»: [ { «resourceURI»: «string», «name»: «string» } ] } } ] }, «etag»: «string» } Теперь понятно, что прежде чем достучаться до собственно списка героев, RK придется пройтись по словарям на несколько уровней вниз, чтобы добраться до нужной структуры. Значение @«data.results» как раз указывает тот путь, по которому надо «спуститься».Вторым методом нашего класса для работы с внутренним объектом RK будет getMarvelObjectsAtPath, который по сути проксирует обращение к getObjectsAtPath объекта типа RKObjectManager. Название у метода «говорящее» — вы ждете от него загрузки удаленных объектов. Так как Marvel требуют, чтобы с каждым запросом им отправлялся hash, timestamp и открытый ключ, удобно инкапсулировать генерацию этих параметров в наш getMarvelObjectsAtPath. Вот он:
— (void)getMarvelObjectsAtPath:(NSString *)path
parameters:(NSDictionary *)params
success:(void (^)(RKObjectRequestOperation *operation, RKMappingResult *mappingResult))success
failure:(void (^)(RKObjectRequestOperation *operation, NSError *error))failure {
// Подготовка нужных параметров
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setDateFormat:@«yyyyMMddHHmmss»];
NSString *timeStampString = [formatter stringFromDate:[NSDate date]];
NSString *hash = [[[NSString stringWithFormat:@»%@%@%@», timeStampString, MARVEL_PRIVATE_KEY, MARVEL_PUBLIC_KEY] MD5String] lowercaseString];
NSMutableDictionary *queryParams = [NSMutableDictionary dictionaryWithDictionary:@{@«apikey» : MARVEL_PUBLIC_KEY,
@«ts» : timeStampString,
@«hash» : hash}];
if (params)
[queryParams addEntriesFromDictionary: params];
// Непосредственный вызов метода у объекта objectManager с вновь собранными параметрами
[objectManager getObjectsAtPath:[NSString stringWithFormat:@»%@%@», MARVEL_API_PATH_PATTERN, path]
parameters: queryParams
success: success
failure: failure];
}
Обратите внимание, что в коде используется метод из нестандартной категории над NSString — MD5String. Как сгенерировать MD5-троку от строки, поищите в интернете.У нашего класса еще будет простой метод — (NSManagedObjectContext *)managedObjectContext, который будет возвращать главный контекст managedObjectStore. Также этот класс будет синглтоном (Singleton) с методом + (GDMarvelRKObjectManager *)manager для доступа к экземпляру.4. Главный ViewController
Для начала создадим базовый вью-контроллер GDBaseViewController, в котором мы просто встроим поддержку анимации ожидания ответа от сервера с единственным новым методом — (void)animateActivityIndicator:(BOOL)animate. В методе viewDidLoad создадим этот индикатор типа UIActivityIndicatorView, присвоим полученное значение переменной экземпляра UIActivityIndicatorView *activityIndicator и добавим его на self.view.В самом методе включения/выключения анимации будет следующий код: animateActivityIndicator: code
— (void)animateActivityIndicator:(BOOL)animate {
activityIndicator.hidden = ! animate;
if (animate) {
[self.view bringSubviewToFront: activityIndicator];
[activityIndicator startAnimating];
}
else
[activityIndicator stopAnimating];
}
Теперь, когда мы будем вызывать этот метод со значением YES для единственного параметра, наш вью-контроллер будет выглядеть вот так:
Далее создадим вью-контроллер GDMainViewController унаследованный от этого класса. Вот его объявление:
@interface GDMainViewController: GDBaseViewController
— (void)loadCharacters { numberOfCharacters = [Character allCharsCountWithContext:[[GDMarvelRKObjectManager manager] managedObjectContext]]; if (noRequestsMade && numberOfCharacters > 0) { noRequestsMade = NO; return; } [self animateActivityIndicator: YES]; noRequestsMade = NO; [[GDMarvelRKObjectManager manager] getMarvelObjectsAtPath: MARVEL_API_CHARACTERS_PATH_PATTERN parameters:@{@«offset» : @(numberOfCharacters)} success:^(RKObjectRequestOperation *operation, RKMappingResult *mappingResult) { [self animateActivityIndicator: NO]; NSInteger newInnerID = numberOfCharacters; for (Character *curCharacter in mappingResult.array) { if ([curCharacter isKindOfClass:[Character class]]) { curCharacter.innerID = @(newInnerID); newInnerID++; //Сохраняем каждого персонажа по одному (а не всех вместе после цикла), чтобы предотвратить потери, если программа аварийно завершится в середине цикла [self saveToStore]; } } numberOfCharacters = newInnerID; [table reloadData]; bottomPullView.hidden = NO; [bottomPullView finishedLoading]; } failure:^(RKObjectRequestOperation *operation, NSError *error) { [bottomPullView finishedLoading]; [[[UIAlertView alloc] initWithTitle:@«Marvel API Error» message: operation.error.localizedDescription delegate: self cancelButtonTitle:@«Cancel» otherButtonTitles:@«Retry», nil] show]; }]; } Сначала мы получаем общее количество персонажей из локальной базы, это значение будет соответствовать количеству ячеек в главной таблице. При первом запуске приложения оно, естественно, будет равняться нулю. Это же значение мы будем использовать в качестве передаваемого параметра offset при обращении к серверу. Таким образом на каждый следующий запрос сервер Marvel будет возвращать только новые объекты героев (по умолчанию герои возвращаются пачками по 20 штук в каждой).Далее мы производим тот самый главный запрос, используя наш метод-обертку getMarvelObjectsAtPath: У этого метода два важных для нас сейчас параметра — это success: и failure:, которые являются блоками, описывающими поведение при успешном и не успешном результатах выполнения запроса соответственно. Итак, при успешном получении массива персонажей, мы генерируем для каждого из них innerID, сохраняем их в локальную базу и изменяем значение общего количества героев. После чего обновляем отображение нашей таблицы. Самая главная магия здесь заключается в том, что на этом этапе полученные объекты уже автоматически сохранились в нашем CoreData-хранилище — RK сделал это за нас. (Стоит отметить, что это касается только тех полей/свойств объекта, для которого заданы маппинг-соответсвия. Так, в коде выше зменение параметра innerID приходится соханять отдельно, вызвав [self saveToStore]).В случае возникновении какой-то ошибки мы просто выводим ее пользователю и не обновляем таблицу.В коде используется метод сохранения в хранилище:
— (void)saveToStore {
NSError *saveError;
if (![[[GDMarvelRKObjectManager manager] managedObjectContext] saveToPersistentStore:&saveError])
XLog (@»%@», [saveError localizedDescription]);
}
Также вы заметите обращение к переменной экземпляра bottomPullView. Эта переменная хранит объект типа AllAroundPullView (cтянуть с GitHub) — полезный контрол, помогающий реализовать поведение Pull-To-Resfresh со всех сторон вашего UIScrollView. Мы будем подгружать каждую очередную порцию наших персонажей, дойдя до нижнего края таблицы и потянув ее вверх.Ранее в — (void)viewDidLoad этот контрол был инициализирован и использован следующим образом:
bottomPullView = [[AllAroundPullView alloc] initWithScrollView: table position: AllAroundPullViewPositionBottom action:^(AllAroundPullView *view){
[self loadCharacters];
}];
bottomPullView.hidden = YES;
[table addSubview: bottomPullView];
Как видите, в теле блока, передаваемого в качестве параметра action: мы поместили все тот же метод подгрузки новых героев loadCharacters.Что ж, запустим приложение в эмуляторе и дождемся первого успешного ответа. Если все прошло правильно, и логгер RK вывел что-то наподобие I restkit.network: RKObjectRequestOperation.m:220 GET 'http://your-url.here' (200 OK / 20 objects), значит все хорошо, и можно проверить, сохранились ли наши объекты в базу.Для этого зайдем в папку эмулятора, найдем там наше приложение и папку Documents. Там должна находиться база RKMarvel.sqlite (именно такое имя мы указали в качестве параметра при вызове метода addSQLitePersistentStoreAtPath: ранее). Откроем эту базу в SQLite-редакторе и удостоверимся в том, что наши персонажи сохранены:
Ура! У некоторых героев даже есть небольшое описание. Самое время перейти к отображению всего этого «добра».
5. Сохранение картинок и отображение.
Я знаю, что нетерпеливый читатель уже давно хочет посмотреть на изображения любимых персонажей. Для этого нам необходимо настроить внешний вид нашей таблицы. Не будем вдаваться в технические подробности создания и настройки объектов типа UITableView (автор предполагает, что это читателю уже известно), а сразу перейдем к методу делегата таблицы, который создает ячейки: tableView: cellForRowAtIndexPath: code
— (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSInteger row = indexPath.row;
NSString *reusableIdentifier = [NSString stringWithFormat:@»%d», row % 2];
UITableViewCell *cell = [table dequeueReusableCellWithIdentifier: reusableIdentifier];
if (! cell) {
cell = [[UITableViewCell alloc] initWithStyle: UITableViewCellStyleDefault reuseIdentifier: reusableIdentifier];
cell.autoresizingMask = UIViewAutoresizingFlexibleWidth;
}
[[cell.contentView subviews] makeObjectsPerformSelector:@selector (removeFromSuperview)];
if (numberOfCharacters > row) {
Character *curCharacter = [Character charWithManagedObjectContext:
[[GDMarvelRKObjectManager manager] managedObjectContext]
andInnerID: row];
if (curCharacter) {
BOOL charHasDescription = ![curCharacter.charDescription isEqualToString:@»];
UILabel *label = [[UILabel alloc] initWithFrame: CGRectMake (70, 0, CGRectGetWidth (cell.contentView.frame) — 70 — (charHasDescription? 60: 0), 60)];
label.backgroundColor = [UIColor clearColor];
label.text = curCharacter.name;
label.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[cell.contentView addSubview: label];
GDCellThumbnailView *thumbnail = [GDCellThumbnailView thumbnail];
if (curCharacter.thumbnailImageData)
[thumbnail setImage:[UIImage imageWithData: curCharacter.thumbnailImageData]];
else
[self loadThumbnail: thumbnail fromURLString: curCharacter.thumbnailURLString forCharacter: curCharacter];
[cell.contentView addSubview: thumbnail];
cell.accessoryType = charHasDescription? UITableViewCellAccessoryDetailButton: UITableViewCellSelectionStyleNone;
cell.selectionStyle = charHasDescription? UITableViewCellSelectionStyleGray: UITableViewCellSelectionStyleNone;
}
}
return cell;
}
После создания очередной ячейки мы достаем нужного героя из базы и отображаем его имя, также мы проверяем, присутствует ли развернутая информация о нем, и помещаем на ячейку кнопку, по нажатию на которую эту информацию потом отобразим. Ну и самое главное — изображение персонажа. Я создал для этого специальный класс GDCellThumbnailView, экземпляры которого я и помещаю на ячейку. Он не делает ничего особенного, просто у него есть возможность показывать нам «крутящийся цветочек» ожидания, пока thumbnail не загрузился.При пустой реализации метода loadThumbnail: fromURLString: forCharacter: наш главный вью-контроллер теперь будет выглядеть так: 
Давайте реализуем метод загрузки картинки героя. Так как RK уже включает в себя фреймворк AFNetworking, будем использовать его для отправки асинхронного запроса к серверам Marvel для загрузки картинок:
— (void)loadThumbnail:(GDCellThumbnailView *)view fromURLString:(NSString *)urlString forCharacter:(Character *)character {
XLog (@«Loading thumbnail for %@», character.name);
AFHTTPRequestOperation *operation = [[AFHTTPRequestOperation alloc] initWithRequest:[NSURLRequest requestWithURL:[NSURL URLWithString: urlString]]];
[operation setCompletionBlockWithSuccess:^(AFHTTPRequestOperation *operation, id responseObject) {
character.thumbnailImageData = responseObject;
[self saveToStore];
[view setImage:[UIImage imageWithData: responseObject]];
} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
XLog (@»%@», [error localizedDescription]);
}];
[operation start];
}
Вот и все. Запустим наше приложение еще раз. Уже хороший результат.
Теперь будет трудно остановиться, и я с вашего позволения использую удобный Pull-To-Refresh контрол для загрузки большего количества персонажей. Заодно проверим, как теперь выглядит наша база.
Теперь и картинки, и информация о героях (естественно только тех, которых мы успели загрузить) будут хранится локально вне зависимости от того, есть у нас соединение с Интернет или нет.
6. Заключение.
RestKit прекрасно справился с поставленной задачей: запросы отправляются, ответы получаются, объекты сохраняются автоматически. Не всем может понравиться сам принцип загрузки и отображения, предоставленный в этой статье: возможно, что разумнее было бы сразу выкачать всю базу и работать с ней полностью локально. Автор считает, что для ознакомления с базовыми возможностями RK такой функциональности вполне достаточно. Исходный код всего проекта (вместе с недостающей в этой статье частью с отображением информации о конкретном персонаже) можно скачать на GitHub. Ваши пожелания и замечания приветствуются в качестве комментариев к статье, а также пул-реквестов на GitHub.Напоследок хочется порадовать еще одним изображением — на сей раз это скриншот второго вью-контроллера, который открывается по нажатию на кнопочку «info» возле имени героя в главном вью-контроллере. Уж очень долго я прокручивал свою таблицу, чтоб наконец загрузить его: 
