Модуляризация 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
// 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-контейнере? Есть несколько причин:
В UDF View Component конфигурируется через Props (аналог ViewModel), все необходимые параметры получаются через State и не имеют внешних зависимостей. Поэтому нецелесообразно класть его в DI.
Если в координаторе использовать только получение зависимостей, зона ответственности подключения к store переходит в координатор. И там придется использовать методы создания и подключения View Component.
View Component’ы, как и любые другие, убираются в DI-контейнер. Вопросы начинаются в store — каждый компонент должен быть подписан на него. Чтобы сделать это во время регистрации store должен быть в DI-контейнере.
Заключение
В конце приведу небольшие чекпоинты для модуляризации DI-контейнеров в проекте:
Для использования DI-контейнера в модульном проекте необходима поддержка иерархии со стороны DI-контейнера.
Обращайте внимание на масштаб проекта и метрики DI-контейнеров
В UDF DI начинает забирать больше ответственности в связи с подключением к store.
На этом все. Спасибо, что читали. Задавайте ваши вопросы в комментариях.