company_banner

Управляем зависимостями в 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
    @interface RCTWeatherViewController : UIViewController
    
    // Сервисы
    @property (strong, nonatomic) id <RCTCityService> cityService;
    @property (strong, nonatomic) id <RCTWeatherService> weatherService;
    @property (strong, nonatomic) id <RCTLocationService> locationService;
    
    // Аниматоры
    @property (strong, nonatomic) id <RCTAnimator> cloudAnimator;
    
    @end

    Исходя из этого, нам нужно построить модель взаимодействия между тремя разными assembly — создающими контроллеры, сервисы и аниматоры соответственно. “Ага!” — думаете вы. — “Сейчас этот парень начнет инжектить один модуль в другой, да и вообще писать фабрику фабрик!” Зря торопитесь! Одна из очередных чудесных возможностей Typhoon заключается в том, что для взаимодействия модулей не требуется никакой дополнительной инъекции. Достаточно лишь в интерфейсе TyphoonAssembly указать другие модули, которые требуются ей для работы — и можно спокойно использовать их публичные методы:
    @interface RCTWeatherUserStoryAssembly : TyphoonAssembly
    @interface RCTWeatherUserStoryAssembly : TyphoonAssembly
    
    @property (strong, nonatomic, readonly) RCTAnimatorAssembly *animatorAssembly;
    @property (strong, nonatomic, readonly) TyphoonAssembly<RCTServiceAssembly>* 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 на слои:


    Каждый из слоев внутри может состоять из любого количества 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:
    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-файл. К примеру, ReleaseBaseHeader.h, DebugDoubleHeader.h.


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

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

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


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

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

    Для уменьшения количества boilerplate-кода я подготовил базовый XCTestCase, упрощающий тестирование TyphoonAssembly:
    @interface RCMAssemblyTestsBase : XCTestCase
    @interface RCMAssemblyTestsBase : XCTestCase
    
    /**
     *  @author Egor Tolstoy
     *
     *  Метод позволяет протестировать создаваемый Assembly объект, в который не инжектились никакие зависимости
     *
     *  Пример:
     *  - (id <RCMStoryboardBuilder>)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-приложениях правильно»



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


    Rambler&Co 75,13
    Компания
    Поделиться публикацией
    Комментарии 0

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

    Самое читаемое