Как стать автором
Обновить
149.93
KTS
Создаем цифровые продукты для бизнеса

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

Время на прочтение8 мин
Количество просмотров3.5K

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

Теги:
Хабы:
+11
Комментарии1

Публикации

Информация

Сайт
kts.tech
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия