Библиотека EasyDi содержит контейнер зависимостей для Swift. Синтаксис этой библиотеки был специально разработан для быстрого освоения и эффективного использования. Она умещается в 200 строк, при этом умеет все, что нужно взрослой Di библиотеке:
— Создание объектов и внедрение зависимостей в существующие
— Разделение на контейнеры — Assemblies
— Типы разрешения зависимостей: граф объектов, синглетон, прототип
— Разрешение циклических зависимостей
— Подмена объектов и конктесты зависимостей для тестов
В EasyDi нет разделения на register/resolve. Вместо этого зависимости описываются вот так:
→ Cocoapods / EasyDi
→ Github / EasyDi
Под катом очень краткое описание «Зачем DI и что это», также примеры использования библиотеки:
Инверсия зависимостей в проекте очень важна, если он содержит в себе больше 5 экранов и будет поддерживаться больше года.
Вот три базовых сценария, где DI делает жизнь лучше:
Суть DI можно так описать одним предложением:
Т.е. вместо:
Стоит использовать:
Подробнее с принципом инверсии зависимостей и концепцией SOLID можно познакомиться
тут (objc.io #15 DI) и тут (wikipedia. SOLID).
Простой пример использования библиотеки: убрать из ViewController работу с сетью в сервисы и разместить их создание и зависимости в отдельном контейнере. Это простой и эффективный способ начать деление приложения на слои. В примере рассмотрим сервис и контроллер из примера выше.
Зависимости сервиса:
И вот так мы внедряем сервис в контроллер:
Теперь можно поменять класс сервиса не залезая во ViewController.
По-умолчанию все зависимости разрешаются через граф объектов. Если объект уже есть в стеке текущего графа объектов, то он используется снова. Это позволяет внедрить один и тот же объект в несколько, а также разрешить циклические зависимости. Для примера возьмём объекты A,B и C со ссылками A->B->C.(Не будем обращать внимания на RetainCycle, он нужен для полноты примера).
Вот так выглядит Assembly и вот такой граф зависимостей для двух запросов A.
Получилось два независимых графа.
Но бывает так, что нужно создать один объект, который потом будет использоваться везде, например система аналитики или хранилище. Использовать классический Singleton с SharedInstance не стоит, т.к. будет невозможно его подменить. Для этих целей в EasyDi есть scope: singleton. Этот объект создаётся один раз, в него один раз внедряются зависимости и больше EasyDi его не меняет, только возвращает. Для примера сделаем B синглетоном.
На этот раз получился один граф объектов, т.к. B стал общим.
И иногда требуется при каждом обращении получать новый объект. На примере объектов ABC для A-прототипа это будет выглядеть так:
Получается, что два графа объектов дают 4 копии объекта A
Важно понять, что это точка входа в граф и другие зависимости не надо делать прототипами. Если объединить прототипы в цикл, то стек переполнится и приложение упадёт.
При тестировании важно сохранять независимость тестов. В EasyDi это обеспечивается контекстами Assemblies. Например, интеграционные тесты, где используются синглетоны. Используются они вот так:
При этом важно следить за тем, чтобы контексты у совместно используемых Assemblies совпадали.
Другая важная часть тестирования — это моки и стабы, т.е объекты с известным поведением. При известных входных данных тестируемый объект выдаёт известный результат. Если не выдаёт, значит тест не пройден. Подробнее про тестирование можно узнать тут (objc.io #15 весь). А вот так можно подменить объект:
Теперь свойство theObject будет возвращать новый объект другого типа с другим intParameter.
У меня не было цели упихать все в 200 строк, просто так получилось. Наиболее влияние на эту библиотеку оказал Typhoon, очень хотелось иметь что-то похожее, но на Swift и попроще.
Дольше всего формировался синтаксис, такой, чтобы писать минимум кода и с минимумом простора для полета мысли. Это особенно важно при работе в команде.
Библиотека упакована в 1 файл, чтобы проще было добавлять в переходные проекты, где ещё не используется use_frameworks, но Swift уже есть.
Ссылки на библиотеку:
Текущая версия '1.1.1'
Должна одинаково хорошо работать на Swift 3/4, в iOS 8+.
На iOS 7 — не знаю, не могу проверить.
А депо-приложение — читалка комиксов XKCD.
— Создание объектов и внедрение зависимостей в существующие
— Разделение на контейнеры — Assemblies
— Типы разрешения зависимостей: граф объектов, синглетон, прототип
— Разрешение циклических зависимостей
— Подмена объектов и конктесты зависимостей для тестов
В EasyDi нет разделения на register/resolve. Вместо этого зависимости описываются вот так:
var apiClient: IAPIClient {
return define(init: APIClient()) {
$0.baseURl = self.baseURL
}
}
→ Cocoapods / EasyDi
→ Github / EasyDi
Под катом очень краткое описание «Зачем DI и что это», также примеры использования библиотеки:
- Как использовать и типы зависимостей
- Как тестировать c подменой объектов
- Как можно это использовать для A/B тестов
- Как собрать VIPER-модуль
Зачем DI и что это?(очень кратко)
Инверсия зависимостей в проекте очень важна, если он содержит в себе больше 5 экранов и будет поддерживаться больше года.
Вот три базовых сценария, где DI делает жизнь лучше:
- Параллельная разработка. Один разработчик сможет заниматься UI, а второй данными, если заранее договорятся о протоколе работы. UI тогда может разрабатываться с тестовыми данными, а слой данных вызываться из тестового UI.
- Тесты. Подменяя сетевой слой на объекты с фиксированными ответами, можно проверить все варианты поведения UI, в том числе в случае ошибок.
- Рефакторинг. Сетевой слой можно заменить на новый, быстрый с кэшем и другим API, если оставить без изменений протокол с UI.
Суть DI можно так описать одним предложением:
Зависимости для объектов надо закрыть протоколами и передать в объект снаружи.
Т.е. вместо:
class OrderViewController {
func didClickShopButton(_ sender: UIButton?) {
APIClient.sharedInstance.purchase(...)
}
}
Стоит использовать:
protocol IPurchaseService {
func perform(...)
}
class OrderViewController {
var purchaseService: IPurchaseService?
func didClickShopButton(_ sender: UIButton?) {
self.purchaseService?.perform(...)
}
}
Подробнее с принципом инверсии зависимостей и концепцией SOLID можно познакомиться
тут (objc.io #15 DI) и тут (wikipedia. SOLID).
Как работать с EasyDi (Простой пример)
Простой пример использования библиотеки: убрать из ViewController работу с сетью в сервисы и разместить их создание и зависимости в отдельном контейнере. Это простой и эффективный способ начать деление приложения на слои. В примере рассмотрим сервис и контроллер из примера выше.
Пример кода сервиса и контроллера
Контроллер:
protocol IPurchaseService {
func perform(with objectId: String, then completion: (success: Bool)->Void)
}
class PurchaseService: IPurchaseService {
var baseURL: URL?
var apiPath = "/purchase/"
var apiClient: IAPIClient?
func perform(with objectId: String, then completion: (_ success: Bool) -> Void) {
guard let apiClient = self.apiClient, let url = self.baseURL else {
fatalError("Trying to do something with uninitialized purchase service")
}
let purchaseURL = baseURL.appendingPathComponent(self.apiPath).appendingPathComponent(objectId)
let urlRequest = URLRequest(url: purchaseURL)
self.apiClient.post(urlRequest) { (_, error) in
let success: Bool = (error == nil)
completion( success )
}
}
}
Контроллер:
class OrderViewController {
var purchaseService: IPurchaseService?
var purchaseId: String?
func didClickShopButton(_ sender: UIButton?) {
guard let purchaseService = self.purchaseService, let purchaseId = self.purchaseId else {
fatalError("Trying to do something with uninitialized order view controller")
}
self.purchaseService.perform(with: self.purchaseId) { (success) in
self.presenter(showOrderResult: success)
}
}
}
Зависимости сервиса:
class ServiceAssembly: Assembly {
var purchaseService: IPurchaseService {
return define(init: PurchaseService()) {
$0.baseURL = self.apiV1BaseURL
$0.apiClient = self.apiClient
}
}
var apiClient: IAPIClient {
return define(init: APIClient())
}
var apiV1BaseURL: URL {
return define(init: URL("http://someapi.com/")!)
}
}
И вот так мы внедряем сервис в контроллер:
var orderViewAssembly: Assembly {
var serviceAssembly: ServiceAssembly = self.context.assembly()
func inject(into controller: OrderViewController, purchaseId: String) {
define(init: controller) {
$0.purchaseService = self.serviceAssembly.purchaseService
$0.purchaseId = purchaseId
}
}
}
Теперь можно поменять класс сервиса не залезая во ViewController.
Типы разрешения зависимостей (Пример средней сложности)
ObjectGraph
По-умолчанию все зависимости разрешаются через граф объектов. Если объект уже есть в стеке текущего графа объектов, то он используется снова. Это позволяет внедрить один и тот же объект в несколько, а также разрешить циклические зависимости. Для примера возьмём объекты A,B и C со ссылками A->B->C.(Не будем обращать внимания на RetainCycle, он нужен для полноты примера).
class A {
var b: B?
}
class B {
var c: C?
}
class C {
var a: A?
}
Вот так выглядит Assembly и вот такой граф зависимостей для двух запросов A.
class ABCAssembly: Assembly {
var a:A {
return define(init: A()) {
$0.b = self.B()
}
}
var b:B {
return define(init: B()) {
$0.c = self.C()
}
}
var c:C {
return define(init: C()) {
$0.a = self.A()
}
}
}
var a1 = ABCAssembly.instance().a
var a2 = ABCAssembly.instance().a
Получилось два независимых графа.
Singleton
Но бывает так, что нужно создать один объект, который потом будет использоваться везде, например система аналитики или хранилище. Использовать классический Singleton с SharedInstance не стоит, т.к. будет невозможно его подменить. Для этих целей в EasyDi есть scope: singleton. Этот объект создаётся один раз, в него один раз внедряются зависимости и больше EasyDi его не меняет, только возвращает. Для примера сделаем B синглетоном.
class ABCAssembly: Assembly {
var a:A {
return define(init: A()) {
$0.b = self.B()
}
}
var b:B {
return define(scope: .lazySingleton, init: B()) {
$0.c = self.C()
}
}
var c:C {
return define(init: C()) {
$0.a = self.A()
}
}
}
var a1 = ABCAssembly.instance().a
var a2 = ABCAssembly.instance().a
На этот раз получился один граф объектов, т.к. B стал общим.
Prototype
И иногда требуется при каждом обращении получать новый объект. На примере объектов ABC для A-прототипа это будет выглядеть так:
class ABCAssembly: Assembly {
var a:A {
return define(scope: .prototype, init: A()) {
$0.b = self.B()
}
}
var b:B {
return define(init: B()) {
$0.c = self.C()
}
}
var c:C {
return define(init: C()) {
$0.a = self.A()
}
}
}
var a1 = ABCAssembly.instance().a
var a2 = ABCAssembly.instance().a
Получается, что два графа объектов дают 4 копии объекта A
Важно понять, что это точка входа в граф и другие зависимости не надо делать прототипами. Если объединить прототипы в цикл, то стек переполнится и приложение упадёт.
Патчи и контексты для тестов (Сложный пример)
При тестировании важно сохранять независимость тестов. В EasyDi это обеспечивается контекстами Assemblies. Например, интеграционные тесты, где используются синглетоны. Используются они вот так:
let context: DIContext = DIContext()
let assemblyInstance2 = TestAssembly.instance(from: context)
При этом важно следить за тем, чтобы контексты у совместно используемых Assemblies совпадали.
class FeedViewAssembly: Assembly {
lazy var serviceAssembly:ServiceAssembly = self.context.assembly()
}
Другая важная часть тестирования — это моки и стабы, т.е объекты с известным поведением. При известных входных данных тестируемый объект выдаёт известный результат. Если не выдаёт, значит тест не пройден. Подробнее про тестирование можно узнать тут (objc.io #15 весь). А вот так можно подменить объект:
protocol ITheObject {
var intParameter: Int { get }
}
class MyAssembly: Assembly {
var theObject: ITheObject {
return define(init: TheObject()) {
$0.intParameter = 10
}
}
}
let myAssembly = MyAssembly.instance()
myAssembly.addSubstitution(for: "theObject") { () -> ITheObject in
let result = FakeTheObject()
result.intParameter = 30
return result
}
Теперь свойство theObject будет возвращать новый объект другого типа с другим intParameter.
про A / B тесты
Этот же механизм можно использовать для a/b тестирования в приложении. Например вот так:
Здесь для теста создается отдельный контейнер, который создает второй вариант объекта и позволяет включить/выключить подстановку этого объекта.
про A / B тесты
Этот же механизм можно использовать для a/b тестирования в приложении. Например вот так:
let FeatureAssembly: Assembly {
var feature: IFeature {
return define(init: Feature) {
...
}
}
}
let FeatureABTestAssembly: Assembly {
lazy var featureAssembly: FeatureAssembly = self.context.assembly()
var feature: IFeature {
return define(init: FeatureV2) {
...
}
}
func activate(firstTest: Bool) {
if (firstTest) {
self.featureAssembly.addSubstitution(for: "feature") {
return self.feature
}
} else {
self.featureAssembly.removeSubstitution(for: "feature")
}
}
}
Здесь для теста создается отдельный контейнер, который создает второй вариант объекта и позволяет включить/выключить подстановку этого объекта.
Внедрение зависимостей в VIPER
Бывает так, что надо внедрить зависимости в существующий объект, а от него тоже кто-то зависит. Самый очевидный пример — это VIPER, когда во ViewController надо добавить Presenter, а он сам должен получить ссылку на ViewController.
Для этого в EasyDi есть ‘ключи’ и плейсхолдеры с помощью которых можно возвращать один и тот же объект из разных методов. Выглядит это так:
Здесь для внедрения зависимостей во ViewController используется метод inject, который связан ключом со свойством viewController. Теперь это свойство возвращает объект, переданный в метод inject. И только при разрешении зависимостей графа объектов, который начинается с метода inject.
Внедрение зависимостей в VIPER
Бывает так, что надо внедрить зависимости в существующий объект, а от него тоже кто-то зависит. Самый очевидный пример — это VIPER, когда во ViewController надо добавить Presenter, а он сам должен получить ссылку на ViewController.
Для этого в EasyDi есть ‘ключи’ и плейсхолдеры с помощью которых можно возвращать один и тот же объект из разных методов. Выглядит это так:
сlass ModuleAssembly: Assembly {
func inject(into view: ModuleViewController) {
return define(key: "view", init: view) {
$0.presenter = self.presenter
}
}
var view: IModuleViewController {
return definePlaceholder()
}
var presenter: IModulePresenter {
return define(init: ModulePresenter()) {
$0.view = self.view
$0.interactor = self.interactor
}
}
var interaction: IModuleInteractor {
return define(init: ModuleInteractor()) {
$0.presenter = self.presenter
...
}
}
}
Здесь для внедрения зависимостей во ViewController используется метод inject, который связан ключом со свойством viewController. Теперь это свойство возвращает объект, переданный в метод inject. И только при разрешении зависимостей графа объектов, который начинается с метода inject.
Вместо заключения
У меня не было цели упихать все в 200 строк, просто так получилось. Наиболее влияние на эту библиотеку оказал Typhoon, очень хотелось иметь что-то похожее, но на Swift и попроще.
Дольше всего формировался синтаксис, такой, чтобы писать минимум кода и с минимумом простора для полета мысли. Это особенно важно при работе в команде.
Библиотека упакована в 1 файл, чтобы проще было добавлять в переходные проекты, где ещё не используется use_frameworks, но Swift уже есть.
Ссылки на библиотеку:
Текущая версия '1.1.1'
pod 'EasyDi', '~>1.1'
Должна одинаково хорошо работать на Swift 3/4, в iOS 8+.
На iOS 7 — не знаю, не могу проверить.
А депо-приложение — читалка комиксов XKCD.