Pull to refresh

Паттерны ООП в примерах для iOS

Reading time 48 min
Views 149K
Original author: Eli Ganem

От переводчика


Искали тут двух русскоязычных разработчиков — на iOS и на C++ под Windows. Видел десятки выполненных тестов. Разница в знании ООП между представителями двух платформ — огромная. На C++ обычно красивый расширяемый код, как само собой разумеющееся. На Objective C картина удручающая. Почти все iOS-кандидаты не знали ООП дальше своего носа NSString'ов и AppDelegate'ов.

Понятно, что плюсы учат по Страуструпу и «банде четырёх», а Objective C — больше по туториалам и Stack Overflow. Фастфуд-обучение не оставляет места на фундаментальные вопросы… Но такой разницы я не ожидал.

Поэтому я перевёл пост, в котором даны начальные сведения о шаблонах проектирования с примерами на iOS… «начальные»? Ага, значит, будет продолжение? Нет, не будет. Дальнейшие сведения вы получите из опыта, из попыток организовать процесс написания кода с помощью паттернов. Сначала не будет получаться, вероятно, фасад здания будет торчать из дымовой трубы, но потом придёт понимание, где какие приёмы реально помогают.

Качественная разработка ПО — творческий процесс, уникальный для каждой конкретной головы. Поэтому не существует общей инструкции: if (A and (B or C)) then use Pattern_N;

Просто почаще спрашивайте себя: то, что я написал — красиво?

Disclaimer
Паттерны — не панацея, не лекарство от кривых рук и не замена для мозгов. Вот этот трафарет:



Он сам не рисует! Рисуешь — ты.

Что такое паттерн


Идея паттернов возникла 40 лет назад у архитектора Кристофера Александера:
Любой паттерн описывает задачу, которая снова и снова возникает в нашей работе, а также принцип её решения, причём таким образом, что это решение можно потом использовать миллион раз, ничего не изобретая заново.

Программисты это увидели и сказали:
Хотя Александр имел в виду паттерны, возникающие при проектировании зданий и городов, но его слова верны и в отношении паттернов объектно-ориентированного проектирования. Наши решения выражаются в терминах объектов и интерфейсов, а не стен и дверей, но в обоих случаях смысл паттерна – предложить решение определенной задачи в конкретном контексте.



В отличие от википедии, я не буду называть паттерн «шаблоном», чтобы не путать с шаблонами C++, которые также имеют право на жизнь в Objective C.

На этом заканчиваю вступление и наконец передаю слово автору статьи. — Прим. пер. :)

Хотя нет, ещё одно лирическое отступление о важности паттернов.
…Путешественники прошли по переулку и очутились в квартале, который был застроен домами с колоннами. Здесь были колонны и прямые, и кривые, и крученые, и витые, и спиральные, и наклонные, и приплюснутые, и косопузые, и блинообразные, и даже такие, которым не подберешь имени. Карнизы у домов тоже были и прямые, и косые, и кривые, и ломаные, и зигзагообразные. У одних домов колонны находились не внизу, как полагается, а сверху, на крышах; у других домов колонны были внизу, зато сами дома стояли вверху, над колоннами; у третьих колонны были подвешены к карнизам и болтались над головами прохожих. Был дом, у которого карниз находился внизу, а колонны стояли вверх ногами и вдобавок покосились набок. Был также дом, у которого колонны стояли прямо, но сам дом стоял косо, словно собирался рухнуть на головы прохожих. Еще был дом, у которого колонны наклонились в одну сторону, а сам дом наклонился в другую, так что казалось, будто все это сейчас рухнет на землю и рассыплется в прах.

— Вы на эти косые дома не смотрите, — сказал архитектор Кубик. — Когда-то у нас была мода увлекаться строительством домов, которые ни на что не похожи. Вот и наделали такого безобразия, что теперь даже смотреть совестно!

Н. Носов. «Незнайка в Солнечном городе»




Хотя многие разработчики согласны с тем, что тема паттернов очень важна, — ей посвящено не слишком много статей, и мы часто не уделяем паттернам достойного внимания при написании кода.

Паттерн — это образец решения некоторой проблемы. Решение, которое можно повторить в другом проекте. Паттерны упрощают понимание кода. Они помогают писать слабо-связанный код (loosely coupled) — такой код, в котором можно легко модифицировать компоненты, или заменить целый компонент на его аналог, почти не трогая остальную часть проекта.

Если вы новичок в теме паттернов проектирования, то у меня для вас хорошие новости! Первая: вы уже используете огромное количество паттернов, благодаря тем принципам, на которых устроен Cocoa. (Впрочем, это не мешает эпплу быдлокодить в примерах на developer.apple.com — Прим. пер.) Второе: эта статья введёт вас в основные (и не только основные) паттерны проектирования, которые обычно используются в Cocoa.

По каждому паттерну мы рассмотрим:
  • что это за паттерн;
  • зачем он нужен;
  • как его использовать;
  • типичные ошибки при использовании паттерна (если такие есть).

Так как паттерны нельзя изучать без практики, мы напишем тестовое приложение — музыкальную библиотеку, которая покажет ваши альбомы и информацию по ним. В процессе разработки вы познакомитесь с некоторыми из паттернов:

Порождающие паттерны (creational) делают систему независимой от способа создания объектов:
  • Одиночка (singleton)
  • Абстрактная фабрика (abstract factory)

Структурные паттерны (structural) ищут простые способы наглядно представить связи между объектами:
  • MVC (спорная классификация, ну пусть будет тут. — Прим. пер.)
  • Декоратор (decorator)
  • Адаптер (adapter)
  • Фасад (facade)
  • Компоновщик (composite)

Поведенческие паттерны (behavioral) определяют сам процесс взаимодействия, «общения» между объектами:
  • Наблюдатель (observer)
  • Хранитель (memento)
  • Цепочка ответственности (chain of responsibility)
  • Команда (command)

К концу статьи наше приложение будет выглядеть примерно так:



Начинаем


Откройте проект BlueLibrary в вашей любимой IDE или в Xcode.



Там обычный ViewController и простой HTTP-клиент с пустой имплементацией.

Знаете ли вы? Как только вы создаёте новый проект в Xcode или AppCode, ваш код уже полон паттернов! MVC, «делегат», «протокол», «синглтон» – вы получаете их бесплатно! :)

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

Xcode: File > New > File… или нажмите ⌘N.
AppCode: ⌘N > File from Xcode Template…

В открывшемся диалоге выберите в списке: iOS > Cocoa Touch > Objective C class, нажмите Next. Пусть класс называется Album, и он будет подклассом NSObject.

Откройте Album.h и добавьте между @interface и @end несколько свойств и один прототип метода:

@property (nonatomic, copy, readonly) NSString * title;
@property (nonatomic, copy, readonly) NSString * artist;
@property (nonatomic, copy, readonly) NSString * genre;
@property (nonatomic, copy, readonly) NSString * coverUrl;
@property (nonatomic, copy, readonly) NSString * year;

- (id)initWithTitle:(NSString *)title
             artist:(NSString *)artist
           coverUrl:(NSString *)coverUrl
               year:(NSString *)year;

Обратите внимание: все свойства имеют флаг readonly, т.к. нам не нужно их менять после создания объекта Album.

Этот метод — инициализатор объекта. Создавая новый альбом, мы передаём сюда название альбома, исполнителя, URL обложки и год выпуска.

Теперь откройте Album.m и вставьте между @implementation и @end следующий код:

- (id)initWithTitle:(NSString *)title
             artist:(NSString *)artist
           coverUrl:(NSString *)coverUrl
               year:(NSString *)year
{
    self = [super init];
    if (self)
    {
        _title = title;
        _artist = artist;
        _coverUrl = coverUrl;
        _year = year;
        _genre = @"Pop";
    }
    return self;
}

Самая обыкновенная инициализация.

Создайте ещё один класс AlbumView — подкласс UIView.

В AlbumView.h добавьте между @interface и @end прототип метода:

- (id)initWithFrame:(CGRect)frame
         albumCover:(NSString *)albumCover;

А в AlbumView.m замените код между @implementation и @end на вот этот:

@implementation AlbumView
{
    UIImageView * coverImage;
    UIActivityIndicatorView * indicator;
}

- (id)initWithFrame:(CGRect)frame
         albumCover:(NSString *)albumCover
{
    self = [super initWithFrame:frame];
    if (self)
    {
        // Устанавливаем чёрный фон:
        self.backgroundColor = [UIColor blackColor];

        // Создаём изображение с небольшим отступом - 5 пикселей от края:
        coverImage = [[UIImageView alloc] initWithFrame:CGRectMake(5, 5, frame.size.width-10, frame.size.height-10)];
        [self addSubview:coverImage];

        // Добавляем индикатор активности:
        indicator = [[UIActivityIndicatorView alloc] init];
        indicator.center = self.center;
        indicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhiteLarge;
        [indicator startAnimating];
        [self addSubview:indicator];
    }
    return self;
}


@end

Первое, что вы должны заметить — тут есть переменная экземпляра: coverImage. Эта переменная представляет из себя изображение с обложной альбома. Вторая переменная indicator — это индикатор, который крутится, изображая активность, пока обложка загружается.

Почему эти переменные объявлены в файле реализации (*.m), а не в заголовочном файле (*.h)? Потому что другим классам (вне AlbumView) не нужно знать о существовании этих переменных, т.к. они используются только внутри класса. Этот момент является чрезвычайно важным, если вы создаете библиотеку (или фреймворк) для других разработчиков.

Выполните построение проекта (⌘B) — просто проверка. Всё в порядке? Тогда приготовьтесь: ваш первый паттерн!

MVC — король паттернов




Модель—Представление—Контроллер (Model—View—Controller или просто MVC) — одна из основ Cocoa и, несомненно, наиболее часто используемый паттерн. Он классифицирует объекты согласно их роли в приложении и помогает чистому разделению кода (что это такое — обсудим ниже).

Три роли:

  • Модель (model) содержит данные приложения и определяет механизмы манипуляций над ними. В нашем приложении моделью будет класс Album.
  • Представление (view), иногда говорят «вид» — отвечает за визуальное представление модели, а также элементов управления, с которыми пользователь может взаимодействовать. Как правило, это UIView и его подклассы. В нашем приложении представление — это AlbumView.
  • Контроллер (controller) координирует всю работу. Имеет доступ к данным модели, отображает их в представлениях, подписывается на события и манипулирует данными. Какой класс у нас будет контроллером? Правильно, ViewController.

