Модуляризация DI в проекте с UDF-архитектурой

от автора

Всем привет, меня зовут Юрий Трыков, я Head of Mobile в inDriver. В этой статье расскажу, как в рамках платформенной iOS-команды мы выстраивали модуляризацию DI-контейнеров в проекте, зачем вообще нам нужны DI-контейнеры и как настраивать взаимодействие UDF-компонентов и DI-контейнеров. Приятного чтения!

Содержание

Зачем нужны DI-контейнеры в больших проектах?

Одна из целей — реализация процесса внедрения зависимостей и принципа инверсии управления зависимостями. Она, в свою очередь, оказывает позитивное влияние на проект, уменьшая связанность между компонентами и модулями.

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

Модули могут иметь сложный граф. Чтобы собрать верхнеуровневую сущность feature-модуля (например, фасад), приходится использовать нескольких дочерних сервисов, которые могут иметь свои зависимости из core-модулей. Чтобы достать зависимость из feature-модуля, разработчику достаточно одной строчки.

Внедрения зависимостей (Dependency Injection):

— через конструктор;

— через функцию;

— через переменную.

Инверсия управления зависимостями (Dependency Inversion Principle — DIP):

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

— Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Выбор DI-контейнера в iOS

Сначала необходимо определиться с выбором фреймворка и критериями, удовлетворяющими целям проекта. В контексте платформенной команды крайне важно уменьшать TTM, не допускать дублирования кода и предоставлять удобные инструменты для разработчиков. Для себя мы определили следующие критерии:

  • Поддержка иерархии контейнеров и возможность разделить assembly.

  • Минимальное время получения зависимости при максимальном числе регистраций зависимостей в контейнере.

  • Thread safety.

DI-фреймворки:

Выбор фреймворка

Фундаментальную работу проделали коллеги из финтеха. Они протестировали все фреймворки, поэтому нам осталось лишь валидировать результаты и проанализировать соответствие критериям. После валидации критериев и результатов тестов scope фреймворков сузился до Swinject и Needle.  

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

Мы осознавали риски преждевременной оптимизации и сложность переключения подходов в долгосрочной перспективе для проекта и команд. А кроме того, понимали все плюсы и минусы различных подходов — Compile Time vs. Runtime, Service Locator vs. Reflection и так далее.

Кстати, напишите в комментариях, каким фреймворком пользуетесь (или не пользуетесь) и почему?

Модули и DI

Приступая к организации DI-контейнера в многомодульном проекте, нужно решить вопрос с доступностью core-модулей для feature-модулей.

Решения на примере Swinject

Для реализации можно смотреть в сторону паттерна composition root.

Давайте посмотрим, как это выглядит на практике. Для этого немного погрузимся в контекст Swinject:

Assembler — отвечает за управление инстансами Assembly и контейнером. Предоставляет доступ для зарегистрированных сущностей в Assembly через протокол Resolver.

Assembly — протокол, который предоставляет общий контейнер для регистрации сущностей. В контейнере находятся все сущности из каждого Assembly.

Создаем корневой Assember и добавляем туда все core-компоненты.

// Composition Root Assembler of application final class MainAssembler: NSObject {       // MARK: - Singleton    static let shared = MainAssembler()     // MARK: - Variables    private(set) var assembler: Assembler     private init(        _ assembler: Assembler = Assembler()    ) {        self.assembler = assembler    }     // MARK: - Functions     /// Function loading all assemblies (starts on start application)    func setup() {        let assemblies = getAssemblies()        assembler.apply(assemblies: assemblies)    }     /// All Assemblies of core dependencies    private func getAssemblies() -> [Assembly] {        return [            AnalyticsAssembly(),            ServiceComponentsAssembly(),            ViewComponentsAssembly()        ] /// Dependencies with other core dependencies            + MyModuleDI(parentAssembler: assembler).assemblies            + MonitoringAssembler(parentAssembler: assembler).assemblies     } }

Теперь в основном модуле все core-зависимости.

Создаем дочерний контейнер, куда передаем родительский. У Swinject доступно создание иерархии контейнеров. В конструкторе есть параметр для передачи родительского контейнера.

