Библиотека EasyDi содержит контейнер зависимостей для Swift. Синтаксис этой библиотеки был специально разработан для быстрого освоения и эффективного использования. Она умещается в 200 строк, при этом умеет все, что нужно взрослой Di библиотеке:
— Создание объектов и внедрение зависимостей в существующие
— Разделение на контейнеры — 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 тестирования в приложении. Например вот так:
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 есть ‘ключи’ и плейсхолдеры с помощью которых можно возвращать один и тот же объект из разных методов. Выглядит это так:
с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.
Автор: shadow_of_irbis