Правильная реализация паттерна MVC означает, что в вашем приложении каждый объект попадает только в одну из этих групп.


  • Модель уведомляет контроллер о любых изменениях в данных. В свою очередь, контроллер обновляет данные в представлениях.
  • Представление уведомляет контроллер о действиях пользователя. Контроллер по необходимости обновляет модель или получает запрошенные данные.

Зачем это всё? Почему бы не выкинуть контроллер, а модель объединить с представлением в один класс? Будет гораздо проще!

Вопрос сводится к двум (взаимосвязанным) вещам:
  1. Разделение кода;
  2. Перспектива повторного использования кода.

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

Например, в будущем мы можем захотеть добавить фильмы или книги в нашу библиотеку. Мы сможем по-прежнему использовать AlbumView для отображения объектов «фильм» и «книга»! А если мы захотим создать совершенно другой проект, который будет иметь дело с альбомами, мы сможем использовать в нём класс Album — это модель, не зависящая от представления. В этом — сила MVC!

Как использовать паттерн MVC


1. Убедитесь, что каждый класс в проекте является моделью, контроллером или представлением. Один класс не может совмещать две роли! Мы уже сделали первый шаг, создав два класса: Album и AlbumView.

2. Забудьте тот ужас, который вы видели на developer.apple.com! Да-да, те примеры, где ViewController'ы играют две-три роли одновременно. — Прим. пер.

3. Создайте в проекте три группы — Model, View и Controller:



Как это сделать:

  1. File > New > Group (или с клавиатуры: в Xcode ⌘⌥N, в AppCode ⌘⌥N > Group), назовите группу Model. Проделайте ту же операцию для Controller и View.
  2. Перетащите Album.h и Album.m в группу Model.
    Перетащите AlbumView.h и AlbumView.m в группу View.
    Перетащите ViewController.h и ViewController.m в группу Controller.

Теперь наш проект выглядит гораздо лучше без этих… файлов, плавающих вокруг. Конечно, вы можете создавать другие группы и классы, но главное — вот эти три категории.

Теперь, когда ваши компоненты организованы, нам нужно где-то взять данные для альбомов. Мы создадим API-класс, через который можно будет обращаться к данным из любого места в проекте. Для этого мы подходим к следующему паттерну проектирования:

Одиночка (синглтон)


Паттерн «одиночка» гарантирует, что во всём приложении существует только один экземпляр данного класса. Существует глобальная точка доступа к этому экземпляру. Обычно применяется отложенная инициализация: этот единственный экземпляр создаётся, когда он нужен в первый раз.

Знаете ли вы? Apple широко использует данный подход. Например: [NSUserDefaults standardUserDefaults], [UIApplication sharedApplication], [UIScreen mainScreen], [NSFileManager defaultManager] — каждый из этих методов возвращает объект-синглтон.

Вы спросите, зачем беспокоиться о том, что где-то существует два или больше экземпляров класса. Почему не больше одного экземпляра? Память нынче дешёвая, разве нет?

Есть такие случаи, где требуется иметь ровно один экземпляр класса. Например, вам ни к чему держать несколько экземпляров класса Logger (только если вы не пишете несколько разных логов одновременно). Или класс обращения к глобальной конфигурации: гораздо лучше обеспечить потоко-безопасный доступ к определённому общему ресурсу (например, к файлу настроек), чем иметь много классов, модифицирующих файл настроек, возможно, одновременно.

Использование паттерна «одиночка»


Рассмотрите схему:

У класса Logger есть одно свойство instance (указатель на единственный экземпляр) и два метода: sharedInstance() и init().

Когда клиент впервые вызывает метод sharedInstance(), свойство instance ещё не инициализировано, тогда создаётся новый экземпляр класса и возвращается указатель на него.

Когда мы в следующий раз вызываем sharedInstance(), нам сразу возвращается instance без инициализации. Такая схема гарантирует существование только одного экземпляра за всё время работы программы.

Мы реализуем этот паттерн: создадим синглтон для управления всеми данными альбома.

Обратите внимание, что в проекте есть группа под названием API. Туда мы будем складывать классы, предоставляющие сервисы для нашего приложения. В этой группе создайте класс из шаблона iOS > Cocoa Touch > Objective-C class, подкласс NSObject, и назовите его LibraryAPI.

Откройте LibraryAPI.h и замените его содержимое на следующее:

@interface LibraryAPI : NSObject

+ (LibraryAPI *)sharedInstance;

@end

Перейдите к LibraryAPI.m и вставьте следующий метод после строки @implentation:

+ (LibraryAPI *)sharedInstance
{
    // 1
    static LibraryAPI * _sharedInstance = nil;

    // 2
    static dispatch_once_t oncePredicate;

    // 3
    dispatch_once(&oncePredicate, ^{
        _sharedInstance = [[LibraryAPI alloc] init];
    });
    return _sharedInstance;
}

В этом коротком методе происходит много интересного:

  1. Объявляем статическую переменную для хранения указателя на экземпляр класса (её значение будет доступно глобально из нашего класса).
  2. Объявляем статическую переменную dispatch_once_t, которая обеспечит, что код инициализации будет выполнен только один раз.
  3. При помощи Grand Central Dispatch (GCD) выполняем инициализацию экземпляра LibraryAPI. В этом суть паттерна «одиночка»: блок инициализации никогда не выполнится повторно.

При повторном вызове sharedInstance() код внутри блока dispatch_once не будет выполнен (т.к. он уже выполнен ранее), и вы получите указатель на предыдущий созданный экземпляр LibraryAPI.

Примечание. Чтобы узнать больше о GCD и о его применении, автор статьи советует туториалы на английском: Multithreading and Grand Central Dispatch и How To Use Blocks.

Итак, у нас есть синглтон — отправная точка для управления альбомами. Сделаем следующий шаг: создадим класс для хранения наших данных.

В группе API создаём новый класс: iOS > Cocoa Touch > Objective-C class, подкласс NSObject, а назовём его PersistencyManager.

Откройте PersistencyManager.h и добавьте строчку в начало файла:

#import "Album.h"

Далее добавьте следующий код после @interface:

- (NSArray *)albums;
- (void)addAlbum:(Album *)album atIndex:(NSUInteger)index;
- (void)deleteAlbumAtIndex:(NSUInteger)index;

Это прототипы трёх методов, которые будут работать с данными альбомов.

Откройте PersistencyManager.m и добавьте код непосредственно перед строчкой @implementation:

@interface PersistencyManager ()
{
    NSMutableArray * albums; // Массив всех альбомов
}
@end

Это расширение класса (class extension) — ещё один способ добавить закрытые (private) методы и переменные к классу таким образом, чтобы внешние классы не знали о них. Здесь мы объявляем массив, который будет содержать данные альбомов. Этот массив — изменяемый (mutable), так что мы можем легко добавлять и удалять альбомы.

Теперь добавим реализацию класса PersistencyManager после строки @implementation:

- (id)init
{
    self = [super init];
    if (self)
    {
        albums = [NSMutableArray arrayWithArray:
                @[[[Album alloc] initWithTitle:@"Best of Bowie"
                                        artist:@"David Bowie"
                                      coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_david_bowie_best_of_bowie.png"
                                          year:@"1992"],

                        [[Album alloc] initWithTitle:@"It's My Life"
                                              artist:@"No Doubt"
                                            coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_no_doubt_its_my_life_bathwater.png"
                                                year:@"2003"],

                        [[Album alloc] initWithTitle:@"Nothing Like The Sun"
                                              artist:@"Sting"
                                            coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_sting_nothing_like_the_sun.png"
                                                year:@"1999"],

                        [[Album alloc] initWithTitle:@"Staring at the Sun"
                                              artist:@"U2"
                                            coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_u2_staring_at_the_sun.png"
                                                year:@"2000"],

                        [[Album alloc] initWithTitle:@"American Pie"
                                              artist:@"Madonna"
                                            coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_madonna_american_pie.png"
                                                year:@"2000"]]];
    }
    return self;
}

В методе init мы собираем массив из пяти альбомов для примера (если вам нечего делать, можете заменить их на свою любимую музыку).

Теперь добавьте три метода в PersistencyManager.m:

- (NSArray *)albums
{
    return albums;
}

- (void)addAlbum:(Album *)album atIndex:(NSUInteger)index
{
    if (albums.count >= index)
        [albums insertObject:album atIndex:index];
    else
        [albums addObject:album];
}

- (void)deleteAlbumAtIndex:(NSUInteger)index
{
    [albums removeObjectAtIndex:index];
}

Эти простые методы позволяют получать, добавлять и удалять альбомы.

Постройте проект (⌘B), просто чтобы убедиться, что он ​​компилируется.

Вы можете удивиться: что делает PersistencyManager в главе про синглтоны? Связь между LibraryAPI и PersistencyManager будет показана в следующей главе.

Фасад




Паттерн «Фасад» предоставляет единый интерфейс к сложной подсистеме. Чтобы не шокировать юзеров множеством классов с разными интерфейсами, мы предоставляем один простой API:



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

Паттерн «Фасад» отделяет код (API), который обращается к подсистеме, от тех классов, которые мы хотим спрятать. Он уменьшает зависимость внешнего кода от внутренней кухни подсистемы. Например, это полезно, когда классы, скрытые за фасадом, с высокой вероятностью могут подвергнуться модификации. Фасад будет предоставлять всё тот же API, в то время как всё меняется за кулисами.



ЕслиКогда придёт время заменить ваш бэкенд, вам не придётся переписывать код, использующий API, т.к. API не изменится.

Использование паттерна «Фасад»


Сейчас у нас есть PersistencyManager для локального хранения данных и HTTPClient для сетевого взаимодействия. Другие классы в вашем проекте не должны думать об этих двух вещах.

Для реализации этого паттерна, только один класс — LibraryAPI — должен ссылаться на экземпляры PersistencyManager и HTTPClient. LibraryAPI — это и есть тот фасад, который предоставляет простой интерфейс для доступа к сервисам (хранение и передача данных).

Замечание. Обычно объект-синглтон существует в течение всего времени выполнения программы. Не нужно держать в синглтоне слишком много «сильных» указателей на другие объекты, т.к. они не будут освобождены из памяти до тех пор, пока приложение не закроется.
Что такое сильный указатель (strong pointer)?
На Stack Overflow нашёлся интересный ответ:

Сборщик мусора освободит память из-под объекта, как только не останется «сильных» указателей на него. Даже если есть «слабые» указатели (weak pointer) — как только последняя сильная ссылка удалена, объект освобождается из памяти, а оставшиеся «слабые» ссылки обнуляются.

Пример
Представьте, что наш объект — это собака. Собака хочет «убежать» (освободить память).



Сильный указатель — это поводок с ошейником. Пока поводок прицеплен к ошейнику, собака не убежит. Если 5 человек прицепят 5 поводков к одному ошейнику (5 указателей на 1 объект) — собака не убежит до тех пор, пока не отцепят все 5 поводков.

А слабые указатели — это дети, которые тычут пальцем на собаку и кричат: «Ух ты, собака!» Пока собака на поводке, они могут тыкать («указывать на объект») сколько угодно. Но если отсоединить все поводки, то собака убежит, независимо от того, сколько детей тычут в неё пальцем.

Получается примерно такая схема:



LibraryAPI будет открыта внешнему коду, но скроет сложность классов HTTPClient и PersistencyManager от остальной части приложения.

Откройте LibraryAPI.h и добавьте в начало файла директиву:

#import "Album.h"

Ниже добавьте объявления методов в интерфейс:

- (NSArray *)albums;
- (void)addAlbum:(Album *)album atIndex:(int)index;
- (void)deleteAlbumAtIndex:(int)index;

Вот те методы, которые будут открыты другим классам.

Перейдите к LibraryAPI.m и добавьте в начало:

#import "PersistencyManager.h"
#import "HTTPClient.h"

Это единственное место, где мы импортируем эти классы! Ваш API будет единственной точкой доступа к вашей «сложной» системе.

Теперь добавьте три закрытых переменных в расширение класса (над строчкой @implementation):

@interface LibraryAPI ()
{
    PersistencyManager * persistencyManager;
    HTTPClient * httpClient;
    BOOL isOnline;
}
@end

Переключатель isOnline обозначает: должны ли изменения, связанные с альбомами (добавление, удаление) поступать на сервер?

Инициализируем эти переменные. Добавьте в LibraryAPI.m:

- (id)init
{
    self = [super init];
    if (self)
    {
        persistencyManager = [[PersistencyManager alloc] init];
        httpClient = [[HTTPClient alloc] init];
        isOnline = NO;
    }
    return self;
}

Примечание: HTTP-клиент у нас не будет работать с реальным сервером. Он присутствует здесь только для демонстрации паттерна «Фасад». isOnline всегда будет NO.

Затем добавьте три метода в реализацию класса LibraryAPI:

- (NSArray *)albums
{
    return [persistencyManager albums];
}

- (void)addAlbum:(Album *)album atIndex:(int)index
{
    [persistencyManager addAlbum:album atIndex:index];
    if (isOnline)
    {
        [httpClient postRequest:@"/api/addAlbum" body:[album description]];
    }
}

- (void)deleteAlbumAtIndex:(int)index
{
    [persistencyManager deleteAlbumAtIndex:index];
    if (isOnline)
    {
        [httpClient postRequest:@"/api/deleteAlbum" body:[@(index) description]];
    }
}

Взгляните на метод addAlbum:atIndex:. Сначала он обновляет данные локально, а затем, если есть подключение к интернету, обновляет данные на удалённом сервере. Это реальное преимущество «Фасада»: когда какой-нибудь класс за пределами Солнечной системы добавляет новый альбом, он не знает — и не должен знать — о сложности того, что скрыто внутри.

Примечание: При использовании паттерна «Фасад» помните, что ничто не мешает клиенту получить доступ к «скрытым» классам напрямую. Не экономьте на действия для «защиты от дурака» или просто имейте в виду, что не все будут играть по вашим правилам.

Создайте и запустите приложение. Идеально черный экран:



На iOS 6 мы бы увидели статус-бар с часами. Пользователи iOS 7 лишены и этого. Текст и символы по дефолту чёрные.

Нам нужно что-то для отображения информации об альбоме. Отличный повод перейти к следующему паттерну, который называется…

Декоратор


Говоря умными словами, шаблон «Декоратор» динамически добавляет поведение (behaviors) и обязанности (responsibilities) к объекту, не меняя его код. Это альтернатива наследованию (при наследовании вы меняете поведение класса, обернув его в подкласс).

В Objective-C есть две очень распространенные реализации этого паттерна: категории и делегирование.

Категории


«Категория» (Category) — очень мощный механизм, позволяющий добавлять методы к существующим классам без наследования. Новые методы добавляются при компиляции и могут быть выполнены как обычные методы расширенного класса. Это немного отличается от классического определения «Декоратора», т.к. «Категория» не содержит экземпляр класса, который она расширяет.

Примечание: Кроме расширения собственных классов, вы также можете добавлять методы к любым классам Cocoa!

Как использовать категории


Представьте ситуацию: у нас есть объект Album, который мы хотим показать в таблице:



Откуда к нам придёт информация об альбоме? Album — это объект «модель», ему без разницы, в каком виде мы показываем данные. Нам нужен некоторый внешний (по отношению к Album) код, чтобы добавить соответствующий функционал к классу Album, не трогая код Album.

Мы с вами создадим категорию — расширение класса Album. Методы категории будут возвращать структуру данных, удобную для использования в UITableView.

Структура данных будет выглядеть так:



Чтобы добавить категорию к классу Album, создайте новый файл по шаблону Objective-C category (не выберите по привычке Objective-C class!)
Заполните поля:
Category: TableRepresentation
Category on: Album

Заметили новое имя файла? Album+TableRepresentation означает, что мы расширяем класс Album. Важно следовать этому соглашению, так как оно облегчает чтение и предотвращает возможные совпадения с другими категориями (которые можете создать вы или кто-то ещё).

Перейдите к Album+TableRepresentation.h и добавьте прототип метода:

- (NSDictionary *)tr_tableRepresentation;

Обратите внимание на префикс имени метода: tr_ — аббревиатура от названия категории (TableRepresentation). Это ещё одно правило, которое поможет нам предотвратить пересечения с именами других методов.

Важное замечание. Если название метода, объявленного в категории, совпадает с названием метода исходного класса (или с названием метода другой категории этого класса или даже его суперкласса) — в этом случае поведение не определено. Вот так. Неизвестно, какой из методов будет выполнен. Это не проблема, если вы оперируете категориями над своими классами. Но это может вызвать большие трудности, если вы используете категории для расширения стандартных классов Cocoa или Cocoa Touch.

Перейдите в Album+TableRepresentation.m и добавьте следующий метод:

- (NSDictionary *)tr_tableRepresentation
{
    return @{@"titles":@[@"Исполнитель", @"Альбом", @"Жанр", @"Год"],
             @"values":@[self.artist, self.title, self.genre, self.year]};
}

Только подумайте, насколько мощный паттерн:
  • Мы используем свойства прямо из Album.
  • Мы расширили функционал класса Album без наследования. (Если вы хотите наследовать от Album, это тоже можно сделать.)
  • Это простое дополнение позволяет нам UITableView'шное представление Album'а без модификации самого кода Album.

Apple много использует категории в классах Foundation. Чтобы посмотреть, как они это делают, можете открыть NSString.h. Найдите @interface NSString — там вы увидите объявление класса с тремя категориями: NSStringExtensionMethods, NSExtendedStringPropertyListParsing, NSStringDeprecated. Категории позволяют упорядочить методы, организовать их по разделам.

Делегирование


Другой вариант паттерна «Декоратор» — это делегирование. Механизм, при котором один объект взаимодействует от имени другого объекта (или в координации с ним). Очень похоже на то, как руководитель делегирует полномочия подчинённым.

Рассмотрим пример: при использовании UITableView один из методов, который мы должны реализовать, это tableView:numberOfRowsInSection: (число строк в разделе таблицы).

Мы не ожидаем, что UITableView сам знает, сколько строк должно быть в каждом разделе таблицы. Понятно, что это число зависит от приложения. Поэтому UITableView передаёт задачу «узнать число строк в каждом разделе» своему делегату. Это даёт важную вещь: сам класс UITableView не зависит от отображаемых данных.

Вот что происходит, когда вы создаёте новый UITableView:



Объект UITableView делает свою работу, показывая таблицу. Для этого ему требуется некоторая информация, которой у него нет. Он обращается к своему делегату (или делегатам), запрашивая дополнительную информацию. Кстати, класс может объявить обязательные и необязательные методы, используя протокол (мы узнаем о протоколах позже в данном уроке).

Казалось бы, проще наследоваться от объекта и переопределить необходимые методы? Но есть один момент: вы можете наследоваться только от одного класса. Язык Objective C не поддерживает множественное наследование. Если вы захотите сделать объект делегатом двух (или более) других объектов, то этого нельзя достичь путём наследования.

Примечание: это важный паттерн. Apple использует этот подход в большинстве классов UIKit: UITableView, UITextView, UITextField, UIWebView, UIAlert, UIActionSheet, UICollectionView, UIPickerView, UIGestureRecognizer, UIScrollView, … окей, не буду продолжать.

Как использовать делегирование


Откройте ViewController.m и добавьте в начало файла ещё пару «импортов»:

#import "LibraryAPI.h"
#import "Album+TableRepresentation.h"

Добавим четыре частные переменные в расширение класса, между @interface и @end:

@interface ViewController ()
{
    UITableView * dataTable;
    NSArray * allAlbums;
    NSDictionary * currentAlbumData;
    int currentAlbumIndex;
}
@end

И здесь же — нам нужно указать, что класс является делегатом. Добавим в угловых скобках пару названий:

@interface ViewController () <UITableViewDataSource, UITableViewDelegate>

UITableViewDataSource, UITableViewDelegate — это названия протоколов. Тем самым мы утверждаем, что делегат будет соответствовать этим двум протоколам. Это своего рода «обещание» делегата выполнить требования протокола.

В результате, UITableView абсолютно уверен, что делегат реализует требуемые методы.

Далее замените метод viewDidLoad на следующий код:

