
Привет, читатель! Меня зовут Александр, я техлид iOS в KTS.
В серии статей я поделюсь своим представлением о DI и попробую решить основную проблему библиотечных решений для DI: нам нужно точно знать, что экран соберётся, зависимости подтянутся, а все ошибки мы отловим на этапе компиляции.
Чтобы копнуть вглубь и собрать большинство возможных подводных камней, нужен большой проект со множеством экранов, в идеале разбитый на модули. Предлагаю вам пройти этот путь совместно и написать проект вместе со мной.
Я планирую серию статей, где мы шаг за шагом нарастим массу кодовой базы и рассмотрим такие проблемы как циклические зависимости, жизненные циклы, ленивая загрузка и декомпозиция контейнера.
Если вы готовы, погнали! ?
Для начала давайте составим примерный план того что должно получиться во всей серии. Пока что я детально понимаю, что будет на старте и чуть менее — что будет в конце, имейте в виду.
Содержание статьи №1
Что будет в остальных статьях:
Жизненный цикл зависимостей и как им управлять.
Декомпозиция контейнера, циклические зависимости.
Ленивая инициализация.
Попробуем избавиться от бойлер-плейт кода при создании объектов.
Модуляризация проекта.
Если по ходу написания статей появятся другие идеи или запросы от комьюнити в комментариях, то можно и переобуться на ходу. Люблю гибкость в таких вопросах, так что не стесняйтесь писать ?
Для начала синхронизируемся на тему понимания принципа Dependency Injection. Начнем с терминологии.
Dependency Injection
?Зависимость для конкретного объекта — это, в моём понимании, любая внешняя сущность, помогающая этому объекту выполнять свои обязанности.
Например, у нас есть два экрана: список чего-либо и детальное отображение: ListViewController и DetailsViewController. Скажем, что ListViewController ходит на бэкенд за данными, и ему в этом помогает некий ItemService. Это зависимость.
Кроме того, чтобы не попасть в ловушку Massive View Controller, мы вынесли бизнес-логику в ListPresenter. Это тоже зависимость. Если мы открываем DetailsViewController, передавая ему id элемента со списка, то id — это тоже зависимость. Итак...
?Dependency Injection — паттерн, который предлагает все зависимости внедрять снаружи, а не инициализировать их внутри самого объекта.
Для этого есть разные способы, но мы сегодня сосредоточимся на одном: внедрение через конструктор в методе init(). Для чего это нужно? Для независимости. Забавная игра слов, верно? Независимость объекта от своих зависимостей: если они приходят снаружи, то мы в любой момент можем их подменить чем-то другим. Например, моками в тестах. Или реализацией с красной кнопкой вместо синей в А/Б тесте.
Типы зависимостей
1️⃣
Данные, которые доступны только в runtime.
Пример: id элемента из списка. Какой бы способ поставления зависимостей мы не выбрали — с DI или без — мы поставляем их снаружи, потому что внутри их нет и появиться они не могут.
2️⃣
То, что мы можем создать в момент, когда инициализируем объект.
Пример: ListPresenter. Скажем, у нас архитектура VIPER и есть ListAssembly, который собирает весь VIPER-модуль. Сущности этого модуля можно создать одновременно и проставить связи между ними, потому что они все должны жить одновременно, и только одновременно.
3️⃣
Зависимости, про которые объект не должен знать много. Ради них и появился паттерн Dependency container.
Пример: ItemService. ListAssembly может заявить, что ей нужен ItemService, но она может не знать, как создавать его самостоятельно. Потому что зависимости ItemService — например HttpClient или Repository для доступа к базе данных — вне зоны ответственности конкретного экрана.
Если мы договорились, то пойдем дальше и познакомимся с проектом.
Проект
Пока у меня всего 2 экрана. Вы уже догадались? List и Details.
Пока непринципиально, что именно за сущности в этом списке, и что за данные на экране детального отображения. Важно, что мы действительно получаем данные с помощью ItemService, который берёт их из базы данных. Бэкенд я пока не подключал. Ну и разумеется, я запрыгиваю в hype train и пишу это все на SwiftUI. Вам может показаться, что в SwiftUI я далеко не спец, но думаю, что к концу серии статей я стану магистром. Посмотрим ☝️?
Архитектура моего проекта — MVVM. С роутерами. Не спрашивайте, я просто хотел в красках показать, что иногда зависимости должны создаваться вместе с созданием объекта и не придумал ничего лучше. Со временем, думаю, выберу что-то более адекватное.
Итак, вот как это сейчас выглядит:
-- MyApp.swift -- Model |-- Item.swift -- Persistence |-- PersistenceController.swift -- Service |-- ItemService.swift -- Views |-- List |-- ListAssembly.swift |-- ListViewModel.swift |-- ListView.swift |-- ListRouter.swift |-- Details |-- DetailsAssembly.swift |-- DetailsViewModel.swift |-- DetailsView.swift
Быстренько про ответственности сущностей:
Assembly— сборщик экрана. Её задача — вернутьView, чтобы кто-то снаружи мог её отобразить. Попутно собирает все зависимости дляView.ViewModelхранит состояние, принимает события отView, обновляет состояние.View— что тут скажешь, вьюха.Router— немного вырожденная сущность в контексте SwiftUI, но я пока не до конца понимаю, как красиво делать навигацию. В этом проекте он нужен для иллюстрации работы с контейнером. Отвечает за получениеViewдля отображения следующего экрана.
И немного кода:
@MainActor final class ListAssembly { func view() -> ListView { let service = ItemService(persistence: PersistenceController.shared) let router = ListRouterImpl() let viewModel = ListViewModel(service: service, router: router) return ListView(viewModel: viewModel) } }
Как видите, DI у меня уже есть: ListView в конструкторе получает свою зависимость, а ListViewModel в конструкторе получает свои. Для контекста — код ListView, ListViewModel и ListRouter:
struct ListView: View { @ObservedObject private var viewModel: ListViewModel init(viewModel: ListViewModel) { self.viewModel = viewModel } var body: some View { NavigationView { List { ForEach(viewModel.items) { item in NavigationLink { viewModel.detailView(by: item.id) } label: { Text(item.text) } } } } }
Простой экран с табличкой. Обратите внимание на то, как взаимодействуют View и ViewModel:
@MainActor final class ListViewModel: ObservableObject { struct Item: Identifiable { let id: Int let text: String } @Published var items: [Item] = [] private let service: ListService private let router: ListRouter init(service: ListService, router: ListRouter) { self.service = service self.router = router Task { do { items = try await service.items().enumerated().map { index, item in Item(id: index, text: itemFormatter.string(from: item.timestamp)) } } catch { print("No items") } } } func detailsView(by id: Int) -> DetailsView? { guard let item = items.first(where: { $0.id == id }) else { return nil } return router.detailsView(with: item.text) } }
ViewModel умеет делать три вещи: получать данные из ItemService, сохранять их в массив items и возвращать экземпляр DetailsView с помощью обращения к ListRouter:
@MainActor protocol ListRouter { func detailsView(with text: String) -> DetailsView } @MainActor final class ListRouterImpl: ObservableObject {} extension ListRouterImpl: ListRouter { func detailsView(with text: String) -> DetailsView { return DetailsAssembly(text: text).view() } }
ListRouter пока создает экземпляр DetailsAssembly самостоятельно.
А вот как открывается самый первый экран:
@main struct MyApp: App { var body: some Scene { WindowGroup { ListAssembly().view() } } }
Главная проблема этого кода — Assembly конкретного экрана создает экземпляр сервиса.
Ведь если сейчас DetailsViewModel понадобится грузить данные для детального отображения с помощью ItemService (что довольно-таки вероятно), то в DetailsAssembly будет та же самая строчка:
let service = ItemService(persistence: PersistenceController.shared)
И это я еще молчу про то, что приходится использовать синглтон PresistenceController, чтобы в обоих экземплярах ItemService был один и тот же экземпляр PresistenceController. А если представить, что мы добавим новый слой абстракции между сервисом и базой — ItemRepository? Теперь в двух местах придется написать вот так:
let repository = ItemRepository(persistence: PersistenceController.shared) let service = ItemService(repository: repository)
А теперь представим, что у нас не 2 экрана, а 10. А если уровней абстракции больше?
Я предлагаю решать эту проблему с помощью Dependency container.
?Dependency container — сущность, которая будет поставлять внешние зависимости. Контейнер решает, нужно ли создавать новый экземпляр и когда, хранить сильные или слабые ссылки. В нём инкапсулирована вся логика по предоставлению зависимостей.
Теперь Assembly будут получать на вход данные, которые появляются только в runtime (зависимости первого типа из начала статьи), создавать экземпляры View, ViewModel и Router (зависимости второго типа) и запрашивать у контейнера сущности, про создание которых экран знать не должен, к примеру сервисы (зависимости третьего типа).
Поскольку контейнер у нас будет пока один — AppContainer — то для него зависимостей третьего типа не будет. Он будет знать обо всех сущностях. Соответственно, контейнер будет получать на вход зависимости первого типа, а остальные — создавать. И первой его задачей будет создать ListAssembly, чтобы вынести ее создание из MyApp.swift:
@MainActor final class AppContainer: ObservableObject { func makeListAssembly() -> ListAssembly { ListAssembly() } }
Довольно простой код. Теперь нужно научить ListAssembly запрашивать ItemService у AppContainer.
Будем идти в обратном направлении: сначала запрашивать зависимости, а потом реализовывать их поставку, чтобы убедиться, что компилятор помогает нам на каждом этапе.
И ещё важный момент. Я не хочу, чтобы ListAssembly мог запросить у AppContainer что-то лишнее, поэтому мы спрячем его за протокол.
protocol ListContainer { func makeItemService() -> ItemService } @MainActor final class ListAssembly { private let container: ListContainer init(container: ListContainer) { self.container = container } func view() -> ListView { let service = container.makeListService() let router = ListRouterImpl() let viewModel = ListViewModel(service: service, router: router) return ListView(viewModel: viewModel) } }
Красота. Ничего не билдится, компилятор говорит, что мы не передали в конструктор ListAssembly новую зависимость — ListContainer. Исправляем:
func makeListAssembly() -> ListAssembly { ListAssembly(container: self) }
Теперь компилятор ругается, что AppContainer не соответствует протоколу ListContainer. Исправляем:
extension AppContainer: ListContainer { func makeItemService() -> ItemService { ItemServiceImpl(persistence: PersistenceController.shared) } }
Но стоп! Теперь у нас есть контейнер и не нужно использовать синглтон PersistenceController. Пусть экземпляр PersistenceController тоже хранится в AppContainer:
@MainActor final class AppContainer: ObservableObject { private let persistenceController = PersistenceController() func makeListAssembly() -> ListAssembly { ListAssembly() } } extension AppContainer: ListContainer { func makeItemService() -> ItemService { ItemServiceImpl(persistence: persistenceController) } }
Красотища! Все работает. Теперь нужно избавиться от явного создания DetailsAssembly в роутере списка:
@MainActor final class ListRouterImpl: ObservableObject { private let container: ListContainer init(container: ListContainer) { self.container = container } } extension ListRouterImpl: ListRouter { func detailsView(with text: String) -> DetailsView { return container.makeDetailsAssembly(text: text).view() } }
Снова ругается компилятор. Чиним:
protocol ListContainer { func makeItemService() -> ItemService func makeDetailsAssembly(text: String) -> DetailsAssembly } @MainActor final class ListAssembly { private let container: ListContainer init(container: ListContainer) { self.container = container } func view() -> ListView { let service = container.makeListService() let router = ListRouterImpl(container: container) let viewModel = ListViewModel(service: service, router: router) return ListView(viewModel: viewModel) } }
extension AppContainer: ListContainer { func makeItemService() -> ItemService { ItemServiceImpl(persistence: persistenceController) } func makeDetailsAssembly(text: String) -> DetailsAssembly { DetailsAssembly(container: self, text: text) } }
Пока что DetailsAssembly зависит от ListContainer, но я поправлю это в будущих статьях этой серии, когда будем рассматривать декомпозицию контейнера. Главное, что мы получили — возможность модифицировать способ создания экземпляра ItemService всего в одном месте, хотя используется он во многих.
Итоги части 1
Мы установили общий контекст для статей: что такое зависимости, какие они бывают и что такое Dependency Injection. Эти определения — моя интерпретация для общего понимания будущих материалов
На примере увидели опасность создания зависимостей третьего типа прямо перед созданием объекта, от них зависящего
Воспользовались паттерном Dependency container, который спрятали за протокол и научились запрашивать зависимости третьего типа
В следующей статье я продолжу развивать контейнер. Посмотрим, чему его нужно будет научить на проекте побольше.
Stay tuned! ??
UPD Читайте вторую часть статьи — Жизненные циклы
Другие наши статьи по iOS-разработке:
