Всем привет! На связи Яша Штеффен — iOS разработчик из hh.ru.
Думаю, любой, кто долгое время писал код для платформы, был свидетелем эволюции работы с зависимостями. На ранних этапах и в простых проектах все создавалось и настраивалось внутри использующих зависимости объектов. Затем частично начал применяться принцип инъекции, обычно через публичное свойство. На более крупных проектах можно было столкнуться с использованием DI‑библиотек, которые зачастую тащили за собой очень крупный блок плохо читаемого кода. При этом для решения проблемы чрезмерной связанности кода существуют элегантные и простые в использовании решения, которые не подразумевают использование сторонних библиотек.
В статье мы рассмотрим основы DI, поговорим о том, какую проблему решает этот принцип, окинем общим взглядом возможные варианты реализации: паттерны и популярные библиотеки. Подробно рассмотрим схему, по которой работает DI в многомодульном iOS проекте hh.ru. В конце статьи будет разобран пример простого приложения с аналогичным подходом к инъекции.
Какую проблему решает DI
Начнем с основ. Что такое DI? DI — это Dependency Injection. В переводе — инъекция зависимостей. Когда читаешь название, легко представить что‑то в духе:
По сути все так и есть, но для понимания стоит разобраться, что же такое зависимость.
Зависимость для конкретного объекта — это любая внешняя сущность, помогающая этому объекту выполнять свои обязанности.
Рассмотрим пример: объект класса ResumeScreenViewModel может управлять объектом класса APIProvider
class ResumeScreenViewModel {
var name: String?
var position: String?
let apiProvider: APIProvider
init() {
apiProvider = APIProvider()
apiProvider.setup()
}
func getResume(id: String) {
apiProvider.requestResume(resumeId: id) { resume in
name = resume.name
position ...
}
}
...
Как именно ResumeScreenViewModel управляет провайдером? У этого процесса есть несколько аспектов:
Вьюмодель создает провайдер
Настраивает его
Вызывает методы провайдера и пользуется полученными данными
Все это приводит к высокой связанности объектов, вьюмодель не может работать без провайдера. Это и есть зависимость, ResumeScreenViewModel полностью зависима от APIProvider.
Здесь, конечно, можно собрать волю в кулак и сказать:
Но нужно понимать, что при длительной разработке проекта с таким подходом начнут возникать проблемы. Бизнес‑требования могут меняться, и мы начнем переписывать код, добавляя или убирая зависимости. При исправлении багов мы можем менять поведение зависимостей, что влечет за собой изменение поведения зависящих объектов. Вероятный редизайн также добавит сложности.
Как итог, можем получить ситуацию, в которой мы буквально тонем в зависимостях. Компоненты могут начать зависеть друг от друга, образуя паутину циклических зависимостей. Изменять классы становится крайне сложно, а заменить один класс на другой так и вовсе нереально. Вызов конструктора и настройка объекта могут быть раскиданы по различным методам, которые тоже придется изменять.
В конце концов, код превращается в обычный текст, вносить изменения в который можно лишь вооружившись поиском.
Чтобы не допустить такого исхода событий, придумано множество принципов и техник. Например, один из принципов SOLID: DIP — принцип инверсии зависимостей описывает, как уменьшить связанность при вызове методов через инверсию управления.
Что такое DI и инверсия зависимостей?
DI — это частный случай инверсии управления, паттерн предоставления внешней зависимости программному компоненту. Объект отдаёт заботу о построении требуемых ему зависимостей внешнему, специально предназначенному для этого общему механизму. Все зависимости внедряются снаружи, а не инициализируются внутри самого объекта.
Если снова рассмотреть наш пример с ResumeScreenViewModel, то в самой простейшей реализации DI мы будем передавать провайдер в конструктор модели:
class ResumeScreenViewModel {
var name: String?
var position: String?
let apiProvider: APIProvider
init(apiProvider: APIProvider) {
self.apiProvider = apiProvider
}
func getResume(id: String) {
apiProvider.requestResume(resumeId: id) { resume in
name = resume.name
position ...
}
}
}
В примере выше мы внедряем сетевой провайдер через инит (конструктор). Это не единственный способ инъекции, хоть он и является предпочтительным. Есть и другие:
Interface injection (через интерфейс). Требует создания дополнительного протокола для инъекции в каждый объект, что может быть избыточным.
Property injection (через публичное свойство). Помимо нарушения инкапсуляции, с таким подходом возрастает риск непредсказуемого поведения объекта, который может быть использован до передачи нужных зависимостей.
Method injection (через публичный метод). Применяется реже, чем инъекция через публичное свойство, но имеет те же недостатки.
Пример создания вьюмодели из нашего приложения:
let viewModel = ExpertListViewModel(
router: router,
mentorMarketplaceService: services.mentorMarketplaceService(),
professionalRoleProvider: services.professionalRoleProvider(),
selectedCareerGoalID: selectedCareerGoalID,
mentorServiceID: mentorServiceID,
categoryIDs: categoryIDs,
paginationManager: services.paginationManager(),
mentorServicesConfigProvider: services.featureConfigProvider()
)
Вьюмодель в активно разрабатываемом проекте может использовать целую пачку зависимостей, которые мы передаем в конструкторе. Можно представить объем кода, который остался бы внутри класса, если бы сервисы не передавались извне, а создавались внутри модели.
Здесь важно понимать, что если в интерфейсе вьюмодели мы будем использовать реализации напрямую, то это нарушит принцип инверсии управления, ведь модель станет зависима от конкретных реализаций сервисов. В такой схеме любое изменение сервиса может привести к непредсказуемым изменениям поведения вьюмодели, ведь мы не зафиксировали контракт. Поэтому реализации сервисов следует скрывать за протоколами.
Ниже на схеме можно увидеть два подхода: в первом случае объект А обращается к методам и полям объекта Б напрямую, что приводит к созависимости. Во втором случае контракт зафиксирован в протоколе А, объект Б, в свою очередь, только наследует его. При желании объект Б легко может быть заменен на любой другой класс или структуру.
Какие преимущества от использования DI в проекте?
Зависимости объекта становятся явными, четко прописанными в его интерфейсе — это позволяет лучше контролировать его сложность. Например, если в конструктор передается 20 разных сервисов и какая‑нибудь вьюмодель использует их все, то явно стоит задуматься о том, чтобы вынести какую‑то часть логики в другие объекты.
Зависимости становятся внешними, передаются в объект извне, а не создаются внутри. Это позволяет отделять код создания объектов от бизнес‑логики, улучшая разделение ответственности.
DI дает возможность сделать зависимости гибкими. Mожно подменить объект другим — например, если закрыть его протоколом, скрыв реализацию. Код классов теперь зависит только от интерфейсов, а не от конкретных классов, скрывая детали реализации и уменьшая связанность кода. Как следствие — объекты становятся легко тестируемыми.
Уменьшается связанность объектов.
Упрощается переиспользование объектов.
Улучшается декомпозиция за счет выноса порождающей зависимости логики наружу.
Стоит отметить и недостатки подхода:
Увеличивается количество сущностей в проекте. Создаются дополнительные классы с порождающей логикой и протоколы, скрывающие детали реализации зависимостей.
Как следствие первого недостатка, возрастает время написания кода.
Примеры реализации: библиотеки и паттерны
Если с необходимостью применения DI все, в целом, очевидно, то остается вопрос: как именно этот подход применять на практике?
При поиске информации про DI на iOS часто можно увидеть упоминание такого шаблона, как Service Locator. Это паттерн, суть которого заключается в наличии объекта‑реестра, к которому обращаются объекты для получения зависимостей. Объект‑реестр знает, как получить все зависимости, которые могут потребоваться.
Пример реализации ServiceLocator через синглтон:
protocol ServiceLocating {
func register<T>(service: T)
func resolve<T>() -> T?
}
final class ServiceLocator: ServiceLocating {
// Доступ к хранилищу через синглтон
static let shared = ServiceLocator()
private lazy var services = [String: Any]()
private init() {}
private func typeName(_ some: Any) -> String {
return (some is Any.Type) ? "\(some)" : "\(type(of: some))"
}
// Регистрация зависимости
func register<T>(service: T) {
let key = typeName(T.self)
services[key] = service
}
// Получение ранее зарегистрированной зависимости
func resolve<T>() -> T? {
let key = typeName(T.self)
return services[key] as? T
}
}
Внутри класса находится словарь, ключом в котором является строка, содержащая имя типа, а значение — это объект, который мы регистрируем в локаторе. Чтобы получить зависимость, нужно сначала проинициализировать и зарегистрировать ее. Чаще всего зависимости регистрируются в одном месте на старте приложения.
Допустим, мы создавали сервис в функции и вызывали его методы:
func someFunc() {
let service = MyService()
let info = service.getInfo()
// другие обращения к методам и полям сервиса
}
Тот же метод с использованием сервис-локатора:
// Где-то на старте приложения
ServiceLocator.shared.register(service: service)
...
func someFunc() {
guard let service: ServiceProtocol = ServiceLocator.shared.resolve() else {
return
}
let info = service.getInfo()
// другие обращения к методам и полям сервиса
}
Плюсы сервис-локатора:
Удобно и просто использовать в коде.
Избавляет от необходимости использовать сервисы‑синглтоны. Однажды созданный и зарегистрированный сервис хранится в локаторе в единственном экземпляре.
Удобно тестировать: можно подменить зависимости при регистрации на моки.
Минусы:
Сервис‑локатор часто является синглтоном. Использование синглтонов часто приводит к трудностям из‑за неявной и при этом жесткой связи с объектами, которые используют синглтон. Тут же получаем глобальное состояние и его неявное изменение из разных точек кода.
Способствует созданию внутренних зависимостей, что приводит к неявной связанности.
Если резолвить зависимость, которая не была зарегистрирована, то мы узнаем об этом только в рантайме.
В целом, стоит сказать, что чаще всего шаблон ServiceLocator — это не совсем DI, ведь при таком подходе зависимости конкретного класса остаются неявными. Где‑то внутри класса может быть вызван любой из зарегистрированных сервисов, а при подходе с DI каждый сервис передается отдельно. Можно оставить передачу сервисов в объект через конструктор и пользоваться локатором вне класса на слое сборки, но такая доработка делает всю схему очень близкой к DI на фабриках, которая будет описана ниже.
Готовые библиотеки
На GitHub доступно множество библиотек для DI на iOS. Хоть все эти библиотеки решают одну задачу, реализация и вытекающие из нее плюсы и минусы могут очень сильно отличаться. Кратко рассмотрим особенности самых популярных вариантов.
Swinject (~ 6k звезд на GitHub)
Плюсы:
Популярный: легко получится найти решение проблем при интеграции.
Относительно просто использовать.
Незначительно влияет на время компиляции.
Минусы:
Не compile-time safe.
Под капотом тот же словарь что и в Service Locator.
Слабая поддержка, редко выходят новые версии (раз в полгода).
Очень сильно замедлит время запуска в большом проекте.
Cleanse: (~ 2к звезд на GitHub)
Плюсы:
Проверенный подход, схожий с Dagger (Android) — те же авторы.
Незначительно влияет на время компиляции.
Минусы:
Не compile-time safe.
Порог вхождения: для использования нужно изучить базовые концепты фреймворка.
Сложности с постепенной интеграцией.
Не поддерживается (последний релиз в сентябре 2020).
Needle от Uber (~ 1.6k звезд на GitHub)
Плюсы:
Compile-time safe — на этапе компиляции явно показывает, чего не хватает и где.
Разруливает иерархию зависимостей.
Активно поддерживается.
Weaver (<1k звезд на GitHub)
Плюсы:
Потокобезопасность.
Встроенные механизмы для удобства тестирования.
Минусы:
Очень сильно вырастает время компиляции при большом количестве зависимостей.
Вынуждает использовать property injection.
Порог вхождения, использование через property wrapper или комменты.
Использование заставляет подстраиваться под нюансы библиотеки, сложно будет изменить подход в будущем.
Если хочется использовать стороннюю библиотеку для DI, Needle выглядит хорошим вариантом. Другие библиотеки не так актуальны и могут приводить к проблемам в проектах с активной поддержкой.
Как видно из сравнения библиотек, у всех вариантов достаточно как плюсов, так и минусов. Но для нашего проекта некоторые недостатки являются критическими:
Не можем принять риски использования библиотек, не гарантирующих корректную работу на этапе компиляции.
Не можем позволить себе использовать редко обновляемые библиотеки. Это увеличивает риски проблем при выходе новых версий iOS.
У Apple есть ограничение на время запуска: если приложение не успевает, то оно просто не запустится. А так как неизвестно, сколько зависимостей может появиться в приложении в будущем, то не подходит любая из библиотек, которая регистрирует зависимости на старте приложения.
Наш проект — многомодульный, генерируется через Tuist. Не подходят решения, у которых проблемы с таким подходом. Это может быть генерация кода зависимостей в один файл или условия, заставляющие делать все сервисы публичными. В таком случае сильно возрастает время компиляции при изменениях любого из сервисов.
В крупном проекте всегда приоритетнее вариант не добавлять дополнительную библиотеку, если это возможно.
О многомодульности в нашем проекте
Многомодульность в iOS проекте hh.ru реализуется с помощью очень крутой утилиты Tuist, которая позволяет генерировать workspace для XCode на основе специальных файлов с описанием проекта, которые пишутся на Swift. Тема работы с Tuist очень обширная, в нашем блоге уже выходили материалы, почитать можно тут, а посмотреть вот тут. Tuist позволяет забыть о конфликтах при изменении структуры проекта и упрощает работу с многомодульностью.
Сам проект при таком подходе очень похож на дом, собранный из различных модулей. Кодовая база для соискательского и работодательского приложений общая и поделена на фичи. Каждая фича — это изолированный функционал в виде отдельного Xcode‑проекта. Так же, как у модульного дома, фича может быть фундаментальной, а может быть фасадом, с которым пользователь взаимодействует.
Примеры фундаментальных фич: сетевой слой, модуль с компонентами дизайн‑системы. Пример продуктовой фичи: модуль, который позволяет пользователю взаимодействовать с резюме.
Так как в кодовой базе сразу два приложения, у каждого из них есть основной модуль, который является изначальной точкой входа при запуске.
После генерации проект состоит из множества таргетов, собранных в общий workspace. Любую фичу можно спокойно редактировать и, благодаря такой структуре, проект очень шустро собирается после изменений.
Как реализован DI?
Многомодульность накладывает еще одно условие на схему DI в проекте. Нам нужна возможность не просто создавать сервис и передавать его в рамках одного модуля, а еще и делиться кодом из одной части проекта в другую.
Вспомним модель, в которую мы хотим передавать сервис (APIProvider):
class ResumeScreenViewModel {
var name: String?
var position: String?
let apiProvider: APIProvider
init() {
apiProvider = APIProvider()
apiProvider.setup()
}
func getResume(id: String) {
apiProvider.requestResume(resumeId: id) { resume in
name = resume.name
position ...
}
}
}
Допустим, где‑то в модуле резюме есть класс, в который мы хотим красиво передать APIProvider по всем принципам DI. Мы не станем создавать провайдер в том же модуле, ведь это сервис, который будет использоваться не только при работе с резюме.
Логично написать общий сервис в фиче Core. Как теперь передать его в модуль резюме? Каждый модуль принимает внешние зависимости через публичные протоколы. Например, протокол для сервисов может выглядеть так:
public protocol ResumeServiceDeps: ServiceDeps {
func apiProvider() -> APIProvider
}
Этот протокол наследует базовый протокол ServiceDeps, которому соответствуют все протоколы зависимостей фичей:
public protocol ServiceDeps {
var serviceContainer: ServiceContainer { get }
}
Функция объявлена, но как получить и вызвать реализацию?
Точками сборки являются публичные протоколы фабрик, также наследующих ServiceDeps, например:
public protocol CoreServiceDeps: ServiceDeps { }
public protocol CoreServiceFactory: CoreServiceDeps { }
extension CoreServiceFactory {
public func apiProvider() -> APIProvider {
APIProviderImpl(
networkClient: networkClient()
)
}
}
Рассмотрим схему получения имплементации метода в модуле резюме:
В модуле Core в CoreServiceFactory объявлен метод, создающий экземпляр apiProvider. Сама фабрика CoreServiceFactory имплементит протокол ServiceDeps через CoreServiceDeps. Получаем дефолтную имплементацию метода apiProvider в протоколе CoreServiceFactory.
В модуле Resume объявлен публичный протокол фабрики, который также конформит ServiceDeps. Через конформ фабрики протокола ResumeServiceDeps в итоге объявлено, что фабрика должна предоставить метод, создающий apiProvider().
Код создания ServiceFactory вызывается на старте приложения в основном модуле. К фабрике добавляются пустые экстеншены, поэтому ServiceFactory конформит все протоколы зависимостей и получает все дефолтные имплементации методов из всех модулей. Экземпляр фабрики передается как аргумент при переходе к другим экранам.
В итоге экземпляр фабрики в модуле резюме будет иметь доступ к методу, создающему нужный нам сервис, так как на уровне, создающем фабрику, есть описание требуемого метода в протоколе и имплементация этого метода в расширении протокола.
Теперь можем просто обратиться к фабрике при создании нашей вьюмодели:
let resumeScreenViewModel = ResumeScreenViewModel(
apiProvider: resumeServiceFactory.apiProvider()
)
Демо-проект
Чтобы со схемой DI можно было поработать на практике, мы выложили на GitHub небольшой демо‑проект. После скачивания и прогона скрипта Tuist можно увидеть, что в проекте три модуля: основной, Chat и Core модуль.
Приложение состоит из нескольких экранов. На стартовом, который находится в основном модуле, есть таблица, по нажатию на элемент происходит открытие экрана чата. Основной экран и экран чата находятся в разных модулях.
Так выглядит модель экрана чата:
final class ChatViewModel {
let textProvider: TextProvider
let chatID: Int
var titleText: String {
textProvider.titleText(chatID: chatID)
}
var messageText: String {
textProvider.messageText()
}
init(
chatID: Int,
textProvider: TextProvider
) {
self.chatID = chatID
self.textProvider = textProvider
}
}
Вьюмодель экрана чата использует сервис TextProvider, чтобы получать строки для отображения в интерфейсе. Вместо TextProvider может быть какой угодно другой сервис, которому мы что‑то делегируем, чтобы отобразить информацию на экране для пользователя. TextProvider — это публичный протокол, объявленный в модуле Core:
public protocol TextProvider {
func titleText(chatID: Int) -> String
func messageText() -> String
}
В этом же модуле описан класс-имплементация. Класс не публичный, область видимости — только модуль Core.
final class TextProviderImpl {
init() { }
}
extension TextProviderImpl: TextProvider {
func messageText() -> String {
"Message by TextProvider"
}
func titleText(chatID: Int) -> String {
"Chat \(chatID)"
}
}
Через протоколы реализуем дефолтный метод для фабрики сервисов в Core:
public protocol CoreServiceDeps: ServiceDeps { }
public protocol CoreServiceFactory: CoreServiceDeps { }
extension CoreServiceFactory {
public func textProvider() -> TextProvider {
TextProviderImpl()
}
}
В протоколе зависимостей модуля чата описываем, что хотим воспользоваться TextProvider. Обратите внимание, здесь уже используется протокол и мы отделяем имплементацию, она осталась только в Core.
public protocol ChatServiceDeps: ServiceDeps {
func textProvider() -> TextProvider
}
В основном модуле описываем класс фабрики. Для целей демо проекта достаточно пустых имплементаций. В реальном приложении здесь будет различная логика для настройки фабрики, можно прикрутить к ней какой‑нибудь сервис‑локатор или считать что‑то нужное из базы данных.
final class ServiceFactory {
init() { }
}
extension ServiceFactory: CoreServiceFactory {}
extension ServiceFactory: ChatServiceFactory {}
Контроллер основного экрана выступает в роли сборщика (в крупном проекте слой сборки экранов лучше вынести отдельно). Здесь мы создаем экземпляр фабрики и передаем его в контроллер чата.
private let serviceFactory = ServiceFactory()
private func showChat(id: Int) {
navigationController?.pushViewController(
ChatViewController(
chatID: id,
serviceFactory: serviceFactory
),
animated: true
)
}
Контроллер чата собирает свою вьюмодель и передает ей нужный сервис из фабрики. Здесь мы немного нарушаем принцип DI для демо‑проекта. Такой код тоже стоит вынести в слой сборки. Затем контроллер использует вьюмодель, чтобы получить текст для отображения.
public init(
chatID: Int,
serviceFactory: ChatServiceFactory
) {
self.viewModel = ChatViewModel(
chatID: chatID,
textProvider: serviceFactory.textProvider()
)
title = viewModel.titleText
}
Переходим в чат и видим, что вьюмодель успешно получила данные из реализации сервиса из другого модуля.
Что имеем в итоге с такой реализацией:
Сервис закрыт публичным протоколом и передается во вьюмодель извне.
Просто добавлять новые сервисы и использовать их во всем проекте: строчка в протоколе Deps модуля, в котором хотим использовать сервис + метод в протоколе фабрики в том модуле, где сервис создается.
Проверка наличия имплементации необходимых сервисов на этапе компиляции. Если необходимый сервис не описан, или есть ошибка в сигнатуре создающего сервис метода, мы узнаем об этом на этапе сборки.
Нет необходимости делать публичным то, что не используется в других модулях. При этом все зависимости, как внешние, так и внутренние, забираются из одной фабрики при создании модуля.
По протоколу зависимостей легко понять, когда модуль слишком раздулся и стоит подумать о его разделении.
Не тащим дополнительную библиотеку в код.
Это просто красиво, расширения протоколов — мощный инструмент, при использовании которого иногда кажется, что код пишет себя сам.
К минусам можно отнести порог вхождения: когда видишь такую схему впервые, понять, как именно все работает, не так просто. Плюс для корректной работы необходимо полное совпадение сигнатур методов в протоколе и в фабрике, автокомплит XCode здесь не даст подсказку. При этом об ошибке мы узнаем до того, как запустим приложение, еще на этапе компиляции.
Итоги
В статье мы рассмотрели основы DI и определили, с решением какой проблемы помогает этот подход. Кратко были оценены популярные DI‑библиотеки. Была предложена схема построения DI в многомодульном проекте. Демо‑проект с аналогичной схемой находится в открытом доступе по ссылке.
Спасибо за внимание, надеюсь, информация в статье оказалась для вас полезной. Буду рад обратной связи в комментариях. Пока!