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

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

87f5b0a8093ed08ad0fb16937636b10c.pngСодержание

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

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

7bd390c275aa1888265b6caf4ad6d7d1.jpg

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

49d86a2cd156c60f57d2248de21d2db1.png

Давайте посмотрим, как это выглядит на практике. Для этого немного погрузимся в контекст 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
    // Navigation coordinator
    private var router: StrongRouter

    public init(store: Store, 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) {
        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.

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

© Habrahabr.ru