- (void)viewDidLoad
{
    [super viewDidLoad];
    // 1
    self.view.backgroundColor = [UIColor colorWithRed:0.76f green:0.81f blue:0.87f alpha:1.f];
    currentAlbumIndex = 0;

    // 2
    allAlbums = [[LibraryAPI sharedInstance] albums];

    // 3
    // UITableView, который отображает данные альбома
    CGRect frame = CGRectMake(0.f, 120.f, self.view.frame.size.width, self.view.frame.size.height - 120.f);
    dataTable = [[UITableView alloc] initWithFrame:frame
                                             style:UITableViewStyleGrouped];
    dataTable.delegate = self;
    dataTable.dataSource = self;
    dataTable.backgroundView = nil;
    [self.view addSubview:dataTable];
}

Вот анализ этого метода:
  1. Установить цвет фона в тёмно-синий.
  2. Получить список всех альбомов через API. Нам не нужно использовать PersistencyManager напрямую!
  3. Создать UITableView. Мы заявляем, что наш ViewController — делегат и источник данных (data source) для UITableView, поэтому вся информация, которая нужна UITableView, будет предоставлена ViewController'ом.

Теперь добавьте ещё один метод в ViewController.m:

- (void)showDataForAlbumAtIndex:(int)albumIndex
{
    // Защита от дурака: убедимся, что запрошенный индекс меньше числа альбомов
    if (albumIndex < allAlbums.count)
    {
        // Берём альбом:
        Album * album = allAlbums[albumIndex];
        // Сохраняем данные альбома, чтобы позже показать в TableView:
        currentAlbumData = [album tr_tableRepresentation];
    }
    else
    {
        currentAlbumData = nil;
    }

    // У нас есть данные, которые нам нужны. Обновляем TableView
    [dataTable reloadData];
}

showDataForAlbumAtIndex: собирает нужные данные по альбому из массива альбомов. Если вы хотите отобразить новые данные, просто вызываете reloadData. В результате этого вызова, UITableView заново начнёт спрашивать своего делегата о таких вещах, как, например, сколько разделов будет в таблице, сколько строк в каждой секции, как должна выглядеть та или иная ячейка.

Добавьте строку в конец viewDidLoad:

[self showDataForAlbumAtIndex:currentAlbumIndex];

Эта строчка загружает текущий альбом при запуске приложения. Так как индекс текущего альбома currentAlbumIndex был ранее установлен в 0, мы видим первый нулевой альбом в коллекции.

Запустив приложение на симуляторе, мы получим… крэш:



Что происходит? Мы объявили наш ViewController делегатом и источником данных UITableView. Но! Сделав это, мы должны реализовать все требуемые методы (включая tableView:numberOfRowsInSection:), а мы этого ещё не сделали.

Добавьте эти два метода в ViewController.m, в любое место между @implementation и @end:

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return [currentAlbumData[@"titles"] count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell * cell = [tableView dequeueReusableCellWithIdentifier:@"cell"];
    if (!cell)
    {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1
                                      reuseIdentifier:@"cell"];
    }

    cell.textLabel.text = currentAlbumData[@"titles"][indexPath.row];
    cell.detailTextLabel.text = currentAlbumData[@"values"][indexPath.row];

    return cell;
}

tableView:numberOfRowsInSection: возвращает число строк, которые нужно отобразить в TableView, что соответствует числу полей в структуре данных.

tableView:cellForRowAtIndexPath: создаёт и возвращает ячейку таблицы с названием поля (title) и его значением (value).

Запустив приложение, вот что мы должны увидеть:



Пока всё идёт неплохо. Но если вы вспомните первое изображение в статье, там был интреесный горизонтальный скролл для переключения между альбомами.

Хочется сделать не «одноразовый» скролл, а такой, который можно потом повторно использовать на любом экране, да? Для этого все решения относительно его содержимого должны приниматься другим объектом — делегатом. Горизонтальный скролл объявит методы, которые делегат должен реализовать. По типу того, как работали методы делегата UITableView. Мы это сделаем в рамках обсуждения следующего паттерна.

«Адаптер»


Адаптер позволяет классам с несовместимыми интерфейсами работать вместе. Адаптер — это обёртка вокруг объекта, которая предоставляет унифицированный интерфейс для взаимодействия с этим объектом.



Если вы знакомы с паттерном «Адаптер» по другим языкам, то вы заметите, что Apple реализует его своеобразно (примерно как и требование Еврокомиссии перейти на MicroUSB). Они это делают с помощью протоколов. Вы, можете быть, знакомы с протоколами UITableViewDelegate, UIScrollViewDelegate, NSCoding, NSCopying. К примеру, протокол NSCopying предоставляет любому классу стандартный метод copy.

Как использовать паттерн «Адаптер»


Вышеупомянутый горизонтальный скролл будет выглядеть следующим образом:



Чтобы приступить к его реализации, создайте новый класс из шаблона «Objective-C class» (ещё не забыли, как?) в группе View, назовите его HorizontalScroller и наследуйте его от UIView.

Откройте HorizontalScroller.h и добавьте после @end:

@protocol HorizontalScrollerDelegate <NSObject>
// тут будут объявления методов
@end

Этим мы определяем протокол HorizontalScrollerDelegate — наследник от протокола NSObject (таким же образом, как наследуются классы). Общепринятая практика — чтобы класс соответствовал протоколу NSObject или другому протоколу, который наследуется/соответствует протоколу NSObject. В результате мы сможем отправлять сообщения, определённые в NSObject, делегату нашего HorizontalScroller. Скоро увидим, почему это важно.

Объявляем обязательные и необязательные методы, которые делегат должен реализовать, между @protocol и @end:

@required

// Спросить делегата, сколько представлений мы покажем внутри горизонтального скроллера
- (NSInteger)numberOfViewsForHorizontalScroller:(HorizontalScroller *)scroller;

// Попросить делегата получить представление по индексу <index>
- (UIView *)horizontalScroller:(HorizontalScroller *)scroller viewAtIndex:(int)index;

// Сообщить делегату о нажатии на представлении по индексу <index>
- (void)horizontalScroller:(HorizontalScroller *)scroller clickedViewAtIndex:(int)index;

@optional

// Спросить делегата, какое из представлений отобразить при открытии
// (метод необязательный, по умолчанию 0, если делегат не реализует метод)
- (NSInteger)initialViewIndexForHorizontalScroller:(HorizontalScroller *)scroller;

Здесь у нас есть обязательные и необязательные методы. Обязательные — должны быть реализованы делегатом, они обычно содержат те данные, которые абсолютно необходимы классу. Здесь нам необходимы данные по количеству элементов (представлений), элемент по данному индексу, а также действие по нажатию на элемент.

Опциональный метод — индекс стартового элемента. Если его не указать, то HorizontalScroller при старте начнёт сначала.

Дальше мы должны сослаться на название нашего делегата внутри объявления класса HorizontalScroller. Но протокола у нас объявляется после класса, т.е. ещё не виден на момент объявления HorizontalScroller. Что же делать?

Решение — предварительное объявление (forward declaration) протокола HorizontalScrollerDelegate. Чтобы компилятор знал, что такой протокол у нас есть (но будет объявлен позже). Добавьте над строчкой @interface:

@protocol HorizontalScrollerDelegate;

В том же файле HorizontalScroller.h добавьте ещё пару строк между @interface и @end:

@property (weak) id<HorizontalScrollerDelegate> delegate;

- (void)reload;

Заметьте, мы добавили атрибут weak («слабый») к свойству (указателю на) delegate. Это необходимо, чтобы предотвратить «retain-зацикливание». Если объект хранит сильный указатель на делегата, а делегат хранит сильный указатель обратно на тот объект, то вы гарантированно получите утечку памяти. Ни один из двух объектов никогда не освободит память.

(Если кто-то забыл разницу между сильными и слабыми указателями, то она обсуждается в главе про использование паттерна «Фасад» под спойлером).

Тип id<HorizontalScrollerDelegate> означает, что делегатом может стать объект любого класса, который соответствует протоколу HorizontalScrollerDelegate (что даёт нам некоторую безопасность типов).

Метод reload будет работать аналогично reloadData в UITableView: он будет заново загружать все данные, нужные для горизонтального скролла.

Замените всё содержимое HorizontalScroller.m на такую заготовку (дальше мы её будем наполнять):

#import "HorizontalScroller.h"

// 1
#define VIEW_PADDING 10
#define VIEW_DIMENSIONS 100
#define VIEWS_OFFSET 100

// 2
@interface HorizontalScroller () <UIScrollViewDelegate>
@end

// 3
@implementation HorizontalScroller
{
    UIScrollView * scroller;
}

@end

По пунктам, что мы делаем:
  1. Определяем константы для удобной модификации внешнего вида на этапе разработки. Размеры представлений внутри скролла будут 100х100 пунктов с отступом 10 пунктов от прямоугольника, который их обрамляет.
    (Пункты — не путать с пикселями! В iOS Drawing Concepts есть раздел Points Versus Pixels).
  2. Класс HorizontalScroller реализует протокол UIScrollViewDelegate. Так как HorizontalScroller использует UIScrollView для прокрутки обложек альбомов, он должен знать о пользовательских событиях, например, когда пользователь остановил прокрутку.
  3. Собственно, создаём scroll view.

Дальше нам нужно написать метод инициализации:

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self)
    {
        scroller = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];
        scroller.delegate = self;
        [self addSubview:scroller];
        UITapGestureRecognizer * tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(scrollerTapped:)];
        [scroller addGestureRecognizer:tapRecognizer];
    }
    return self;
}

Область прокрутки (scroller) полностью заполняет HorizontalScroller. Объект класса UITapGestureRecognizer обнаруживает касания на данном ScrollView и проверяет, было ли нажатие на обложке альбома. Если было, то уведомляет делегата HorizontalScroller.

Добавьте следующий метод:

- (void)scrollerTapped:(UITapGestureRecognizer *)gesture
{
    CGPoint location = [gesture locationInView:gesture.view];
    // Не используем enumerator, т.к. не хотим перечислять все дочерние представления.
    // Мы хотим перечислить только те subviews, которые мы добавили:
    for (int index = 0; index < [self.delegate numberOfViewsForHorizontalScroller:self]; index++)
    {
        UIView * view = scroller.subviews[index];
        if (CGRectContainsPoint(view.frame, location))
        {
            [self.delegate horizontalScroller:self clickedViewAtIndex:index];
            CGPoint offset = CGPointMake(view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2, 0);
            [scroller setContentOffset:offset animated:YES];
            break;
        }
    }
}

Из объекта «жест» (gesture), передаваемого в виде параметра, можно извлечь позицию (locationInView:).

