Пишем типизированный DI-контейнер для iOS приложения. Часть 1

от автора

Привет, читатель! Меня зовут Александр, я техлид 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-разработке:


ссылка на оригинал статьи https://habr.com/ru/articles/688664/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *