Управляем зависимостями в iOS-приложениях правильно: Модульность Typhoon

1637a2b14a37403c9e7f2714a3950701.jpg

В предыдущей статье цикла мы кратко рассмотрели основные принципы устройства и функционирования Typhoon Framework — Dependency Injection контейнера для iOS. Тем не менее, мало понимать, как устроен инструмент — важнее всего правильно его использовать. В первой части мы рассматривали различные примеры настройки конфигураций создаваемых зависимостей, то теперь разберемся с более высоким уровнем — разбитием на модули самих TyphoonAssembly и их тестированием.

Цикл «Управляем зависимостями в iOS-приложениях правильно»


Зачем нужна модульность


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

  • Список городов, для которых есть погодные данные,
  • Информация о погоде в выбранном городе,
  • Добавление новой географической точки,
  • Настройки приложения.


9e9341a2ff7548988a0e1a21161a12b5.png

Тут появляются первые четыре метода, отдающие definition«ы для этих экранов.

Счетчик методов: 4


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

  • Управление списком городов (получение коллекции городов, добавление/удаление/изменение любого из них),
  • Получение погодных данных (получение информации о погоде для выбранного города),
  • Работы с геолокацией (получение текущей геолокации пользователя, истории его перемещений),
  • Обработка push-уведомлений (регистрация устройства, изменение настроек подписки),
  • Получение данных о приложении (справка, лицензии, версионность).


Счетчик методов: 4 + 5 = 9


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

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

  • Взаимодействие со своим сервером,
  • Работа с API сервиса погоды 1,
  • Работа с API сервиса погоды 2,
  • Работа с API сервиса погоды n,
  • Работа с базой данных.


Счетчик методов: 9 + n + 2 = 11 + n


Кроме клиентов, каждому сетевому сервису нужны маппер (отвечает за преобразование сырых данных, полученных от сервера, к модельным объектам) и валидатор (отвечает за проверку полученных данных).

Счетчик методов: 11 + n + 2 * (n + 1) = 13 + 3n


Не забудем еще несколько замечательных хелперов:

  • Логирование,
  • Работа с networkActivityIndicator,
  • Мониторинг состояния соединения


Счетчик методов: 13 + 3n + 3 = 16 + 3n


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

  • Аниматор состояния экрана,
  • DataSource (не важно, для таблицы или любой другой view),
  • Объект, инкапсулирующий логику, связанную со скроллингом,
  • Роутер, реализующий навигацию между экранами,
  • Обработчик ошибок, связанных с текущим экраном.


Счетчик методов: 16 + 3n + 4×5 = 36 + 3n


В этой формуле n — количество погодных сервисов, данные которых нужны для корректной работы приложения. Мы хотим использовать Gismeteo, Яндекс.Погоду и Yahoo.Weather.

Счетчик методов: 36 + 3×3 = 45


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

  • Огромный размер класса TyphoonAssembly — его будет очень сложно проанализировать и поддерживать в чистом состоянии.
  • Размытие ответственности — создание объектов абсолютно разных и не связанных друг с другом слоев абстракции происходит в одном и том же месте.
  • Отсутствие структурированности — при необходимости нельзя предоставить различные реализации логически связанных между собой definition«ов.


Чтобы избежать таких проблем, TyphoonAssembly делится на модули, связанные между собой как вертикально, так и горизонтально. Как я рассказывал в прошлой части, на самом деле, при активации нескольких assembly под капотом у них создается одна общая TyphoonComponentFactory, содержащая в себе регистр всех definition’ов и пулы созданных объектов. Такая архитектура фреймворка позволяет нам спокойно декомпозировать TyphoonAssembly, являющуюся в общем виде прокси для доступа к внутренней фабрике.

Подведем итоги. Модульность уровня TyphoonAssembly нужна, чтобы:

  • Группировать связанные между собой зависимости, строя тем самым четкую архитектуру,
  • Поддерживать интерфейс каждой из assembly в чистом состоянии и объявлять только методы, нужные другим компонентам,
  • Предоставлять при необходимости другую реализацию любого из выделенных компонентов (скажем, уровня клиентов),
  • Дать возможность постороннему человеку составить представление об архитектуре всего проекта лишь по структуре модулей TyphoonAssembly.

Разбитие на модули


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

@interface RCTWeatherViewController: UIViewController
@interface RCTWeatherViewController : UIViewController

// Сервисы
@property (strong, nonatomic) id  cityService;
@property (strong, nonatomic) id  weatherService;
@property (strong, nonatomic) id  locationService;

// Аниматоры
@property (strong, nonatomic) id  cloudAnimator;

@end


Исходя из этого, нам нужно построить модель взаимодействия между тремя разными assembly — создающими контроллеры, сервисы и аниматоры соответственно. «Ага!» — думаете вы. — «Сейчас этот парень начнет инжектить один модуль в другой, да и вообще писать фабрику фабрик!» Зря торопитесь! Одна из очередных чудесных возможностей Typhoon заключается в том, что для взаимодействия модулей не требуется никакой дополнительной инъекции. Достаточно лишь в интерфейсе TyphoonAssembly указать другие модули, которые требуются ей для работы — и можно спокойно использовать их публичные методы:

@interface RCTWeatherUserStoryAssembly: TyphoonAssembly
@interface RCTWeatherUserStoryAssembly : TyphoonAssembly

@property (strong, nonatomic, readonly) RCTAnimatorAssembly *animatorAssembly;
@property (strong, nonatomic, readonly) TyphoonAssembly* serviceAssembly;

- (UIViewController *)weatherViewController;

@end


И вот так будет выглядеть сам метод для TyphoonDefinition’а:

— (UIViewController *)weatherViewController
- (UIViewController *)weatherViewController {
    return [TyphoonDefinition withClass:[RCTWeatherViewController class]
                        configuration:^(TyphoonDefinition *definition) {
                                [definition injectProperty:@selector(cityService)
                                                        with:[self.serviceAssembly cityService]];
                                [definition injectProperty:@selector(weatherService)
                                                        with:[self.serviceAssembly weatherService]];
                                [definition injectProperty:@selector(locationService)
                                                        with:[self.serviceAssembly locationService]];
                             
                                [definition injectProperty:@selector(cloudAnimator)
                                                        with:[self.animatorAssembly cloudAnimator]];
                        }];
}


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

[definition injectProperty:@selector(cityService)];


В таком случае Typhoon берет тип свойства (класс или протокол) и ищет подходящий definition по всем assembly в проекте. Тем не менее, не рекомендую использовать такой подход — гораздо очевиднее как для прочих участников проекта, так и для вас в будущем, будет напрямую указывать происхождение зависимости. Кроме того, это позволит избежать появления неявных связей между assembly разных слоев абстракции.

Как я уже упоминал, модули могут быть связаны между собой как горизонтально (к примеру, assembly для разных user story), так и вертикально (RCTWeatherUserStoryAssembly и RCTServiceAssembly из примера выше). Для того, чтобы построить грамотную архитектуру TyphoonAssembly, нужно строго придерживаться следующих правил:

  • Модули на одном уровне абстракции ничего не знают друг о друге,
  • Модули нижнего уровня ничего не знают о модулях верхнего уровня.

Самый базовый пример разбития модулей TyphoonAssembly на слои:
f8fcb0ccb77c47b39ec54ac20b694efa.png

Каждый из слоев внутри может состоять из любого количества assembly. К примеру, зависимости из Presentation Level имеет смысл разбить по нескольким user story.

Рассмотрим более сложный случай разбития TyphoonAssembly на модули на примере, как обычно, Рамблер.Почты.

Структура Assembly в Рамблер.Почте


В общем виде все модули разбиты по трем стандартным слоям — Presentation, Business Logic и Core.
fb7792f0f1cf4bafbe085a4d4c1c19c1.png

Пробежимся по всем Assembly:

  • RCMApplicationAssembly. Отвечает за создание объектов уровня приложения — AppDelegate, PushNotificationCenter, ApplicationConfigurator и прочих. Зависит от двух других модулей — helperAssembly и serviceComponents.
  • RCMUserStoryAssembly. Каждой storyboard в Рамблер.Почте соответствует своя Assembly, отнаследованная от RCMUserStoryAssemblyBase. В таких модулях содержатся definition’ы для ViewController’ов, роутеров, аниматоров, view-моделей и прочих зависимостей, уникальных для данной user story. Зависит от helperAssembly, parentAssembly и serviceComponents.
  • RCMSettingsUserStoryAssemblyBase/RCMSettingsUserStoryAssemblyDebug. В зависимости от выбранной build scheme (Release/Debug) предоставляют различные реализации зависимостей экрана настроек (подробнее об этом расскажу чуть позже). Такой подход позволяет легко добавить в настройки приложения специальный функционал для тестировщиков, который будет отсутствовать в сборке для App Store.
  • RCMParentAssembly. Содержит базовые definition’ы для контроллеров, роутеров и обработчиков ошибок. Не умеет порождать ни один настоящий инстанс (все помечены как abstract). Ни от кого не зависит.
  • RCMHelperAssembly. Содержит определения для различных хелперов слоя Presentation, требуемых для компонентов нескольких UserStoryAssembly. Ни от кого не зависит.
  • RCMServiceComponentsAssemblyBase. Содержит в себе definition’ы всех сервисов приложения. Зависит от двух модулей уровня ядра — clientAssembly и coreComponentsAssembly.
  • RCMServiceComponentsAssemblyDouble. В отличие от базовой assembly, возвращает фейковые реализации всех сервисов, для работы которых не требуется взаимодействие с интернетом. Ни от кого не зависит.
  • RCMClientAssembly. Отвечает за создание различных клиентов. Ни от кого не зависит.
  • RCMCoreComponentsAssembly. Отвечает за создание вспомогательных компонентов, необходимых для работы сервисов. Ни от кого не зависит.


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

Конечно, все эти модули необходимо активировать одновременно — лучше всего сделать это, добавив соответствующие ключи в Info.plist, но можно и вручную, реализовав специальный метод в AppDelegate (подробности во второй части цикла).

Подмена реализаций Assembly


Рассмотрим две типичные ситуации:

  • Мы собираемся начать работать над приложением, но серверная команда еще не успела выкатить API.
  • На разработку достаточно крупного проекта ставится несколько разработчиков — часть из них планирует заниматься UI, а другая — бизнес-логикой, причем в отрыве друг от друга.


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

Пишется протокол, определяющий, какие методы должен реализовывать текущий модуль, и две его реализации — базовая, которая будет наполняться по мере готовности API и сервисного слоя, и фейковая, содержащая в себе самые элементарные имплементации сервисов, работающих, к примеру, с in-memory базой данных.

Посмотрим, как реализовать это с помощью механизма препроцессинга файла Info.plist:

  1. Создаем два файла — BaseHeader.h и DoubleHeader.h:
    BaseHeader:
    #define SERVICE_COMPONENTS_ASSEMBLY RCMServiceComponentsAssemblyBase
    

    DoubleHeader:
    #define SERVICE_COMPONENTS_ASSEMBLY RCMServiceComponentsAssemblyDouble
    
  2. В файле Info.plist, в разделе TyphoonInitialAssemblies вместо конкретного класса RCMServiceComponentsAssemblyBase указываем только что заданную директиву — SERVICE_COMPONENTS_ASSEMBLY.
  3. В Build Settings проекта, в разделе Packaging ищем ключ Info.plist Preprocessor Prefix File, и для каждой из используемых билд-схем задаем соответствующий header-файл. К примеру, Release — BaseHeader.h, Debug — DoubleHeader.h.

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

В некоторых случаях не нужно заменять все объекты фейковыми реализациями, а требуется подменить всего одну зависимость. В таком случае поможет использование категории TyphoonDefinition+Option, подробнее о которой я расскажу в следующей статье.

Тестирование модулей TyphoonAssembly


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

В первую очередь необходимо определиться с тем, что нужно тестировать:

  • Метод активированной TyphoonAssembly создает объект нужного класса,
  • Созданный объект содержит в себе все необходимые зависимости,
  • Все зависимости созданного объекта реализуют требуемый протокол/класс.


Для уменьшения количества boilerplate-кода я подготовил базовый XCTestCase, упрощающий тестирование TyphoonAssembly:

@interface RCMAssemblyTestsBase: XCTestCase
@interface RCMAssemblyTestsBase : XCTestCase

/**
 *  @author Egor Tolstoy
 *
 *  Метод позволяет протестировать создаваемый Assembly объект, в который не инжектились никакие зависимости
 *
 *  Пример:
 *  - (id )storyboardBuilder {
 *      return [TyphoonDefinition withClass:[RCMStoryboardBuilderBase class]];
 *  }
 *
 *  @param targetDependency Создаваемая зависимость
 *  @param targetClass          Класс, на соответствие которому мы хотим проверить зависимость
 */