Дальше вызвали метод делегата numberOfViewsForHorizontalScroller:. Экземпляр HorizontalScroller не имеет никакой информации о делегате, кроме того, что ему можно смело отправить это сообщение (ибо делегат соответствует протоколу HorizontalScrollerDelegate).

Для каждого представления внутри scroll view — проверили, было ли на нём нажатие, вызвав функцию CGRectContainsPoint. Если нашли такое представление, отправили делегату сообщение horizontalScroller:clickedViewAtIndex: и установили выбранный элемент по центру области прокрутки.

Теперь добавим код обновления данных в скроллере:

- (void)reload
{
    // 1 - нечего загружать, если нет делегата:
    if (self.delegate == nil) return;

    // 2 - удалить все subviews:
    [scroller.subviews enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL * stop) {
        [obj removeFromSuperview];
    }];

    // 3 - xValue - стартовая точка всех представлений в скроллере:
    CGFloat xValue = VIEWS_OFFSET;
    for (int i = 0; i < [self.delegate numberOfViewsForHorizontalScroller:self]; i++)
    {
        // 4 - добавляем представление в нужную позицию:
        xValue += VIEW_PADDING;
        UIView * view = [self.delegate horizontalScroller:self viewAtIndex:i];
        view.frame = CGRectMake(xValue, VIEW_PADDING, VIEW_DIMENSIONS, VIEW_DIMENSIONS);
        [scroller addSubview:view];
        xValue += VIEW_DIMENSIONS + VIEW_PADDING;
    }

    // 5
    [scroller setContentSize:CGSizeMake(xValue + VIEWS_OFFSET, self.frame.size.height)];

    // 6 - если определён initialView, центрируем его в скроллере:
    if ([self.delegate respondsToSelector:@selector(initialViewIndexForHorizontalScroller:)])
    {
        int initialView = [self.delegate initialViewIndexForHorizontalScroller:self];
        CGPoint offset = CGPointMake(initialView * (VIEW_DIMENSIONS + (2 * VIEW_PADDING)), 0);
        [scroller setContentOffset:offset animated:YES];
    }
}

Пройдёмся по этому коду:
  1. Если нет делегата, то нам тут нечего делать. Завершаем выполнение метода.
  2. Удаляем все дочерние представления (subviews), добавленные ранее.
    (Интересно, как перевести «subview» проще и не коверкая русский язык? Не «подвиды» же. :) — Прим. пер.)
  3. Все представления расположены начиная с некоторого смещения (VIEWS_OFFSET). В настоящее время это значение 100 пунктов, и оно может быть легко изменено в #define выше в этом файле.
  4. HorizontalScroller спрашивает у своего делегата все представления (UIView) одно за другим, и располагает их по горизонтали на определенном расстоянии друг от друга.
  5. Когда все представления на месте, установить размер скроллера, чтобы позволить пользователю осуществлять прокрутку между всеми альбомами.
  6. HorizontalScroller смотрит: отвечает ли делегат на селектор сообщения initialViewIndexForHorizontalScroller:. Такая проверка нужна, потому что этот метод протокола является необязательным. Если делегат не реализует этот метод, в качестве значения по умолчанию берётся 0. Эта часть кода устанавливает вид прокрутки по центру представления, определённого делегатом (initialView).

Мы выполняем reload в том случае, если у нас изменились данные. Также этот метод нужно вызвать, когда мы добавляем HorizontalScroller к новому представлению. Для этого добавьте такой метод к классу HorizontalScroller:

- (void)didMoveToSuperview
{
    [self reload];
}

Смысл сообщения didMoveToSuperview, думаю, понятен из названия: оно отправляется нашему представлению, когда оно добавляется к какому-то другому в качестве дочернего. В этом случае нужно обновить содержимое скроллера.

Последняя часть пазла HorizontalScroller — убедиться, что текущий выбранный альбом всегда находится в центре scroll view. Для этого нам нужно выполнить некоторые вычисления, когда пользователь осуществляет прокрутку.

Добавьте следующий метод (всё к тому же файлу, да, HorizontalScroller.m):

- (void)centerCurrentView
{
    int xFinal = scroller.contentOffset.x + (VIEWS_OFFSET / 2) + VIEW_PADDING;
    int viewIndex = xFinal / (VIEW_DIMENSIONS + (2 * VIEW_PADDING));
    xFinal = viewIndex * (VIEW_DIMENSIONS + (2 * VIEW_PADDING));
    [scroller setContentOffset:CGPointMake(xFinal, 0) animated:YES];
    [self.delegate horizontalScroller:self clickedViewAtIndex:viewIndex];
}

Этот код вычисляет расстояние текущего представления от центра на основании текущего смещения прокрутки (content offset), а также размеров (dimensions) и отступов (padding). Последняя строка имеет следующее значение: когда наш альбом перемещён в центр, наконец сообщить делегату, что выделение изменилось.

Чтобы определить момент, когда пользователь закончил скроллить, добавим следующие методы протокола UIScrollViewDelegate:

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
    if (!decelerate)
    {
        [self centerCurrentView];
    }
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
    [self centerCurrentView];
}

scrollViewDidEndDragging:willDecelerate: сообщает делегату, когда пользователь заканчивает перетаскивание. Параметр decelerate («замедление») принимает значение true, если скролл на данный момент ещё не полностью остановился, а продолжает двигаться «по инерции». Когда прокрутка полностью остановится, система пошлёт нам сообщение scrollViewDidEndDecelerating. В обоих случаях мы должны центрировать текущий альбом (centerCurrentView), так как текущий альбом, понятно, мог смениться в результате прокрутки.

Ваш HorizontalScroller готов! Ещё раз посмотрите на этот код. Здесь нет ни одного упоминания Album или AlbumView. Превосходно! Это значит, наш новый скроллер получился действительно независимым от контента, и его можно повторно использовать.

Выполните построение проекта, чтобы убедиться, что всё компилируется.

Теперь, когда горизонтальный скролл готов, самое время использовать его в нашем приложении! Откройте ViewController.m и включите в него 2 заголовочных файла:

#import "HorizontalScroller.h"
#import "AlbumView.h"

Ниже добавьте HorizontalScrollerDelegate к списку протоколов, которые реализует ViewController:

@interface ViewController () <UITableViewDataSource, UITableViewDelegate, HorizontalScrollerDelegate>

Добавит переменную экземпляра «скроллер» в расширение класса (между фигурными скобками):

HorizontalScroller * scroller;

Теперь можно реализовать методы делегата. Вы удивитесь, как мало кода потребуется для множества функций!

Добавьте код в ViewController.m:

#pragma mark - HorizontalScrollerDelegate methods

- (void)horizontalScroller:(HorizontalScroller *)scroller clickedViewAtIndex:(int)index
{
    currentAlbumIndex = index;
    [self showDataForAlbumAtIndex:index];
}

Он устанавливает значение переменной, которая хранит текущий альбом, и затем вызывает showDataForAlbumAtIndex: для отображения данных по новому альбому.

Примечание. Иногда несколько методов можно сгруппировать под одним заголовком. Этим заголовком является директива #pragma mark. Компилятор её игнорирует, но вы увидите результат в панели навигации в IDE — название нашей директивы жирным шрифтом. Например, в AppCode по нажатию ⌘F12:



Такая организация кода, конечно, сильно упрощает навигацию по файлу.

Затем добавьте код:

- (NSInteger)numberOfViewsForHorizontalScroller:(HorizontalScroller *)scroller
{
    return allAlbums.count;
}

Помните? Это метод протокола, возвращающий число представлений в нашем scroll view. Мы хотим отображать обложки всех альбомов, которые у нас есть, поэтому возвращаем общее число альбомов.

Теперь добавим код:

- (UIView *)horizontalScroller:(HorizontalScroller *)scroller viewAtIndex:(int)index
{
    Album * album = allAlbums[index];
    return [[AlbumView alloc] initWithFrame:CGRectMake(0.f, 0.f, 100.f, 100.f) albumCover:album.coverUrl];
}

Здесь мы создаём новый AlbumView и передаём его в HorizontalScroller.

Всего-то! Три коротких метода, чтобы отобразить красивый горизонтальный скроллер!

Да, и мы всё равно должны создать (собственно) скроллер и добавить его к главному представлению. Но сначала добавим следующий метод:

- (void)reloadScroller
{
    allAlbums = [[LibraryAPI sharedInstance] albums];
    if (currentAlbumIndex < 0)
        currentAlbumIndex = 0;
    else if (currentAlbumIndex >= allAlbums.count)
        currentAlbumIndex = allAlbums.count - 1;
    [scroller reload];

    [self showDataForAlbumAtIndex:currentAlbumIndex];
}

Этот метод загружает данные по альбому через LibraryAPI, а затем устанавливает текущее представление. На всякий случай — проверка на выход за границы массива.

Теперь инициализируем скроллер, добавив следующий код в viewDidLoad перед строчкой [self showDataForAlbumAtIndex:currentAlbumIndex];

scroller = [[HorizontalScroller alloc] initWithFrame:CGRectMake(0.f, 20.f, self.view.frame.size.width, 120.f)];
scroller.backgroundColor = [UIColor colorWithRed:0.24f green:0.35f blue:0.49f alpha:1];
scroller.delegate = self;
[self.view addSubview:scroller];

[self reloadScroller];

Этот код создает новый экземпляр HorizontalScroller, устанавливает цвет фона, назначает делегатом себя. Затем добавляет скроллер на главный экран и обновляет в нём данные.

Примечание. Если протокол сильно разрастается, подумайте о том, чтобы разбить его на несколько более мелких. Например, UITableViewDelegate и UITableViewDataSource. Они оба — протоколы UITableView, т.е. технически могли существовать в одном протоколе, но разбиты для удобства. Старайтесь разрабатывать протоколы так, чтобы каждый отвечал за свою область функционала.

Постройте и запустите проект. Чудесный горизонтальный скролл:



Так, стоп. Скролл есть. А где обложки?

А, точно! Мы же ещё не написали код для загрузки обложек. Нам надо придумать, как загружать изображения. Так как весь наш доступ к сервисам идёт через LibraryAPI, новый метод должен будет направиться именно туда. Но сначала нужно разобрать несколько моментов:

