Всем привет, меня зовут Юрий Трыков, я 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) -> StateStore — хранилище состояния. Основано на паттерне Observer (вроде горячих сигналов в RAC). C одной поправкой — нельзя менять состояние внутри, кроме как отправить
Action. Тогда стейт изменится редьюсерами, которые эти экшены обрабатывают, и после всем подписчикам придет новое состояние.Connector — чистая функция
(State) -> PropsComponent — все экраны, сервисы и прочие подписчики 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-контейнере? Есть несколько причин:
-
В UDF View Component конфигурируется через Props (аналог ViewModel), все необходимые параметры получаются через State и не имеют внешних зависимостей. Поэтому нецелесообразно класть его в DI.
-
Если в координаторе использовать только получение зависимостей, зона ответственности подключения к store переходит в координатор. И там придется использовать методы создания и подключения View Component.
-
View Component’ы, как и любые другие, убираются в DI-контейнер. Вопросы начинаются в store — каждый компонент должен быть подписан на него. Чтобы сделать это во время регистрации store должен быть в DI-контейнере.
Заключение
В конце приведу небольшие чекпоинты для модуляризации DI-контейнеров в проекте:
-
Для использования DI-контейнера в модульном проекте необходима поддержка иерархии со стороны DI-контейнера.
-
Обращайте внимание на масштаб проекта и метрики DI-контейнеров
-
В UDF DI начинает забирать больше ответственности в связи с подключением к store.
На этом все. Спасибо, что читали. Задавайте ваши вопросы в комментариях.
ссылка на оригинал статьи https://habr.com/ru/company/indriver/blog/648791/
Добавить комментарий