Привет! Меня зовут Антон, я iOS-разработчик в Joom. Из этой статьи вы узнаете, как мы работаем с DI-фреймворком Needle, и реально ли он чем-то выгодно отличается от аналогичных решений и готов для использования в production-коде. Это всё — с замерами производительности, естественно.
Предыстория
Во времена, когда приложения для iOS еще писали полностью на Objective-C, существовало не так много DI-фреймворков, и стандартом по умолчанию среди них считался Typhoon. При всех своих очевидных плюсах, Typhoon приносил с собой и определённый overhead в runtime, что приводило к потере производительности в приложении.
На заре Joom мы попытались воспользоваться этим решением, но показанные им характеристики в тестовых замерах оказались не удовлетворительны для нас, и от него решили отказаться в пользу собственного решения. Это было так давно, что те времена из нашей нынешней iOS-команды застал всего один человек, и описанные события восстановлены по его воспоминаниям.
Потом на смену Objective-C пришел Swift, и все больше приложений стало переходить на этот новый язык. Ну а что же мы?
Пока все переходили на Swift, мы продолжали писать на Objective-C и пользовались самописным решением для DI. В нем было реализовано все то, что нам нужно было от инструмента для внедрения зависимостей: скорость и надежность.
Скорость обеспечивалась за счет того, что не надо было регистрировать никакие зависимости в runtime. Контейнер состоял из обычных property, которые могли при необходимости предоставляться в виде:
- обычного объекта, который создается при каждом обращении к зависимости;
- глобального синглтона;
- синглтона для определенного сочетания набора входных параметров.
При этом все дочерние контейнеры создавались через lazy property у родительских контейнеров. Другими словами, граф зависимостей у нас строился на этапе компиляции проекта, а не в runtime.
Надежность обеспечивалась за счет того, что все проверки проходили в compile time. Поэтому если где-то в header контейнера мы объявили зависимость и забыли реализовать ее создание в implementation, или у зависимости не находилось какое-либо свойство в месте обращения к ней, то об этом мы узнавали на этапе компиляции проекта.
Но у этого решения был один недостаток, который нам мешал жить.
Представьте, что у вас есть граф DI-контейнеров и вам надо из контейнера в одной ветке графа пронести зависимость в контейнер из другой ветки графа. При этом глубина веток запросто может достигать 5-6 уровней.
Вот список того, что нужно было сделать в нашем решении для проброса одной зависимости из родительского контейнера в дочерний:
- сделать forward declaration типа новой зависимости в .h-файле дочернего контейнера;
- объявить зависимость в качестве входного параметра конструктора в .h-файле дочернего контейнера;
- сделать #import header с типом зависимости в .m-файле дочернего контейнера;
- объявить зависимость в качестве входного параметра конструктора в .m-файле дочернего контейнера;
- объявить свойство в дочернем контейнере, куда мы положим эту зависимость.
Многовато, не правда ли? И это только для проброса на один уровень ниже.
Понятно, что половину этих действий требует сама идеология разбиения кода на заголовочные файлы и файлы с имплементацией в языках семейства Cи. Но это становилось головной болью разработчика и требовало от него по сути бездумного набора copy/paste действий, которые убивают любую мотивацию в процессе разработки.
В качестве альтернативы пробросу одной конкретной зависимости можно воспользоваться передачей всего контейнера с зависимостями. Это могло сработать, если было понимание, что в будущем из пробрасываемого контейнера могут понадобится и другие зависимости. И мы частенько так делали.
Но это неправильный путь. В таком случае один объект получает больше знаний, чем ему нужно для работы. Все мы проходили интервью, где рассказывали про принципы SOLID, заветы Дядюшки Боба, и вот это вот все, и знаем, что так делать не стоит. И мы достаточно долго жили только с этим решением и продолжали писать на Objective-C.
Возможно, вы помните нашу первую часть статьи о том, как писать на этом языке в 2018.
Вторую часть, как и второй том «Мертвых душ» Гоголя, миру уже не суждено увидеть.
В начале этого года мы приняли окончательное решение о переводе разработки новых фичей на Swift и постепенного избавления от наследия Objective-C.
В плане DI настало время еще раз посмотреть на имеющиеся решения.
Нам нужен был framework, который бы обладал теми же преимуществами, что и наше самописное решение на Objective-C. При этом бы не требовал написания большого объема boilerplate кода.
На данный момент существует множество DI framework-ов на Swift. Cамыми популярными на текущий момент можно назвать Swinject и Dip. Но у этих решений есть проблемы.
А именно:
- Граф зависимостей создается в runtime. Поэтому, если вы забыли зарегистрировать зависимость, то об этом вы узнаете благодаря падению, которое произойдет непосредственно во время работы приложения и обращения к зависимости.
- Регистрация зависимостей так же происходит в runtime, что увеличивает время запуска приложения.
- Для получения зависимости в этих решениях приходится пользоваться такими конструкциями языка, как force unwrap
!
(Swinject) илиtry!
(Dip) для получения зависимостей, что не делает ваш код лучше и надежнее.
Нас это не устраивало, и мы решили поискать альтернативные решения. К счастью, нам попался достаточно молодой DI framework под названием Needle.
Общая информация
Needle — это open-source решение от компании Uber, которое написано на Swift и существует с 2018 года (первый коммит — 7 мая 2018).
Главным преимуществом по словам разработчиков является обеспечение compile time safety кода работы для внедрения зависимостей.
Давайте разберемся как это все работает.
Needle состоит из двух основных частей: генератор кода и NeedleFoundation framework.
Генератор кода
Генератор кода нужен для парсинга DI кода вашего проекта и генерации на его основе графа зависимостей. Работает на базе SourceKit.
Во время работы генератор строит связи между контейнерами и проверяет доступность зависимостей. В результате его работы для каждого контейнера будет сгенерирован свой собственный DependencyProvider
, основным назначением которого является предоставление контейнеру зависимостей от других контейнеров. Более подробно про это мы поговорим чуть позже.
Но главное, что если какая-либо зависимость не найдена, то генератор выдаст ошибку с указанием контейнера и типом зависимости, которая не найдена.
Сам генератор поставляется в бинарном виде. Его можно получить двумя способами:
- Воспользоваться утилитой homebrew:
brew install needle
- Склонировать репозиторий проекта и найти его внутри:
git clone https://github.com/uber/needle.git & cd Generator/bin/needle
Для подключения в проект необходимо добавить Run Script
фазу, в которой достаточно указать путь до генератора и путь до файла, куда будет помещен сгенерированный код. Пример такой настройки:
export SOURCEKIT_LOGGING=0 && needle generate ../NeedleGenerated.swift
../NeedleGenerated.swift
— файл, в которой будет помещен весь генерированный код для построения графа зависимостей.
NeedleFoundation
NeedleFoundation — это фреймворк, который предоставляет разработчикам набор базовых классов и протоколов для создания контейнеров с зависимостями.
Устанавливается без проблем через один из менеджеров зависимостей. Пример добавления с помощью CocoaPods
:
pod 'NeedleFoundation'
Сам граф начинает строиться с создания root-контейнера, который должен быть наследником специального класса BootstrapComponent
.
Остальные контейнеры должны наследоваться от класса Component
.
Зависимости DI-контейнера описываются в протоколе, который наследуется от базового протокола зависимостей Dependency
и указывается в качестве generic type-а самого контейнера.
Вот пример такого контейнера с зависимостями:
protocol SomeUIDependency: Dependency {
var applicationURLHandler: ApplicationURLHandler { get }
var router: Router { get }
}
final class SomeUIComponent: Component<SomeDependency> {
...
}
Если зависимостей нет, то указывается специальный протокол <EmptyDependency>
.
Все DI-контейнеры содержат в себе lazy-свойства path
и name
:
// Component.swift
public lazy var path: [String] = {
let name = self.name
return parent.path + ["\(name)"]
}()
private lazy var name: String = {
let fullyQualifiedSelfName = String(describing: self)
let parts = fullyQualifiedSelfName.components(separatedBy: ".")
return parts.last ?? fullyQualifiedSelfName
}()
Эти свойства нужны для того, чтобы сформировать путь до DI-контейнера в графе.
Например, если у нас есть следующая иерархия контейнеров:
RootComponent->UIComponent->SupportUIComponent
,
то для SupportUIComponent
свойство path
будет содержать значение [RootComponent, UIComponent, SupportUIComponent]
.
Во время инициализации DI-контейнера в конструкторе извлекается DependencyProvider
из специального регистра, который представлен в виде специального singleton-объекта класса __DependencyProviderRegistry
:
// Component.swift
public init(parent: Scope) {
self.parent = parent
dependency = createDependencyProvider()
}
// ...
private func createDependencyProvider() -> DependencyType {
let provider = __DependencyProviderRegistry.instance.dependencyProvider(for: self)
if let dependency = provider as? DependencyType {
return dependency
} else {
// This case should never occur with properly generated Needle code.
// Needle's official generator should guarantee the correctness.
fatalError("Dependency provider factory for \(self) returned incorrect type. Should be of type \(String(describing: DependencyType.self)). Actual type is \(String(describing: dependency))")
}
}
Для того, чтобы найти нужный DependencyProvider
в __DependencyProviderRegistry
используется ранее описанное свойство контейнера path
. Все строки из этого массива соединяются и образуют итоговую строку, которая отражает путь до контейнера в графе. Далее от итоговой строки берется hash и по нему уже извлекается фабрика, которая и создает провайдер зависимостей:
// DependencyProviderRegistry.swift
func dependencyProvider(`for` component: Scope) -> AnyObject {
providerFactoryLock.lock()
defer {
providerFactoryLock.unlock()
}
let pathString = component.path.joined(separator: "->")
if let factory = providerFactories[pathString.hashValue] {
return factory(component)
} else {
// This case should never occur with properly generated Needle code.
// This is useful for Needle generator development only.
fatalError("Missing dependency provider factory for \(component.path)")
}
}
В итоге полученный DependencyProvider
записывается в свойство контейнера dependency
, с помощью которого во внешнем коде можно получить необходимую зависимость.
Пример обращения к зависимости:
protocol SomeUIDependency: Dependency {
var applicationURLHandler: ApplicationURLHandler { get }
var router: Router { get }
}
final class SomeUIComponent: Component<SomeDependency> {
var someObject: SomeObjectClass {
shared {
SomeObjectClass(router: dependecy.router)
}
}
}
Теперь рассмотрим откуда берутся DependecyProvider
.
Создание DependencyProvider
Как мы уже было отмечено ранее, для каждого объявленного в коде DI-контейнера создается свой DependencyProvider
. Это происходит за счет кодогенерации. Генератор кода Needle
анализирует исходный код проекта и ищет всех наследников базовых классов для DI-контейнеров BootstrapComponent
и Component
.
У каждого DI-контейнера есть протокол описания зависимостей.
Для каждого такого протокола генератор анализирует доступность каждой зависимости путем поиска ее среди родителей контейнера. Поиск идет снизу вверх, т.е. от дочернего компонента к родительскому.
Зависимость считается найденой только если совпадают имя и тип зависимости.
Если зависимость не найдена, то сборка проекта останавливается с ошибкой, в которой указывается потерянная зависимость. Это первый уровень обеспечения compile-time safety.
После того, как будут найдены все зависимости в проекте, генератор кода Needle создает DependecyProvider
для каждого DI-контейнера. Полученный провайдер отвечает соответствующему протоколу зависимостей:
// NeedleGenerated.swift
/// ^->RootComponent->UIComponent->SupportUIComponent->SomeUIComponent
private class SomeUIDependencyfb16d126f544a2fb6a43Provider: SomeUIDependency {
var applicationURLHandler: ApplicationURLHandler {
return supportUIComponent.coreComponents.applicationURLHandler
}
// ...
}
Если по каким-то причинам на этапе построения связей между контейнерами потерялась зависимость и генератор пропустил этот момент, то на этом этапе вы получите не собирающийся проект, так как поломанный DependecyProvider
не будет отвечать протоколу зависимостей. Это второй уровень compile-time safety от Needle.
Теперь рассмотрим процесс поиска провайдера зависимостей для контейнера.
Регистрация DependencyProvider
Получив готовые DependecyProvider и зная связь между контейнерами, генератор кода Needle создает для каждого контейнера путь в итоговом графе.
Каждому пути сопоставляется closure-фабрика, внутри которой возвращается провайдер зависимостей. Код сопоставления создается кодогенератором.
В результате появляется глобальная функция registerProviderFactories()
, которую мы должны вызвать в своем коде до первого обращения к каким-либо DI-контейнерам.
// NeedleGenerated.swift
public func registerProviderFactories() {
__DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent") { component in
return EmptyDependencyProvider(component: component)
}
__DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent->UIComponent") { component in
return EmptyDependencyProvider(component: component)
}
// ...
}
Сама регистрация внутри глобальной функции происходит с помощью singleton-объекта класса __DependencyProviderRegistry
. Внутри данного объекта провайдеры зависимостей складываются в словарь [Int: (Scope) -> AnyObject]
, в котором ключом является hashValue
от строки, описывающий путь от вершины графа до контейнера, а значением — closure-фабрика. Сама запись в таблицу является thread-safe за счет использования внутри NSRecursiveLock
.
// DependencyProviderRegistry.swift
public func registerDependencyProviderFactory(`for` componentPath: String, _ dependencyProviderFactory: @escaping (Scope) -> AnyObject) {
providerFactoryLock.lock()
defer {
providerFactoryLock.unlock()
}
providerFactories[componentPath.hashValue] = dependencyProviderFactory
}
Результаты тестирования в проекте
Сейчас у нас порядка 430к строк кода без учета сторонних зависимостей. Из них около 83к строк на Swift.
Все замеры мы проводили на iPhone 11 c iOS 13.3.1 и с использование Needle версии 0.14.
В тестах сравнивались две ветки — актуальный develop
и ветка, в которой root-контейнер и все его дочерние контейнеры были переписаны на needle-конейнеры, и одна ветка контейнеров в графе полностью заменена на Needle. Все изменения для тестов проводились именно в этой ветке графа.
Проведенные тесты
Время полной сборки
Номер измерения | Без Needle | С Needle |
---|---|---|
1 | 294.5s | 295.1s |
2 | 280.8s | 286.4s |
3 | 268.2s | 294.1s |
4 | 282.9s | 279.5s |
5 | 291.5s | 293.4s |
Среднее значение без Needle: 283.58s
Среднее значение с Needle: 289.7s
Как видно, время на первоначальный анализ кода проекта, который должен провести кодогенератор Needle, принесло нам +6 секунд ко времени чистой сборки с нуля.
Время инкрементальной сборки
Номер измерения | Без Needle | С Needle |
---|---|---|
1 | 37.8s | 36.1s |
2 | 27.9s | 37.0s |
3 | 37.3s | 33.0s |
4 | 38.2s | 35.5s |
5 | 37.8s | 35.8s |
Среднее значение Без Needle: 35.8s
Среднее значение С Needle: 35.48s
В этом тесте мы добавляли и удаляли к контейнеру в самом низу графа одну и ту же зависимость.
Измерения registerProviderFactories()
Среднее значение (секунды): 0.000103
Замеры:
0.0001500844955444336
0.0000939369201660156
0.0000900030136108398
0.0000920295715332031
0.0001270771026611328
0.0000950098037719726
0.0000910758972167968
0.0000970363616943359
0.0000969171524047851
0.0000959634780883789
В этом тесте мы выяснили, что время на запуск нашего приложения при использовании Needle почти не изменилось.
Измерения первого доступа к зависимости
Номер измерения | Без Needle | С Needle | C Needle + FakeComponents |
---|---|---|---|
1 | 0.000069 | 0.001111 | 0.002981 |
2 | 0.000103 | 0.001153 | 0.002657 |
3 | 0.000080 | 0.001132 | 0.002418 |
4 | 0.000096 | 0.001142 | 0.002812 |
5 | 0.000078 | 0.001177 | 0.001960 |
Среднее значение Без Needle (секунды): 0.000085
Среднее значение C Needle (секунды): 0.001143
(+0.001058
)
Среднее значение C Needle + FakeComponents (секунды): 0.002566
Примечание: SomeUIComponent
в тестируемом примере лежит на седьмом уровне вложенности графа:^->RootComponent->UIComponent->SupportUIComponent->SupportUIFake0Component->SupportUIFake1Component->SupportUIFake2Component->SupportUIFake3Component->SomeUIComponent
В этом тесте мы померили скорость первоначального обращения к зависимости. Как видно, наше самописное решение тут выигрывает в десятки раз. Но если посмотреть на абсолютные цифры, то это очень незначительное время.
Измерения повторного доступа к BabyloneUIComponent c Needle
Номер измерения | Без Needle | С Needle | C Needle + FakeComponents |
---|---|---|---|
1 | 0.000031 | 0.000069 | 0.000088 |
2 | 0.000037 | 0.000049 | 0.000100 |
3 | 0.000053 | 0.000054 | 0.000082 |
4 | 0.000057 | 0.000064 | 0.000092 |
5 | 0.000041 | 0.000053 | 0.000088 |
Среднее значение без Needle (секунды): 0.000044
Среднее значение с Needle (секунды): 0.000058
Среднее значение с Needle + FakeComponents (секунды):0.000091
Повторное обращение к зависимости происходит еще быстрее. Тут наше решение опять выигрывает, но абсолютные цифры так же очень малы.
Выводы
В итоге по результатам тестов мы пришли к выводу, что Needle дает нам именно то, что мы хотели от DI-фреймворка.
Он дает нам надежность благодаря обеспечению compile time safety кода зависимостей.
Он быстрый. Не такой быстрый, как наше самописное решение на Objective-C, но все же в абсолютных цифрах он достаточно быстрый для нас.
Он избавляет нас от необходимости руками вносить зависимости через множество уровней в графе за счет своей кодогенерации. Достаточно реализовать создание зависимости в одном контейнере и задекларировать потребность в ней в другом контейнере через специальный протокол.
При использовании Needle все же остается проблема того, что на старте приложения нам надо выполнить какие-то настройки кода зависимостей. Но как показал тест, прирост времени запуска составил меньше миллисекунды и мы готовы с этим жить.
На наш взгляд, Needle отлично подходит для команд и проектов, которые заботятся, как и мы, о производительности и надежности приложения, а так же удобстве работы с его кодовой базой.
askl
Не совсем понятно что берет на себя генератор кода и как выглядит код до и после использования Needle.
Tomcattoff Автор
Конечная цель генератора кода заключается в предоставлении DI-контейнеру зависимостей из других DI-контейнеров.
Допустим, что у нас есть такой контейнер:
router
— это зависимость, которая предоставляется каким-то другим DI-контейнером. Пусть это будетRouterComponent
.Тогда генератор кода для
SomeUIComponent
создастDependencyProvider
, который может выглядеть следующим образом:Для связи между различными
DependencyProvider
и контейнерами генератор кода создает глобальную функциюregisterProviderFactories()
,которую мы должны вызвать в своем коде до начала использования DI-контейнеров.
Внутри этой функции для рассмотренного
SomeUIComponent
будут находится следующие строки:Если какая-то зависимость не находится, то генератор кода достаточно информативно оповещает об этом:
askl
И что с DependencyProvider потом делать? К тому что вот я использую RIBs. Там это дерево уже генерится шаблоном модуля. Чем Needle упрощает жизнь?
Tomcattoff Автор
Прошу прощения, ошибся веткой. Ответ вот тут.