1. AlbumView не должен работать напрямую с LibraryAPI. Мы же не хотим смешивать код UI с сетевым взаимодействием, так?
2. По той же причине LibraryAPI не должна знать о AlbumView.
3. LibraryAPI должна сообщить AlbumView, как только обложки загрузятся, чтобы AlbumView их отобразил.

Выглядит как головоломка? Не отчаивайтесь! Нам на помощь спешит следующий паттерн —

Наблюдатель


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

Обычно наблюдатель «регистрирует» свой интерес о состоянии другого объекта. Когда состояние меняется, все объекты-наблюдатели будут уведомлены об изменении. Сервис push-уведомлений Apple — известный пример такой схемы.

Если вы хотите придерживаться концепции MVC (подсказка: вы хотите) — вам нужно реализовать взаимодействие между объектами Модель и Представление, но — без прямых ссылок друг на друга! И здесь вступает в действие паттерн «Наблюдатель».

Cocoa реализует этот паттерн двумя похожими способами: уведомления (Notifications) и Key-Value Observing (KVO).

Уведомления


(Только не путайте с Push и с прочими выскакивающими сообщениями. Мы говорим о программировании.) Уведомления основаны на модели «подписка—публикация». Согласно ей, объект «издатель» (publisher) рассылает сообщения подписчикам (subscribers / listeners). Издатель ничего не должен знать о подписчиках.

Уведомления активно используются Apple. Например, когда iOS показывает или скрывает клавиатуру, система рассылает уведомление: UIKeyboardWillShowNotification или UIKeyboardWillHideNotification, соответственно. Когда ваше приложение уходит в фоновый режим, система отправляет уведомление UIApplicationDidEnterBackgroundNotification.

Примечание. Откройте UIApplication.h, в конце файла вы увидите огромный список уведомлений, отправляемых системой.
Как его открыть?
1 способ: напишите в начале любого файла:

#import <UIKit/UIApplication.h>

И при нажатой кнопке щёлкните на UIApplication.h.

2 способ: в проекте найдите папку Frameworks (список ссылок на используемые фреймворки), разверните UIKit.framework (она тут есть, потому что она была изначально добавлена в проект автором) и там найдите файл UIApplication.h:


Как использовать уведомления


Перейдите к AlbumView.m и вставьте следующий код в initWithFrame:albumCover: после [self addSubview:indicator];

[[NSNotificationCenter defaultCenter] postNotificationName:@"BLDownloadImageNotification"
                                                    object:self
                                                  userInfo:@{@"coverUrl":albumCover,
                                                             @"imageView":coverImage}];

Эта строка отправляет уведомление через синглтон NSNotificationCenter. Уведомление содержит URL изображения, которое нужно загрузить, и UIImageView, в которое нужно поместить это изображение. Вот и всё, что нужно знать нашему «подписчику», который получит это уведомление, чтобы выполнить задачу загрузки.

Добавьте следующую строку в LibraryAPI.m в метод init, сразу после isOnline = NO:

[[NSNotificationCenter defaultCenter] addObserver:self
                                         selector:@selector(downloadImage:)
                                             name:@"BLDownloadImageNotification"
                                           object:nil];

Это вторая часть уравнения — наблюдатель (подписчик). Каждый раз, когда класс AlbumView отправляет уведомление "BLDownloadImageNotification", т.к. LibraryAPI зарегистрировалась в качестве наблюдателя на это уведомление, система уведомит LibraryAPI. В свою очередь, LibraryAPI выполняет downloadImage:.

В названии уведомления префикс «BL» = сокращение от названия проекта BlueLibrary для облегчения рефакторинга.

Но прежде чем мы перейдём к наблюдателю, ещё одна маленькая деталь: нам нужно отписаться от уведомления, когда экземпляр нашего класса освобождает память. Если этого не сделать, система может отправить уведомление объекту, который уже был высвобожден из памяти (deallocated), что может привести к падению приложения.

Если бы Почта России была написана на Objective C
Если человек умирает, не отписавшись от газеты, то это приводит к аварийному завершению всей Почты России.

Добавьте следующий метод в LibraryAPI.m:

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

Когда объект класса выгружается из памяти, он отписывается от всех уведомлений, на которые он был подписан.

И ещё one more thing. Было бы неплохо сохранить загруженные обложки локально, чтобы не пришлось их скачивать снова и снова.

Откройте PersistencyManager.h и добавьте два прототипа методов:

- (void)saveImage:(UIImage *)image filename:(NSString *)filename;
- (UIImage *)getImage:(NSString *)filename;

И вот их имплементация в PersistencyManager.m:

- (void)saveImage:(UIImage *)image filename:(NSString *)filename
{
    filename = [NSHomeDirectory() stringByAppendingFormat:@"/Documents/%@", filename];
    NSData * data = UIImagePNGRepresentation(image);
    [data writeToFile:filename atomically:YES];
}

- (UIImage *)getImage:(NSString *)filename
{
    filename = [NSHomeDirectory() stringByAppendingFormat:@"/Documents/%@", filename];
    NSData * data = [NSData dataWithContentsOfFile:filename];
    return [UIImage imageWithData:data];
}

Вроде, всё понятно. Загруженные изображения сохранены в папку Documents/ внутри папки приложения. getImage: вернёт nil, если файл не найден. (Это плохой стиль, советую сделать нормальную проверку на существование файла, а не пытаться инициализировать UIImage данными, полученными из несуществующего файла. — Прим. пер.)

Теперь добавьте вот такой метод в LibraryAPI.m:

- (void)downloadImage:(NSNotification *)notification
{
    // 1
    NSString * coverUrl = notification.userInfo[@"coverUrl"];
    UIImageView * imageView = notification.userInfo[@"imageView"];

    // 2
    imageView.image = [persistencyManager getImage:[coverUrl lastPathComponent]];

    if (imageView.image == nil)
    {
    	// 3
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            UIImage * image = [httpClient downloadImage:coverUrl];

            // 4
            dispatch_sync(dispatch_get_main_queue(), ^{
                imageView.image = image;
                [persistencyManager saveImage:image filename:[coverUrl lastPathComponent]];
            });
        });
    }
}

Вот что делает этот код:

  1. downloadImage вызывается из уведомления и получает объект объект NSNotification в качестве параметра. Из этого параметра извлекаются URL изображения и указатель на UIImageView.
  2. Получаем и отображаем изображение из PersistencyManager, если оно было загружено ранее.
  3. Если изображение ещё не было загружено, получаем его с помощью HTTPClient.
  4. Когда загрузка будет завершена, отобразим изображение в UIImageView и сохраним его локально.

Мы снова используем паттерн «Фасад», чтобы скрыть от других классов сложный процесс скачивания изображения. Отправителю уведомления неинтересно, откуда взято изображение: из интернета или с файловой системы.

Постройте и запустите приложение, и увидите красивые обложки в нашем HorizontalScroller:



Апдейт юзерам iOS 9 и выше: как, опять не увидели картинок?
Ну тут такое дело. Хотя я и поменял протокол на HTTPS, эппл не устраивает амазоновский сертификат. Поэтому ставим официальный костыль и радуемся ждём, пока его запретят.

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

Но вы заметили баг: «ромашка» не перестаёт крутиться! В чём дело?

Всё просто: мы запустили индикатор при загрузке изображения, но ещё не реализовали логику, которая уберёт индикатор, когда изображение загружено. Мы могли бы отправлять уведомление каждый раз, когда изображение загружено. Но давайте сделаем это более красивым способом: KVO.

Key-Value Observing (KVO)


От переводчика: по-русски не нашёл этой фразы. Смысл: наблюдение за парами «ключ-значение».

При использовании подхода KVO объект может попросить, чтобы его уведомляли об изменениях конкретного свойства — его собственного или свойства другого объекта. Этот подход подробно описан в KVO Programming Guide от Apple.

Как использовать паттерн KVO


KVO позволяет объекту наблюдать за изменением свойства. Здесь мы будем использовать KVO, чтобы наблюдать за изменением свойства image объекта класса UIImageView.

Откройте AlbumView.m и добавьте следующий код в метод initWithFrame:albumCover: сразу после строки [self addSubview:indicator];

[coverImage addObserver:self forKeyPath:@"image" options:0 context:nil];

Мы добавили self (сам объект класса AlbumView) в качестве наблюдателя за свойством image объекта coverImage.
И нужно «сложить полномочия» наблюдателя, когда мы закончим, в AlbumView.m перед @end:

- (void)dealloc
{
    [coverImage removeObserver:self forKeyPath:@"image"];
}

Наконец, добавьте следующий метод:

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if ([keyPath isEqualToString:@"image"])
    {
        [indicator stopAnimating];
    }
}

Необходимо реализовать этот метод в каждом классе, который будет наблюдателем. Система выполняет этот метод, когда «наблюдаемое» свойство изменяется. В приведенном выше коде мы останавливаем индикатор, когда меняется свойство image. То есть, именно в тот момент, когда изображение загрузится.

(Тоже плохая расширяемость, я бы добавил проверку параметра change, в котором есть информация об изменённом свойстве. В частности, там есть старое и новое значение свойства. Если старое было nil, а новое содержит картинку => останавливаем ромашку. — Прим. пер.)

Запустите приложение. Индикатор должен исчезнуть:



Примечание. Всегда удаляйте наблюдателей в методе dealloc. Иначе ваше приложение упадёт, когда попытается отправить сообщение несуществующему наблюдателю!

Если вы немного поиграетесь с вашим приложением и закроете его, а потом снова запустите, то обратите внимание: состояние приложения (последний выбранный альбом) не восстанавливается при запуске. Конечно, с чего бы? :)

Чтобы исправить эту «оплошность», мы переходим к следующему паттерну по списку:

Хранитель (Memento)


«Хранитель» фиксирует состояние объекта и сохраняет его… где-то. Дальше это сохранённое состояние может быть восстановлено без нарушения инкапсуляции, т.е. закрытые (private) члены класса остаются закрытыми.

Как использовать шаблон Memento


Добавьте следующие два метода в ViewController.m:

- (void)saveCurrentState
{
    // Когда пользователь закрывает приложение и потом снова к нам приходит,
    // он хочет увидеть то же состояние, на котором он закончил. Для этого
    // нужно сохранить текущий выбранный альбом. Здесь немного информации,
    // поэтому можно использовать NSUserDefaults:
    [[NSUserDefaults standardUserDefaults] setInteger:currentAlbumIndex
                                               forKey:@"currentAlbumIndex"];
}

- (void)loadPreviousState
{
    currentAlbumIndex = [[NSUserDefaults standardUserDefaults] integerForKey:@"currentAlbumIndex"];
    [self showDataForAlbumAtIndex:currentAlbumIndex];
}

Метод saveCurrentState сохраняет индекс текущего альбома. NSUserDefaults — стандартное хранилище настроек и данных приложения, предоставляемое iOS.

loadPreviousState загружает и восстанавливает индекс, сохранённый ранее. Это не совсем полная реализация паттерна «Хранитель», но мы к ней идём.

Теперь добавьте следующую строку в ViewController.m к методу viewDidLoad перед инициализацией скроллера:

[self loadPreviousState];

Этот вызов загружает ранее сохраненное состояние при запуске приложения. Как нам сохранить текущее состояние приложения? Используем для этого механизм уведомлений. iOS отправляет уведомление UIApplicationDidEnterBackgroundNotification, когда приложение уходит в фон. Когда нам придёт это уведомление, вызовем saveCurrentState. Удобно? Да.

Добавьте следующую строку в конец метода viewDidLoad:

[[NSNotificationCenter defaultCenter] addObserver:self
                                         selector:@selector(saveCurrentState)
                                             name:UIApplicationDidEnterBackgroundNotification
                                           object:nil];

Когда приложение соберётся уйти в фоновый режим, ViewController автоматически сохранит текущее состояние, вызвав saveCurrentState.

От переводчика: Этот туториал написан для iOS 6, где нельзя просто взять и закрыть приложение. Можно только отправить в фон, а затем закрыть. Но что если Apple разрешит сразу закрыть приложение? Тогда ваша логика сломается? Лучше думать об этом заранее, а не после выхода iOS 7. К счастью, в Купертино пожалели таких горе-разработчиков, которые ориентируются не на здравый смысл, а на текущий вариант юзабилити. Вопреки документации, iOS 7.0 отправляет уведомление …DidEnterBackground…, даже если вы не отправляете в фон, а сразу закрываете приложение. Но вообще, не следует ожидать от разработчиков ОС такой отзывчивости.

Сразу добавим следующий код:

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

Удалили объект ViewController из числа наблюдателей, прежде чем выгрузить его из памяти.

Снова запустите приложение, перейдите к одному из альбомов. Затем закройте приложение, дважды нажав кнопку Home (в симуляторе — ⌘⇧H) и удалите его из многозадачности. Запустите заново и проверьте, что выбран именно тот альбом, который был выбран до перезапуска:



Сведения об альбоме в TableView корректны, но в скроллере обложка выбрана другая. Почему?

А вот тут нам пригодится метод initialViewIndexForHorizontalScroller: — опциональный метод нашего протокола. Пока он просто не реализован в делегате (в данном случае ViewController делегат). Поэтому скроллер при старте устанавливается в ноль.

Чтобы исправить это, добавьте метод в ViewController.m:

- (NSInteger)initialViewIndexForHorizontalScroller:(HorizontalScroller *)scroller
{
    return currentAlbumIndex;
}

Теперь индекс скроллера (номер альбом, который будет по центру) будет установлен на currentAlbumIndex. Отличный способ сделать приложение более персонализированным.

Запустите приложение снова. Проблема решена!



Если вы найдёте в классе PersistencyManager метод init, то обратите внимание, что данные для создания объекта Album жестко прописаны в коде. То есть, этот код работает каждый раз при создании PersistencyManager. Но лучше создать список альбомов один раз и хранить их в файле. Как мы сохраним данные альбома в файл?

Один из вариантов — вручную перечислить все свойства альбома, сохранять их в файл plist, а затем создать из них экземпляры класса Album по необходимости. Это не лучший способ, потому что:
  1. Придётся перечислять список свойств прямо в коде. Если позже вы создадите класс Movie с другим набором свойств, для сохранения и загрузки этих данных потребуется новый код.
  2. Мы не сможем сохранить значения закрытых переменных, так как они недоступны для стороннего класса.

Вот почему Apple придумали[citation needed] механизм архивации.

Архивация


Одна из реализаций паттерна «Хранитель» от Apple — это архивация. Она превращает объект в поток, который можно сохранить и позже восстановить без нарушения инкапсуляции, т.е. не показывая закрытые свойства внешним классам. Для более подробной информации автор туториала отсылает нас к книге от команды Ray Wenderlich или опять же к документации Apple.

Как использовать архивацию


Сперва мы должны объявить, что класс Album можно архивировать. Для этого надо указать, что он реализует протокол NSCoding. Откройте Album.h и измените строчку @interface на эту:

@interface Album : NSObject <NSCoding>

Добавьте два метода в Album.m:

- (void)encodeWithCoder:(NSCoder *)aCoder
{
    [aCoder encodeObject:self.year forKey:@"year"];
    [aCoder encodeObject:self.title forKey:@"album"];
    [aCoder encodeObject:self.artist forKey:@"artist"];
    [aCoder encodeObject:self.coverUrl forKey:@"cover_url"];
    [aCoder encodeObject:self.genre forKey:@"genre"];
}

- (id)initWithCoder:(NSCoder *)aDecoder
{
    self = [super init];
    if (self)
    {
        _year = [aDecoder decodeObjectForKey:@"year"];
        _title = [aDecoder decodeObjectForKey:@"album"];
        _artist = [aDecoder decodeObjectForKey:@"artist"];
        _coverUrl = [aDecoder decodeObjectForKey:@"cover_url"];
        _genre = [aDecoder decodeObjectForKey:@"genre"];
    }
    return self;
}

Чтобы архивировать экземпляр класса, мы вызываем encodeWithCoder:. А метод initWithCoder: создаёт экземпляр альбома из архива. Простой, но мощный инструмент.

Теперь, когда класс Album можно архивировать, добавьте код, который будет запускать сохранение/загрузку списка альбомов.

Добавьте прототип метода в PersistencyManager.h:

- (void)saveAlbums;

И его реализацию в PersistencyManager.m:

- (void)saveAlbums
{
    NSString * filename = [NSHomeDirectory() stringByAppendingString:@"/Documents/albums.bin"];
    NSData * data = [NSKeyedArchiver archivedDataWithRootObject:albums];
    [data writeToFile:filename atomically:YES];
}

NSKeyedArchiver архивирует массив альбомов в файл "albums.bin".

Когда мы архивируем объект, который содержит другие объекты, архиватор рекурсивно архивирует дочерние объекты. У нас архивирование начинается с albums (массив объектов Album). Так как оба класса NSArray и Album поддерживают интерфейс NSCoding, всё содержимое массива рекурсивно архивируется.

Теперь в PersistencyManager.m замените метод init на следующий код:

- (id)init
{
    self = [super init];
    if (self)
    {
        NSData * data = [NSData dataWithContentsOfFile:[NSHomeDirectory() stringByAppendingString:@"/Documents/albums.bin"]];
        albums = [NSKeyedUnarchiver unarchiveObjectWithData:data];
        if (albums == nil)
        {
            albums = [NSMutableArray arrayWithArray:
                  @[[[Album alloc] initWithTitle:@"Best of Bowie"
                                          artist:@"David Bowie"
                                        coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_david_bowie_best_of_bowie.png"
                                            year:@"1992"],

                    [[Album alloc] initWithTitle:@"It's My Life"
                                          artist:@"No Doubt"
                                        coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_no_doubt_its_my_life_bathwater.png"
                                            year:@"2003"],

                    [[Album alloc] initWithTitle:@"Nothing Like The Sun"
                                          artist:@"Sting"
                                        coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_sting_nothing_like_the_sun.png"
                                            year:@"1999"],

                    [[Album alloc] initWithTitle:@"Staring at the Sun"
                                          artist:@"U2"
                                        coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_u2_staring_at_the_sun.png"
                                            year:@"2000"],

                    [[Album alloc] initWithTitle:@"American Pie"
                                          artist:@"Madonna"
                                        coverUrl:@"https://s3.amazonaws.com/CoverProject/album/album_madonna_american_pie.png"
                                            year:@"2000"]]];
            [self saveAlbums];
        }
    }
    return self;
}

Теперь NSKeyedUnarchiver загружает данные альбома из файла, если он существует. Если не существует, то мы создаём альбомы из тестовых данных. И сразу сохраняем, чтобы прочитать их при следующем запуске.

Также мы хотим сохранить данные альбома каждый раз, когда приложение уходит в фон. Вы можете сказать, что это не нужно сейчас, но что если вы захотите позже добавить возможность изменить данные альбома? Тогда нужно убедиться, что все изменения сохраняются.

Добавьте объявление нового метода в LibraryAPI.h:

- (void)saveAlbums;

Именно к LibraryAPI, как мы помним, приложение обращается за сервисами. Именно здесь приложение укажет PersistencyManager'у, что нужно сохранить данные альбома.

И реализация этого метода в LibraryAPI.m:

- (void)saveAlbums
{
    [persistencyManager saveAlbums];
}

Наконец, добавьте следующий код в ViewController.m в конец метода saveCurrentState:

[[LibraryAPI sharedInstance] saveAlbums];

Мы вызываем сохранение альбомов каждый раз, когда ViewController сохраняет своё состояние.

Выполните построение приложения (Build), чтобы проверить, что всё компилируется.

К сожалению, нет простого способа проверить корректность сохранённых данных. Вы можете найти папку Documents нашего приложения в симуляторе (либо на девайсе через iExplorer). Там мы увидим, что файл альбомов создаётся. Но чтобы увидеть любые другие изменения, нам придётся добавить соответствующий функционал по модификации данных.

Но поскольку у нас и так один из самых длинных переводов на хабре, давайте сделаем проще: добавим возможность удалить альбом для противников попсы. И заодно продемонстрируем возможность отменить действие, если вдруг мы удалили альбом по ошибке.

Для нас это отличная возможность поговорить о последнем паттерне в списке: Команда.

Команда


Команда — в смысле «приказ», Command, а не Team.

Паттерн «Команда» инкапсулирует запрос или действие в объект. Например, у нас есть команда «выполнить метод»:

Forrest->Run(speed, distance);

Из этой команды мы создаём объект с тремя свойствами:
  • Forrest;
  • Run;
  • Array(speed, distance).

Инкапсуляция делает запрос гораздо более гибким: его можно передавать между объектами, сохранить, модифицировать, помещать в очередь… жонглировать, в общем. Apple реализует этот паттерн с помощью механизма «цель-действие» (Target-Action) и вызова (Invocation).