/// Child dependency public final class MyModuleDI {    // Child assembler    private var assembler: Assembler     // MARK: - Constructor    public init(        parentAssembler: Assembler    ) {        assembler = Assembler(            [                FooAssembly(),                SomeComponentAssembly()            ],            parent: parentAssembler        )    } }

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

import Monitoring import Swinject  /// Some assembly for child dependency class SomeComponentAssembly: Assembly {      public func assemble(container: Container) {        container.register(SomeProtocol.self) { res in             // Dependency from other module                                               let logger = res.resolve(Logger.self)!             return SomeComponent(logger)         }.inObjectScope(.transient)     } }

UDF и DI

Наш проект реализован на архитектуре UDF, о которой можно прочитать в статьях моего коллеги Антона Гончарова (раз, два, три, четыре). Напомню основные термины, а затем посмотрим, как работать с ней и DI-контейнерами.

Reducer — чистая функция(State, Action) -> State

Store — хранилище состояния. Основано на паттерне Observer (вроде горячих сигналов в RAC). C одной поправкой — нельзя менять состояние внутри, кроме как отправитьAction. Тогда стейт изменится редьюсерами, которые эти экшены обрабатывают, и после всем подписчикам придет новое состояние.

Connector — чистая функция (State) -> Props

Component — все экраны, сервисы и прочие подписчики store.

Зоны ответственности 

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

public final class MyModuleDI {     // DI Container     private let assembler: Assembler     // UDF store     private let store: Store<MyModuleState>     // Navigation coordinator     private var router: StrongRouter<MyRootRoute>      public init(store: Store<MyVerticalModuleState>, parentAssembler: Assembler) {         assembler = Assembler([MyServiceComponentsAsembly()], parent: parentAssembler)         self.store = store         router = MyVerticalCoordinatorFactory(store: store).makeRootCoordinator().strongRouter         connectComponents(to: store)     }      public func rootScreen(modulePayload: ICDeeplink) -> UIViewController {         return router.viewController     }      private func connectComponents(to store: Store<MyModuleState>) {         let serviceComponent = assembler.resolver.resolve(MyServiceComponent.self)         serviceComponent?.connect(to: store)         ...     } } 

Это относится как к корневому, так и к дочернему DI-assembler. В них подключаются компоненты к store. Жизненный цикл Service Component привязан к жизненному циклу модуля и приложения.

View Components

За создание и подключение UI-компонентов к стору отвечает SceneFactory — протокол фабрики для создания View Component.

При создании View Component’a нам требуется подключить его к store. Жизненный цикл View Component привязан к координатору. Приведу пример подключения к модулю:

extension MyVerticalSceneFactory: MySceneFactory {     func makeMyScene() -> UIViewController {         let scene = MyViewController()         scene.connect(to: store)         return scene     } }

View Component’ы подключаются к store через фабрики координаторов.

extension MyVerticalCoordinatorFactory: MyCoordinatorFactory {     func makeMyCoordinator() -> MyCoordinatorFactory {         let coordinator = MyCoordinator(             rootViewController: rootViewController,             sceneFactory: sceneFactory,             coordinatorFactory: self         )         coordinator.connect(to: store) { $0.myState.navigationStatus }         return coordinator     } }

Почему View Component не нуждается в DI-контейнере? Есть несколько причин:

  1. В UDF View Component конфигурируется через Props (аналог ViewModel), все необходимые параметры получаются через State и не имеют внешних зависимостей. Поэтому нецелесообразно класть его в DI.

  2. Если в координаторе использовать только получение зависимостей, зона ответственности подключения к store переходит в координатор. И там придется использовать методы создания и подключения View Component.

  3. View Component’ы, как и любые другие, убираются в DI-контейнер. Вопросы начинаются в store — каждый компонент должен быть подписан на него. Чтобы сделать это во время регистрации store должен быть в DI-контейнере.

Заключение

В конце приведу небольшие чекпоинты для модуляризации DI-контейнеров в проекте:

  • Для использования DI-контейнера в модульном проекте необходима поддержка иерархии со стороны DI-контейнера.

  • Обращайте внимание на масштаб проекта и метрики DI-контейнеров

  • В UDF DI начинает забирать больше ответственности в связи с подключением к store.

На этом все. Спасибо, что читали. Задавайте ваши вопросы в комментариях.


ссылка на оригинал статьи https://habr.com/ru/company/indriver/blog/648791/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *