Привет, читатель! Меня зовут Александр, я техлид 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! ????????
Orakull
Спасибо за SwiftUI :) Не понял только, зачем в роутерах, ассемблях и т.д. писать '@MainActor' и 'ObservableObject'?