Про Target-Action можно почитать в документации Apple. Для вызовов используется класс NSInvocation, который содержит целевой объект (которому будет отправлено сообщение), селектор сообщения и некоторые параметры. Этот объект может быть изменён динамически и, конечно же, выполнен при необходимости. Отличный пример паттерна «Команда», который даёт нам разделение кода (меньше связность => больше гибкость в определённых случаях). Отправитель сообщения отделяется от получателя, и запрос может пройти по цепочке.

Как использовать паттерн «Команда»


Перед тем, как погрузиться в механизм вызовов, мы подготовим возможность отмены действий. Определим UIToolbar и NSMutableArray для стека отмены действий (undo stack).

Добавьте следующий код в ViewController.m к расширению класса ViewController, где мы определили все остальные переменные:

UIToolbar * toolbar;
NSMutableArray * undoStack; // массив действий отмены, над которым мы будем выполнять операции push и pop

Это будет панель инструментов, проще говоря — тулбар с кнопками для действий. А также массив, который будет играть роль стека команд.

Добавьте следующий код в начало viewDidLoad: прямо перед комментарием "// 2":

toolbar = [[UIToolbar alloc] init];

UIBarButtonItem * undoItem = [[UIBarButtonItem alloc]
        initWithBarButtonSystemItem:UIBarButtonSystemItemUndo
                             target:self
                             action:@selector(undoAction)];
undoItem.enabled = NO;

UIBarButtonItem * space = [[UIBarButtonItem alloc]
        initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace
                             target:nil
                             action:nil];

UIBarButtonItem * delete = [[UIBarButtonItem alloc] 
        initWithBarButtonSystemItem:UIBarButtonSystemItemTrash
                             target:self
                             action:@selector(deleteAlbum)];

[toolbar setItems:@[undoItem, space, delete]];
[self.view addSubview:toolbar];

undoStack = [[NSMutableArray alloc] init];

Эти строки создают тулбар с двумя кнопками и гибкое пространство между ними (гибкое = подстраивается под ширину экрана). Кроме того, здесь инициализируется пустой стек действий. Кнопка Undo изначально отключена, т.к. стек пустой.

Заметим, что тулбар инициирован без frame, т.е. мы не определяем его границ здесь. Дело в том, что границы в viewDidLoad не являются окончательными. Мы найдём окончательные границы после того, как ViewController завершит «кадрирование». Добавьте следующий метод в ViewController.m:

- (void)viewWillLayoutSubviews
{
    toolbar.frame = CGRectMake(0, self.view.frame.size.height - 44, self.view.frame.size.width, 44);
    dataTable.frame = CGRectMake(0, 130, self.view.frame.size.width, self.view.frame.size.height - 200);
}

Далее мы добавим три метода в ViewController.m для обработки действия по управлению альбомами: добавление, удаление и отмена последнего действия.

Первый метод — добавление нового альбома:

- (void)addAlbum:(Album *)album atIndex:(int)index
{
    [[LibraryAPI sharedInstance] addAlbum:album atIndex:index];
    currentAlbumIndex = index;
    [self reloadScroller];
}

Здесь мы добавляем альбом, «переходим» к нему (устанавливаем текущий индекс) и обновляем скроллер.

Далее, удаление:

- (void)deleteAlbum
{
    // 1
    Album * deletedAlbum = allAlbums[currentAlbumIndex];

    // 2
    NSMethodSignature * sig = [self methodSignatureForSelector:@selector(addAlbum:atIndex:)];
    NSInvocation * undoDeleteAction = [NSInvocation invocationWithMethodSignature:sig];
    [undoDeleteAction setTarget:self];
    [undoDeleteAction setSelector:@selector(addAlbum:atIndex:)];
    [undoDeleteAction setArgument:&deletedAlbum atIndex:2];
    [undoDeleteAction setArgument:&currentAlbumIndex atIndex:3];
    [undoDeleteAction retainArguments];

    // 3
    [undoStack addObject:undoDeleteAction];

    // 4
    [[LibraryAPI sharedInstance] deleteAlbumAtIndex:currentAlbumIndex];
    [self reloadScroller];

    // 5
    [toolbar.items[0] setEnabled:YES];
}

Тут есть новые интересные функции, давайте рассмотрим этот код подробнее:

  1. Получаем указатель на альбом, который нужно удалить.
  2. Создаём сигнатуру метода (объект класса NSMethodSignature). И на основе сигнатуры создаём объект NSInvocation, который в дальнейшем будет использоваться для отмены операции удаления — undoDeleteAction.
    NSInvocation должен знать три вещи:
    • цель — объект, которому отправлять сообщение;
    • селектор — какое сообщение передавать;
    • аргументы сообщения.
    В нашем примере мы должны отправлять сообщение, обратное операции «удалить». А значит — добавить, вернуть удалённый альбом.
  3. Когда у нас создано undoDeleteAction, добавим его в воображаемый «стек»…
    Никогда так не делайте! Автор называет массив стеком. Типа, «человек, читающий код, должен представить, что массив — это стек»… Хотите стек? Должны быть две операции: push и pop. Всё. — Прим. пер.
  4. Удалите альбом из структуры данных с помощью LibraryAPI и обновите скроллер.
  5. У нас появилось действие, которое можно отменить, поэтому нам нужно разрешить нажатие кнопки Undo.

Примечание. Используя схему NSInvocation, нужно иметь в виду следующее:
  • Аргументы должны быть переданы по указателю.
  • Нумерация аргументов начинается с индекса 2. Индексы 0 и 1 зарезервированы для цели и селектора.
  • Если есть вероятность освобождения аргументов из памяти, следует вызвать retainArguments.

Наконец, добавьте метод для действия «отменить»:

- (void)undoAction
{
    if (undoStack.count > 0)
    {
        NSInvocation * undoAction = [undoStack lastObject];
        [undoStack removeLastObject];
        [undoAction invoke];
    }

    if (undoStack.count == 0)
    {
        [toolbar.items[0] setEnabled:NO];
    }
}

Здесь мы видим имитацию удаления элемента из стека в два метода: lastObject + removeLastObject. Конечно, ViewController не должен думать о таких вещах. Сделать нормальный стек — вот вам домашнее задание от переводчика данной статьи.

Мы достаём из стека крайний элемент, он всегда имеет тип NSInvocation и может быть вызван с помощью метода invoke. Тем самым, вызывается команда, которую мы создали выше (при удалении альбома) — а она возвращает удалённый альбом в список.

Элемент, полученный из стека, удаляется из оного. Поэтому нужно проверить, является ли стек пустым. Если он пустой, то у нас больше нет действий для отмены. В этом случае мы запрещаем нажатие кнопки Undo (она становится серой).

Запустите приложение, чтобы проверить, как работает наш ​​механизм отмены. Удалите один-два альбома и нажмите кнопку Undo, чтобы увидеть её действие:

Также хорошо бы проверить, сохраняются ли изменения в списке альбомов между запусками приложения. Удалите альбом, отправьте приложение в фон, а затем закройте его из многозадачности. Приложение сохранит список альбомов, вы должны это увидеть при следующем запуске.

Что дальше


В Objective C широко используются ещё два паттерна, которые не поместились в этот туториал: Абстрактная фабрика (Abstract Factory) и Цепочка ответственности (Chain of Responsibility). Обязательно изучите их и попробуйте применить в своих проектах.

Мы увидели, что дают паттерны: для сложных задач получаются простые решения. Мы узнали, что такое синглтон, MVC, делегирование, протокол, фасад, наблюдатель, хранитель и команда.

Ваш код слабо связан, его легко прочитать, легко использовать повторно. Если другой разработчик посмотрит на этот код, он быстро поймёт, что происходит, что делает каждый класс в нашем приложении.

Речь не о том, чтобы использовать паттерны проектирования в каждой строчке кода. Просто будьте в курсе паттернов, когда размышляете, как решить ту или иную проблему, особенно на ранних стадиях проектирования вашего приложения, и конечно, делая рефакторинг. Паттерны делают жизнь разработчика намного легче, а код — лучше!



От переводчика (вместо заключения)


Вспомним начало статьи: паттерны впервые описаны Кристофером Александером в книге «Язык паттернов». Паттерны — это язык, который объединяет две вещи:
  1. Опыт разработчиков;
  2. Язык, на котором мы говорим и пишем.

Надо понимать, что паттерны — это живой язык, и что нет предела совершенству. В последней главне часто встречается слово «стек», но в коде понятие «стек» никак не выделено. Что это — объект? свойство? алгоритм?

Допустим, здесь вы знаете ответ. Я к тому, что это знание должно быть выражено в коде, чтобы избавиться от фразы «представим, что NSMutableArray — это у нас стек» в документации к вашему будущему проекту. Код и документация должны отражать друг друга, а не дополнять («чего не видно из кода, вы найдёте в документации, и наоборот»).

Каждая сущность, каждая идея, о которой мы говорим, должна быть красиво обозначена в коде, чтобы посторонний человек, взглянув на код, быстро уловил суть. «Как устроен объект? Как он взаимодействует с другими объектами?» — простые и ёмкие ответы существуют именно в виде паттернов.

И ещё. Всегда смотрите на предупреждения от IDE. Например, в коде метода showDataForAlbumAtIndex: мы сделали проверку if (albumIndex < allAlbums.count), а почему не проверили (albumIndex >= 0)? Автор туториала об этом не подумал, а создатели класса NSArray подумали, поэтому AppCode сразу подсвечивает:



Заменив int на NSUInteger, получим безопасный код — без лишних проверок — «by design».

P.S.


Пишите на почту dev@x128.ru, если что-то непонятно.

Данная статья написана по мотивам документа Cocoa Design Patterns от Apple, который устарел прямо во время перевода статьи:



На смену этому документу пришло более простое описание Streamline Your App with Design Patterns, из которого выброшены все ссылки на «Gang of Four». Слоган «Start Developing Mac Apps Today» несовместим с какими-то книгами. Книгу за день не прочитаешь.

Тем важнее наша задача. У языка Objective C есть хороший шанс превратиться в очередной ****Script, на котором проще всего писать программы методом копипаста (и это при живых ООП-методиках). Чтобы этого не произошло, придётся потрудиться.

Что почитать?


Tags:
Hubs:
+65
Comments 50
Comments Comments 50

Articles