- (void)testTargetDependency:(id)targetDependency
                withClass:(Class)targetClass;

/**
 *  @author Egor Tolstoy
 *
 *  Метод позволяет протестировать создаваемый Assembly объект, в который инжектились зависимости
 *
 *  Пример:
 *  return [TyphoonDefinition withClass:[RCMContactsMapperBase class]
 *                      configuration:^(TyphoonDefinition *definition) {
 *      [definition injectProperty:@selector(emailValidator)
 *                              with:[self mapperEmailValidator]];
 *  }];
 *
 *  @param targetDependency Создаваемая зависимость
 *  @param targetClass          Класс, на соответствие которому мы хотим проверить зависимость
 *  @param dependencies         Массив селекторов геттеров инжектируемых property
 */
- (void)testTargetDependency:(id)targetDependency
                withClass:(Class)targetClass
                dependencies:(NSArray *)dependencies;

/**
 *  @author Egor Tolstoy
 *
 *  Метод позволяет протестировать создаваемый Assembly объект, зависимости, которые были в него проинжекчены, и их класс/протокол
 *
 *  @param targetDependency     Создаваемая зависимость
 *  @param targetClass          Класс, на соответствие которому мы хотим проверить зависимость
 *  @param dependenciesAndTypes Словарь, ключи в котором - селекторы геттеров инжектируемых property, значения - Class/Protocol. Если для одной из зависимостей класс/протокол проверять не нужно, сда отдается [NSNull class].
 */
- (void)testTargetDependency:(id)targetDependency
                withClass:(Class)targetClass
        dependenciesAndTypes:(NSDictionary *)dependenciesAndTypes;

@end


Вот так выглядит тест одного из методов Assembly:

— (void)testThatAssemblyCreatesApplicationBadgeHandler
- (void)setUp {
    [super setUp];
   
    self.applicationAssembly = [[RCMApplicationAssembly alloc] init];
    [self.applicationAssembly activateWithCollaboratingAssemblies:@[[RCMHelperAssembly new], [RCMServiceComponentsAssemblyBase new], [RCMCoreComponentsAssembly new], [RCMClientAssembly new]]];
}


- (void)testThatAssemblyCreatesApplicationBadgeHandler {
    // given
    Class targetClass = [RCMApplicationBadgeHandlerBase class];
    NSDictionary *dependenciesAndTypes = @{
                                           NSStringFromSelector(@selector(folderService)) : @protocol(RCMFolderService)
                                           };
   
    // when
    id result = [self.applicationAssembly applicationBadgeHandler];
    // then
    [self testTargetDependency:result withClass:targetClass dependenciesAndTypes:dependenciesAndTypes];
}


В случае Assembly, тестировать нужно не только публичные методы, но и приватные — так как мы должны проверить, насколько корректно создаются все зависимости в приложении. Будьте готовы создавать extension _Testable для каждого из выделенных модулей.

Должен признать, что даже в таком виде покрытие всех методов Assembly тестами — достаточно трудоемкое и утомительное дело, поэтому откладывать его на последний этап разработки не стоит. Достаточно просто выработать привычку добавлять новый testcase при появлении еще одного definition’а в TyphoonAssembly.

Заключение


В этой статье мы узнали, зачем нужно разбивать одну большую Assembly на несколько меньших модулей, и как это делать правильно. Приведенная схема разбития всех модулей на слои — Presentation, Business Logic и Core, в том или ином виде может быть использована практически в любом проекте. Кроме того, при грамотном построении структуры модулей становится возможным подменить любой из них фейковой реализацией, которая может быть использована в качестве заглушки в процессе работ над базовой версией.

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

После прочтения этой части цикла вы уже полностью готовы интегрировать Typhoon Framework в свой проект, вне зависимости от процента его завершенности. В следующей статье мы рассмотрим различные Tips n' Tricks в использовании Typhoon: Autowire, TyphoonConfig, TyphoonPatcher, особенности работы с несколькими UIStoryboard и многое другое.

Цикл «Управляем зависимостями в iOS-приложениях правильно»


Полезные ссылки


© Habrahabr.ru