Управляем зависимостями в iOS-приложениях правильно: Модульность Typhoon
В предыдущей статье цикла мы кратко рассмотрели основные принципы устройства и функционирования Typhoon Framework — Dependency Injection контейнера для iOS. Тем не менее, мало понимать, как устроен инструмент — важнее всего правильно его использовать. В первой части мы рассматривали различные примеры настройки конфигураций создаваемых зависимостей, то теперь разберемся с более высоким уровнем — разбитием на модули самих TyphoonAssembly и их тестированием.
Цикл «Управляем зависимостями в iOS-приложениях правильно»
Зачем нужна модульность
Возьмем в качестве примера достаточно простое приложение — клиент для погодного сервиса (к слову, одно из демо-приложений). Он состоит из следующих экранов:
- Список городов, для которых есть погодные данные,
- Информация о погоде в выбранном городе,
- Добавление новой географической точки,
- Настройки приложения.
Тут появляются первые четыре метода, отдающие 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
// Сервисы
@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
@property (strong, nonatomic, readonly) RCTAnimatorAssembly *animatorAssembly;
@property (strong, nonatomic, readonly) TyphoonAssembly* serviceAssembly;
- (UIViewController *)weatherViewController;
@end
И вот так будет выглядеть сам метод для TyphoonDefinition’а:
- (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 на слои:
Каждый из слоев внутри может состоять из любого количества assembly. К примеру, зависимости из Presentation Level имеет смысл разбить по нескольким user story.
Рассмотрим более сложный случай разбития TyphoonAssembly на модули на примере, как обычно, Рамблер.Почты.
Структура Assembly в Рамблер.Почте
В общем виде все модули разбиты по трем стандартным слоям — Presentation, Business Logic и Core.
Пробежимся по всем 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:
- Создаем два файла — BaseHeader.h и DoubleHeader.h:
BaseHeader:#define SERVICE_COMPONENTS_ASSEMBLY RCMServiceComponentsAssemblyBase
DoubleHeader:#define SERVICE_COMPONENTS_ASSEMBLY RCMServiceComponentsAssemblyDouble
- В файле Info.plist, в разделе TyphoonInitialAssemblies вместо конкретного класса RCMServiceComponentsAssemblyBase указываем только что заданную директиву — SERVICE_COMPONENTS_ASSEMBLY.
- В 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
/**
* @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)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-приложениях правильно»
Полезные ссылки