Как стать автором
Обновить
304.32
Рейтинг
TINKOFF
IT’s Tinkoff — просто о сложном

DI в iOS: Complete guide

Блог компании TINKOFF Разработка под iOS *Swift *

Всем привет, меня зовут Виталий, я iOS-разработчик в юните мессенджера в Тинькофф. Сегодня поговорим о том, что такое DI, зачем он нужен, рассмотрим известные библиотеки для DI и сравним их между собой. Поехали!

Часть 1. Введение в DI

Dependency Injection, или внедрение зависимостей, — это паттерн настройки объекта, при котором зависимости объекта задаются извне, а не создаются самим объектом. Другими словами, объекты настраиваются внешними объектами. 

Существует несколько способов внедрения зависимостей.

  1. Interface injection (через интерфейс)

final class TestViewModel {

    private lazy var userService = getUserService()

    func getData() -> Data {
        return userService.getData()
    }
}
protocol TestViewModelResolving {
    func getUserService() -> UserServiceProtocol
}
extension TestViewModel: TestViewModelResolving {
    func getUserService() -> UserServiceProtocol { return UserService() }
}

2. Property injection (через публичное свойство)

final class TestViewModel {
    var userService: UserServiceProtocol?
}
final class UserService:  UserServiceProtocol { … }
…
let viewModel = TestViewModel()
viewModel.userService =  UserService(...)

3. Method injection (через публичный метод) 

final class TestView: UIView {

    private var style: ViewStyleProtocol?

    func apply(style: ViewStyleProtocol) {
         self.style = style
         // applying style
    }
}
struct TestViewStyle: ViewStyleProtocol { … }
…
let style = TestViewStyle(...)
testView.apply(style: style)

4. Constructor injection (через инициализатор/конструктор)  

final class TestViewModel {

    private let userService: UserServiceProtocol
    private let modelId: String

    init(userService: UserServiceProtocol, modelId: String) {
        self.userService = userService
        self.modelId = modelId
    }
    ...
}

final class UserService:  UserServiceProtocol { … }
…
let userService = UserService(...)
let viewModel = TestViewModel(userService: userService, modelId: “id”)

Предпочтительным, как правило, является Constructor injection, так как он лишен недостатков трех предыдущих способов:

  1. Interface Injection требует создания дополнительного протокола для инъекции в каждый объект, что кажется избыточным.

  2. Property Injection позволяет создать объект и начать его использовать еще до того, как заинжектили все нужные зависимости. Это может приводить к ошибкам из-за неконсистентного состояния объекта. Кроме того, нам приходится делать внедряемые поля публичными, что дает возможность менять их извне в любой момент, нарушая инкапсуляцию

  3. Method injection очень похож на Property Injection, но внедрение происходит через метод. Используется реже, чем Property Injection, но имеет те же недостатки.

Но даже Constructor injection можно использовать не всегда — например, когда есть цикл зависимостей (пример будет чуть ниже). Еще бывает так, что использовать Constructor injection не очень удобно. Например, когда зависимости создаются уже после создания основного объекта. Конечно, можно использовать Constructor injection, если сделать зависимость опциональной и передавать в конструктор как nil по умолчанию. Но в таких кейсах обычно применяется Property injection.

Некоторые разработчики считают, что Constructor injection плох тем, что приводит к появлению boilerplate кода в разрастающемся от количества зависимостей инициализаторе, но, думаю, это не плохо. Такие иниты выступают отличным индикатором того, что вы помещаете в объект слишком много зависимостей и, возможно, стоит вспомнить про Single Responsibility Principle и подумать о лучшей декомпозиции объекта.

В чем преимущества от использования DI?

  1. DI делает зависимости объекта явными (четко прописанными в интерфейсе объекта) — это позволяет лучше контролировать его сложность.

  2. DI делает зависимости объекта внешними (передаются в объект извне, а не создаются внутри) — это позволяет отделять код создания объектов от бизнес-логики, улучшая разделение ответственности.

  3. DI дает возможность сделать зависимости гибкими. Mожно подменить объект другим — например, если закрыть его протоколом, скрыв реализацию. Код классов теперь зависит только от интерфейсов, а не от конкретных классов, скрывая детали реализации и уменьшая связанность кода. Как следствие — объекты становятся легко тестируемыми

  4. DI уменьшает связанность(coupling) объектов.

  5. DI упрощает переиспользование объектов.

  6. DI улучшает декомпозицию за счет выноса порождающей логики наружу.

Какие минусы у этого подхода?

  1. Увеличивается количество сущностей в проекте. Добавляются дополнительные классы с порождающей логикой и протоколы, скрывающие детали реализации зависимостей.

  2. Возрастает время написания кода.

Часть 2. Service Locator

Когда говорят про DI в iOS-проектах, часто приводят в пример Service Locator (SL) — паттерн, суть которого в наличии объекта-реестра, к которому обращаются объекты для получения зависимостей. Объект-реестр знает, как получить все зависимости, которые могут потребоваться. 

В статьях и iOS-коммьюнити в целом есть некая неоднозначность трактовки понятий ServiceLocator, DI, DI-контейнер, IoC и IoC-контейнер. Не буду на этом подробно останавливаться, просто оставлю ссылку, где, как мне кажется, хорошо разобран этот вопрос. Отмечу только, что ключевым отличием паттернов SL и DI является то, что к локатору обращаются явно внутри класса и достают из него необходимые зависимости. При DI же, наоборот, зависимости внедряются извне.

Давайте рассмотрим простой пример сервис-локатора:

protocol ServiceLocating {
    func resolve<T>() -> T?
}

final class ServiceLocator: ServiceLocating {

    static let shared = ServiceLocator() // Singleton

    // MARK: Private properties

    private lazy var services = [String: Any]()

    // MARK: Init
  
    private init() {}

    // MARK: Private methods

    private func typeName(_ some: Any) -> String {
        return (some is Any.Type) ? "\(some)" : "\(type(of: some))"
    }

    // MARK: Internal methods

    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
    }
}

Как видим, реализация довольно проста. Внутри находится словарь, где ключом является строка, содержащая имя типа, а значением — объект, который мы регистрируем в локаторе. Чтобы получить какую-то зависимость, нужно ее сначала зарегистрировать. Зависимости, как правило, регистрируются в одном месте на старте приложения. Данная реализация не является потокобезопасной, но для демонстрации сути паттерна этого вполне достаточно. Кроме того, мы реализовали SL как singleton, что, строго говоря, не обязательно.

Давайте рассмотрим пример использования SL. Допустим, раньше вы создавали зависимости прямо по месту использования:

func someMethod() {
    let service = TestService()
    // используем service
}

С SL вы будете регистрировать зависимости где-то в одной точке при старте приложения и позже получать их через локатор:

ServiceLocator.shared.register(service: service)
…
func someMethod() {
    guard let service: TestServiceProtocol = ServiceLocator.shared.resolve() else { 
        return 
    }
    // используем service
}

В чем профит? Давайте разберемся.

Плюсы и минусы сервис-локатора

Плюсы:

  1. Можно получить любую необходимую зависимость, скрывая от пользователя детали создания объекта-зависимости.

  2. Избавляет от необходимости использовать сервисы-синглтоны.

  3. Удобно тестировать — можно подменить зависимости при регистрации на моки.

Минусы:

  1. Часто является синглтоном. Синглтоны сами по себе имеют много недостатков и часто позиционируются как антипаттерны.
    Проблемы синглтонов: 
    - глобальное состояние и его неявное изменение;
    - неявная зависимость от синглтона, что приводит к неявному нарушению SRP;
    - жесткая зависимость от синглтона, что мешает тестированию.

  2. Является god-объектом, который знает слишком много и имеет доступ к любому объекту. А значит, все, кто имеет к нему доступ, получают те же возможности. В сочетании со свойством синглтона проблема усугубляется.

  3. Способствует созданию внутренних неявных зависимостей, что приводит к неявной связанности (coupling), которая приводит к неявной сложности. 

  4. Ошибки в рантайме: если вы резолвите зависимость, которую не регистрировали, то узнаете об ошибке только в рантайме.

Как было сказано выше, SL не обязательно должен быть синглтоном. Мы можем, например, инжектить его в объекты через Constructor Injection:

final class SomeClass {

    private let userService: UserServiceProtocol

    init(serviceLocator: ServiceLocating) {
        userService = serviceLocator.resolve()!
    }
}

Но тогда зависимости нашего класса все еще остаются неявными. В этом примере можно заметить еще один недостаток SL — необходимость использовать implicitly unwrapped optional либо при каждом резолве проверять опционал на nil. Дело в том, что мы никогда не знаем, регистрировали ли мы нужный нам сервис в локаторе или нет, пока не попробуем зарезолвить его. Мы не получим никаких ворнингов или ошибок при сборке, если какой-то сервис не был зарегистрирован, — просто поймаем краш в рантайме.

Чтобы улучшить последний пример, можно вынести SL в фабрику:

final class UserProfileFactory {

    static func createUserProfile(serviceLocator: ServiceLocating) -> UIViewController {
        let viewController = UserProfileViewController()
        let userService: UserServiceProtocol = serviceLocator.resolve()!
        let viewModel = UserProfileViewModel(userService: userService)
        viewController.viewModel = viewModel
        return viewController
    }
}

Так мы реализуем принцип наименьших привилегий и избавляем объекты от необходимости знать про SL (то есть знать слишком много), оставляя его только в области видимости фабрик. Но это не избавляет нас от необходимости передавать объект SL между фабриками. Кто-то должен будет хранить этот объект, ведь он будет нужен в каждой фабрике каждого модуля приложения. Неплохим вариантом такого хранителя является координатор. Использование DI(CI) + Factory + SL + Coordinator позволяет сгладить описанные выше недостатки сервис-локатора.

Но тогда возникает резонный вопрос: а нужен ли здесь тогда вообще сервис-локатор? Почему бы не отказаться от него совсем и создавать зависимости внутри фабрик — в конце концов, фабрики и задуманы для инкапсуляции порождающей логики. Оставляю этот вопрос вам на подумать.

Часть 3. DI-библиотеки

Итак, время познакомиться с популярными библиотеками для DI. Начнем с классификации. DI-библиотеки, которые мы рассмотрим, будут основаны либо на рефлексии, либо на кодогенерации.

Начнем с библиотек, основанных на рефлексии. Под рефлексией подразумевается отображение множества ключей на множество объектов. Например, в нашем сервис-локаторе мы использовали словарь (Dictionary), который, по сути, отображает ключ (имя типа объекта) на сам сервис-объект.

Библиотеки, основанные на рефлексии

Swinject

https://github.com/Swinject/Swinject

Более 4.5k звезд на «Гитхабе». Наверное, самая популярная DI-библиотека для iOS. Довольно проста в использовании:

protocol Animal {
    var name: String { get }
}

final class Cat: Animal {

    let name: String

    init(name: String) {
        self.name = name
    }
}

...

let container = Container()
container.register(Animal.self) { _ in Cat(name: "Mimi") }

...

let animal = container.resolve(Animal.self)!
print(animal.name) //prints “mimi”

Но погодите! register...resolve… вам это ничего не напоминает? Это же очень похоже на уже хорошо знакомый нам ServiceLocator. Если мы заглянем внутрь, то увидим аналогичный словарь, но ключом уже является структура ServiceKey, а значением — некий объект за протоколом ServiceEntryProtocol.

public final class Container {
    internal var services = [ServiceKey: ServiceEntryProtocol]()
    ...
}

internal struct ServiceKey {

    internal let serviceType: Any.Type

    internal let argumentsType: Any.Type

    internal let name: String?

    internal let option: ServiceKeyOption? // Used for SwinjectStoryboard or other extensions.

    ...
}

В нашем примере простого SL мы использовали в качестве ключа строку с именем типа. Здесь все немного сложнее. Тоже используется тип сервиса (serviceType), но также учитываются типы аргументов, передаваемые при резолве. Имя (name) нужно, чтобы добавлять зависимости одного типа под разными именами, опции (option) — для использования контейнера со сторибордами. По сути, все эти параметры нужны, чтобы получать уникальное значение хэша для разных зависимостей, которые мы регистрируем.

internal protocol ServiceEntryProtocol: AnyObject {

    func describeWithKey(_ serviceKey: ServiceKey) -> String

    var objectScope: ObjectScopeProtocol { get }

    var storage: InstanceStorage { get }

    var factory: FunctionType { get }

    var initCompleted: (FunctionType)? { get }

    var serviceType: Any.Type { get }
}

ServiceEntryProtocol - это уже не объект-сервис, как в нашем простеньком сервис-локаторе. За этим протоколом стоит класс ServiceEntry, который знает, как создавать объект, где и как его хранить (зависит от скоупа), а также содержит необходимые данные для разруливания циклических зависимостей (через initCompleted).

Что такое циклические зависимости? Чтобы разобраться, сначала нужно понять, как контейнер создает зависимости. Когда вы запрашиваете у контейнера зависимость некоторого типа, она тоже может иметь зависимости, которые тоже могут иметь зависимости и так далее. При создании зависимости контейнер строит ориентированный граф зависимостей, основываясь не только на связях между ними, но и на типе их скоупа.

Скоуп (scope) — это тип, описывающий время жизни регистрируемой зависимости

Swinject предоставляет четыре типа скоупа (но можно добавить и свой собственный скоуп): Graph, Transient, Container, Weak.

Graph

Дефолтный скоуп в Swinject. Зависимость разруливается в рамках построенного графа зависимостей. Например:

final class A {

  private let b: B

  init(b: B) {
      self.b = b
  }
}

final class B {

  private let c: C

  init(c: C) {
      self.c = c
  }
}

final class C {

  private let a: A

  init(a: A) {
      self.a = a
  }
}

let container = Container()

container.register(A.self) { r in 
   return A(b: r.resolve(B.self)!)
}
container.register(B.self) { r in 
   return B(c: r.resolve(C.self)!)
}
container.register(C.self){ r in 
   return C(a: r.resolve(A.self)!)
}

...

let a1 = container.resolve(A.self)!
let a2 = container.resolve(A.self)!

Для каждого резолва a1 и a2 строятся отдельные графы:

Важно понимать, что если вы запустите этот код, то поймаете ошибку в рантайме, потому что получите бесконечный цикл создания зависимостей. Такие кейсы как раз разруливаются с помощью .initCompleted, но об этом чуть позже.

Transient 

Он же prototype или unique в других библиотеках. Зависимость создается заново при каждом резолве.

Зарегистрируем A со скоупом transient:

container.register(A.self) { r in 
   return A(b: r.resolve(B.self)!)
}
.inObjectScope(.transient)

Получим следующий граф при резолве:

 

Container 

Он же singleton в других библиотеках. Думаю, суть понятна: один экземпляр на контейнер и дочерние контейнеры. Создается при первом резолве.

Зарегистрируем B со скоупом container:

container.register(B.self) { r in 
   return B(c: r.resolve(C.self)!)
}
.inObjectScope(.container)

Получим следующий граф при резолве:

Weak

Он же WeakSingleton. Зависимость будет существовать, пока на нее есть сильная ссылка снаружи. С графом, думаю, понятно — аналогично container.

Обработка циклических зависимостей 

Разруливание циклических зависимостей — мастхэв-фича для DI-библиотеки. В Swinject она реализуется через отложенную property injection внутри метода initCompleted для одной или нескольких зависимости в цикле. 

То есть не получится использовать только constructor injection, чтобы разрулить цикл.

Например, для цикла, который мы рассматривали выше, можно переделать класс С, чтобы он поддерживал property injection, и добавить его реализацию в initCompleted:

final class C {
  var a: A?
}

container.register(C.self) { _ in return C() }
    .initCompleted { r, c in
        c.a = r.resolve(A.self)
    }

При резолве циклических зависимостей в Swinject последние могут создаваться несколько раз, что аффектит время резолва, и на больших циклах могут прилично аффектить время запуска приложения. Кроме того, нельзя точно сказать, какой именно из создавшихся объектов будет использован. В таких случаях лучше вынести все резолвы в цикле в initCompleted, что поможет избежать этой проблемы (подробнее — здесь).

Swinject Autoregistration

Swinject также предоставляет упрощенный синтаксис для регистрации — Autoregistration. То есть вместо того, чтобы писать так:

container.register(MyServiceProtocol.self) { r in 
    MyService(dependencyA: r.resolve(DependencyA.self)!, 
              dependencyB: r.resolve(DependencyB.self)!, 
              dependencyC: r.resolve(DependencyC.self)!, 
              dependencyD: r.resolve(DependencyD.self)!)
}

Можно писать намного короче:

container.autoregister(MyServiceProtocol.self, initializer: MyService.init)

Удобная фича, но за удобство приходится платить скоростью. Использование авторегистрации замедляет время резолва, а значит, и время запуска приложения. На больших проектах при очень большом количестве зависимостей это будет критично. Насколько именно критично — узнаем чуть позже, а пока предложу альтернативу, которая чуть короче обычного синтаксиса, но быстрее авторегистрации:

prefix operator <?
prefix func <? <Service>(_ resolver: Resolver) -> Service? {
    return resolver.resolve(Service.self)
}

prefix operator <~
prefix func <~ <Service>(_ resolver: Resolver) -> Service {
    return (<?resolver)!
}

И это все! Теперь можно писать чуть короче:

container.register(MyServiceProtocol.self) { r in 
    MyService(dependencyA: <~r, 
              dependencyB: <~r, 
              dependencyC: <~r, 
              dependencyD: <~r)
}

Плюс ко всем вышеописанным плюшкам Swinject также дает возможность:

  1. Строить иерархии контейнеров.

  2. Разбивать регистрацию на модули (Assembly).

  3. Работать со сторибордами.

  4. Обеспечить потокобезопасность. Container не потокобезопасен по умолчанию, но метод synchronize дает нам потокобезопасный резолвер:

let threadSafeContainer: Resolver = container.synchronize()

Минусы Swinject

Главный недостаток Swinject в том, что его очень легко можно использовать как сервис-локатор. В таком случае он наследует все проблемы, описанные в разделе о SL.

Swinject тоже имеет runtime-природу со всеми вытекающими. Если вы резолвите зависимость, которую не регистрировали, то узнаете об этом только при краше в рантайме. Вы можете сказать, что с вами этого не случится, так как вы никогда не забываете регистрировать зависимости. Но реально в больших проектах такое вполне может происходить. Например, может потребоваться добавить аргумент при резолве уже существующей зависимости, которая используется в разных модулях. С этого момента при резолве без аргумента вы получите краш в рантайме. Забыли добавить аргумент при резолве в одном из модулей? Что вам об этом скажет Swinject? К сожалению, ничего.

DIP 

https://github.com/AliSoftware/Dip

888 звезд на «Гитхабе». Тоже довольно популярная библиотека, очень похожая на Swinject в плане синтаксиса:

let container = DependencyContainer()
container.register { ServiceImp() as Service }
...
let service = try! container.resolve() as Service

Обладает практически таким же набором фич, как Swinject, с несколькими отличиями:

  1. Нет поддержки иерархии контейнеров и разбиения регистрации на модули.

  2. Потокобезопасность — по умолчанию (можно отключить через аргумент в DependencyContainer.init).

  3. Есть фича валидации. Можно провалидировать контейнер, что все зависимости, которые были зарегистрированы, будут зарезолвлены без ошибок. 

  4. Кроме уже описанных скоупов, которые есть у Swinject, добавляет также EagerSingleton — «нетерпеливый синглтон». Суть в том, что этот синглтон создается при вызове container.bootstrap(), а не при первом резолве синглтона. Метод bootstrap фиксирует зависимости в контейнере, не давая добавить новые зависимости или удалить старые после его вызова.

Внутри устроен похожим на Swinject образом:

public final class DependencyContainer {
  ...
  var definitions = [DefinitionKey: _Definition]()
  ...
}

Ключ также хранит информацию о типе и аргументах. Тэг — аналог поля name в Swinject — используется для регистрации одного типа под разными тэгами.

public struct DefinitionKey: Hashable, CustomStringConvertible {
  public let type: Any.Type
  public let typeOfArguments: Any.Type
  public private(set) var tag: DependencyContainer.Tag?
  ...
}

Definition — обертка для зависимости, которая знает ее тип, скоуп и как создавать зависимость. 

Как и Swinject, DIP может быть легко использован как сервис-локатор со всеми вытекающими. Некоторые разработчики предпочитают использовать DIP на старте проекта или на небольших проектах, объясняя это тем, что он проще, чем Swinject (спорное утверждение). Но когда «небольшой проект» разрастается, они с ужасом замечают, что время запуска приложения стало чертовски долгим. Дело в том, что DIP значительно медленнее, чем Swinject. Хотите узнать насколько? Не переключайтесь.

DITranquillity

https://github.com/ivlevAstef/DITranquillity

316 звезд на «Гитхабе». Синтаксис немного отличается от Swinject и DIP. Во-первых, «авторегистрация» из коробки, что удобно, но за это приходится платить увеличением времени резолва. Во-вторых, resolve без необходимости использовать ! и try! — тоже хорошо.

let container = DIContainer()
container.register(MyService.init).as(MyServiceProtocol.self)
...
let router: MyServiceProtocol = container.resolve()

Потокобезопасность также из коробки, отключить нельзя. Доступна модульность и иерархии контейнеров. 

Несколько новых скоупов: perRun (один на каждый запуск приложения), perContainer (один на каждый контейнер) и single (типа синглтон, но создается сразу после вызова .initializeSingletonObjects() у контейнера). 

Доступна валидация графа, то есть библиотека может проверить граф зависимостей еще до извлечения зависимостей. Когда регистрируем все зависимости, получаем граф, вызываем функцию проверки checkIsValid, и она проверяет, что все зарегистрировано верно, иначе бросает ошибку со ссылкой на файл и строку, где ошибка возникает, — также очень удобно.

#if DEBUG
if !container.makeGraph().checkIsValid(checkGraphCycles: true) {
    fatalError("invalid graph")
}
#endif

Под капотом в целом суть та же, хотя на первый взгляд кажется, что все сложнее, чем в Swinject и DIP

Объекты под капотом хранятся в специальном объекте за протоколом:

public protocol DIStorage {
     /// Return all storaged object if there is.
     var any: [DIComponentInfo: Any] { get }
     ...
}

/// Short information about component. Needed for good log
public struct DIComponentInfo: Hashable, CustomStringConvertible {

     /// Any type announced at registration the component
     public let type: DIAType

     /// File where the component is registration
     public let file: String

     /// Line where the component is registration
     public let line: Int
  
     ...
}

Ключ содержит информацию не только о типе, но и о файле и строке, где зависимость регистрировалась. Это нужно как раз для того, чтобы выдавать точные логи при проверке графа зависимостей.

Автор библиотеки даже сравнил скорость своей библиотеки со Swinject на большом количестве регистраций(>1000) и простых графах зависимостей. По результатам этих тестов, DITranquility быстрее, чем Swinject (можно глянуть здесь).

EasyDI 

https://github.com/TinkoffCreditSystems/EasyDi

88 звезд на «Гитхабе». Отличается от предыдущих в плане синтаксиса. В отличие от предыдущих библиотек, уже не располагает к использованию себя в качестве сервис-локатора:

final class ServiceAssembly: Assembly {
    var apiClient: APIClient {
      return define(init: APIClient())
    }
}

То есть мы не регистрируем и не резолвим зависимости явно, а создаем специальный объект — Assembly, который выступает провайдером зависимостей и сам строит граф зависимостей под капотом. Все Assembly существуют в рамках некоторого контекста DIContext.

public final class DIContext {

    public static var defaultInstance = DIContext()

    fileprivate var assemblies: [String: Assembly] = [:]

    var objectGraphStorage: [String: InjectableObject] = [:]

    ...
}

Как видим, дефолтный DIContext статический и создается по умолчанию. Он контролирует граф зависимостей всех подконтрольных Assembly, то есть граф общий для всех ассембли внутри одного контекста. Сам граф зависимостей реализован с помощью словаря. Ключом в данном случае выступает имя типа Assembly + имя переменной нашей зависимости. А объект — это просто Any.

public typealias InjectableObject = Any

Предоставляет четыре уже знакомые нам скоупа: prototype, objectGraph, lazySingleton и совсем недавно добавленный weak singleton. Кроме того, можно строить иерархии Assembly. Есть фича подмены объектов для тестирования.

Библиотека довольно проста в использовании и реализации и занимает всего чуть более 200 строк кода.

Кроме того, получаем потокобезопасность из коробки. Плюс нет явной фазы регистрации зависимостей, а значит, нельзя забыть зарегистрировать то, что нам понадобится.

Плюсы и минусы библиотек на рефлексии

Плюсы:

  1. Просты в использовании, невысокий порог входа.

  2. В реализации относительно несложно разобраться.

Минусы:

  1. Довольно легко использовать как сервис-локаторы и получить связанные с этим проблемы.

  2. Вся магия происходит в рантайме — значит, и ошибки получаем в рантайме и не у всех библиотек есть возможность валидации графа.

  3. Аффектят время запуска приложения (опять-таки из-за своей рантайм-природы).

Несмотря на достаточное количество минусов, остаются самыми популярными решениями для DI в iOS.

Библиотеки, основанные на кодогенерации

Сначала давайте разберемся, как работают подобные библиотеки. Если вкратце, вы помечаете проперти своего класса неким атрибутом или наследуете свои компоненты от определенного протокола, а библиотека (или в данном случае уже скорее фреймворк), генерирует код, который эти проперти/зависимости внедряет. И это все. Вам больше ничего делать не нужно — только использовать свои зависимости.  

Если пока не стало понятно — ничего, разберемся на примерах.

Needle 

https://github.com/uber/needle

822 звезды на «Гитхабе». Набирающая популярность библиотека от разработчиков Uber. Работает на базе SourceKit.

Главным преимуществом, по словам разработчиков, является обеспечение compile time safety кода работы для внедрения зависимостей. Контейнеры зависимостей должны наследоваться от класса Component. Зависимости DI-контейнера описываются в протоколе, который наследуется от базового протокола зависимостей Dependency и указывается в качестве generic типа самого контейнера.

protocol MyDependency: Dependency {
    var chocolate: Food { get }
    var milk: Food { get }
}
final class MyComponent: Component<MyDependency> {

    var hotChocolate: Drink {
        return HotChocolate(dependency.chocolate, dependency.milk)
    }
    var myChildComponent: MyChildComponent {
        return MyChildComponent(parent: self)
    }
}

В примере также видим, как дочерние компоненты создаются в родительских. Очень напоминает Assembly в EasyDI, правда? Хотя под капотом они работают абсолютно по-разному. Каждый раз при сборке проекта генератор кода Needle анализирует исходный код проекта и ищет всех наследников класса Component - базового классов для DI-контейнеров.

У каждого DI-контейнера есть протокол описания зависимостей. Для каждого такого протокола генератор анализирует доступность каждой зависимости путем поиска ее среди родителей контейнера. Поиск идет снизу вверх — от дочернего компонента к родительскому. Зависимость считается найденной, только если совпадают имя и тип зависимости.

Если зависимость не найдена, то сборка проекта останавливается с ошибкой, в которой указывается потерянная зависимость. Это дает заявленную выше compile-time safety.

Плюсы и минусы Needle

Плюсы:

  1. Compile-time safety — все проверки и ошибки во время компиляции.

  2. Время запуска приложения не аффектится с ростом числа зависимостей.

  3. Потокобезопасность из коробки.

  4. Поддержка иерархии компонентов.

Минусы:

  1. Пока что всего два варианта скоупа: shared (по сути — container) и prototype, но в теории можно ими обойтись.

  2. Порог входа выше, чем у сервис-локаторов, хоть и не намного.

Мне самому Needle зашел. Потенциально хорошая альтернатива Swinject. Несмотря на номер последней версии v0.17.1, разработчики говорят, что библиотека production ready и уже используется во всех приложениях Uber.

Более подробно о библиотеке можно почитать здесь.

Weaver 

https://github.com/scribd/Weaver

543 звезды на «Гитхабе». Также основана на кодогенерации, использует SourceKitten, основанный на SourceKit. Но работает несколько иначе, чем Needle, а именно — через аннотации к зависимостям. Тот, кто немного знаком с миром Android и Dagger, сразу поймет, о чем речь. Аннотации в нашем случае — комментарии определенного формата или специальные property wrapper’ы.

Например:

final class MyClass {
    // weaver: movieManager = MovieManager <- MovieManaging
    // weaver: movieManager.scope = .container
    ...
}

или

final class MyClass {

    @Weaver(.registration, type: MovieManager.self, scope: .container)
    private var movieManager: MovieManaging
    ...
}

Первый вариант мне лично не нравится из-за «хрупкости» комментариев, но этот вариант был единственным до появления проперти врапперов в Swift 5.1. Второй вариант был вполне ожидаемым после их появления.

Weaver сканирует код проекта в поисках аннотаций, строит граф зависимостей и генерирует все, что нужно для DI, а именно контейнер MainDependencyContainer, у которого можно получить DependencyResolver для каждой зависимости.

Аннотации в Weaver бывают трех видов — register, reference и parameter:

  1. Register

// weaver: dependencyName = DependencyConcreteType <- DependencyProtocol

@Weaver(.registration, type: DependencyConcreteType.self) 
var dependencyName: DependencyProtocol

Добавляет зависимость в контейнер и предоставляет доступ к зависимости.

2. Reference

// weaver: dependencyName <- DependencyType

@Weaver(.reference) 
var dependencyName: DependencyType 

Только предоставляет доступ (ссылку) к зависимости.

3. Parameter

// weaver: parameterName <= ParameterType

@Weaver(.parameter) 
var parameterName: ParameterType

Параметр, который будет передаваться в объект при инициализации.

Все объекты, где мы добавляем аннотации, должны предоставлять стандартный init c аргументом резолвером соответствующего генерируемого типа, что выглядит как костыль:

required init(injecting _: MyDependencyResolver) {
    // no-op
}

Библиотека предоставляет уже знакомые нам четыре скоупа: transient, container, weak, lazy.

Плюсы и минусы Weaver

Плюсы:

  1. Относительно проста в использовании.

  2. Compile time safety.

  3. Умеет генерировать стабы для тестов.

Минусы:

  1. Фактически библиотека принуждает нас использовать PropertyInjection.

  2. Костыльный конструктор у всех зависимостей.

  3. Порог вхождения выше, чем у needle. Неинтуитивные для понимания register/reference/parameter.

  4. Код самих объектов, в которые мы инжектим зависимости, зависит от нюансов библиотеки, а значит, поменять библиотеку на другую без изменения кода самих объектов не получится.

Плюсы и минусы библиотек на кодогенерации

Плюсы:

  1. Compile time safety, избавление от крашей в рантайме.

  2. Не аффектят скорость запуска приложения.

Минусы:

  1. Немного сложнее для понимания, чем register/resolve-контейнеры.

  2. Объекты зависят от нюансов библиотеки (наследуют специальные протоколы или используют специальные атрибуты у полей).

Часть 4. Битва контейнеров

Попробуем сравнить производительность рассмотренных выше библиотек на растущем количестве зависимостей.

Пусть каждая регистрируемая зависимость имеет от 0 до 3 зависимостей, часть которых также зависит друг от друга. Замеры будем делать на iPad 2017 (по железу — аналог iPhone 6s) iOS 13,5 (время в секундах) в релизной конфигурации(с оптимизациями).

Итак, что мы имеем?

  1. DIP — аутсайдер в этой битве.

  2. Swinject и DITranquillity — самые быстрые на 100 регистрациях, но деградируют на росте регистраций до 1000. При этом DITranquillity деградирует медленнее.

  3. SwinjectAutoregistration делает свинжект еще немного медленнее.

  4. EasyDI медленнее Swinject и DITranquillity на 100 регистрациях, но зато рост регистраций слабо аффектит время его работы. И на 1000 регистраций он уже быстрее чем Swinject.

Уточню, после оптимизаций в версии 4.2 производительность DITranquillity сильно выросла. До версии 4.2 производительность была прилично ниже.

Казалось бы, вот оно! Мы нашли фаворитов и можно идти внедрять их в свои проекты! Расходимся! Но нет, не все так просто. Мы ведь рассматривали простой граф зависимостей. У каждой регистрируемой зависимости было от 0 до 3 зависимостей. Это довольно мало, и в крупных и сложных проектах ситуация явно хуже. 

Давайте рассмотрим некоторый «худший» случай в противовес простому. Допустим, у каждой регистрируемой зависимости будет 10 своих зависимостей, которые также зависимы между собой.

Как видим, ситуация сильно меняется. Swinject все еще деградирует на росте регистраций, но его время заметно меньше, чем у других библиотек, кроме DITranquillity. Видимо, работа со сложным графом в Swinject и DITranquillity оптимизирована лучше. При этом DITranquillity обгоняет Swinject при достижении уже 500 регистраций.

Отмечу, что время отдельно взятой библиотеки само по себе малоинформативно, важны лишь их значения относительно друг друга, потому что: 

  1. При тестах считалось среднее время по замерам 100 тестов подряд. И первая итерация обычно выполнялась медленнее, чем последующие.

  2. Объекты, регистрируемые в тестах, по сути — пустышки, не содержащие бизнес-логики, а значит, время их создания меньше, чем у объектов в реальных проектах.

Так как проекты обычно становятся все сложнее и сложнее, добавляются все новые и новые фичи, рано или поздно рост количества регистраций и сложности графа зависимостей может привести к критически большому времени запуска приложения, это надо иметь в виду. Возможно, в таких случаях имеет смысл перейти на DI-библиотеку, основанную на кодогенерации, ведь у них вся магия происходит во время компиляции, а не в рантайме. 

Но и с библиотеками на кодогенерации тоже не все так просто :)

Давайте проверим, как сказывается рост числа зависимостей на времени компиляции. Рассмотрим уже упомянутые Needle и Weaver.

Даже несмотря на то, что обе библиотеки работают на базе SourceKit, время компиляции отличается на порядок при большом количестве зависимостей. На этом примере можно понять, как важна оптимизация в таких библиотеках.

Заключение

Вот какие выводы можно сделать из вышесказанного:

  1. Многие из популярных в iOS DI-библиотек позволяют использовать их как сервис-локаторы, то есть в некотором смысле они ближе к сервис-локаторам, чем к DI-контейнерам.

  2. Работа многих DI-библиотек основана на общих принципах и инструментах, но общие принципы можно реализовать по-разному. Например, время работы контейнеров, основанных на рефлексии, очень разнится и зависит не только от количества зависимостей, но и от сложности самого графа зависимостей (ну и от реализации самого контейнера, конечно). Или, например, время компиляции для библиотек, основанных на кодогенерации, тоже очень разнится.

  3. Оптимизация работы DI-библиотек очень важна, так как аффектит запуск приложения или его время сборки.

Какую библиотеку выбрать для своего проекта?

Зависит от ситуации. На небольших проектах Swinject может быть хорошим вариантом. Если вы уже используете Swinject (или любую другую register/resolve библиотеку) и вас критически не устраивает время запуска приложения, присмотритесь к Needle.

А еще всегда можно сделать свой DI c блэкджеком и оптимизацией. Можно это сделать довольно просто, например через фабрики, но это уже совсем другая история...

Ссылки на тестовые проекты

  1. https://github.com/vitalybatrakov/iOS_DI_Libs_Performance_Tests

  2. https://github.com/vitalybatrakov/iOS_DI_Libs_Compilation_Time_Tests

Что почитать/посмотреть

  1. https://martinfowler.com/articles/injection.html

  2. https://www.youtube.com/watch?v=-JGGw4SN6NA&ab_channel=AvitoTech

  3. https://jonfir.github.io/posts/ioc-ios/

  4. http://sergeyteplyakov.blogspot.com/2013/03/di-service-locator.html

  5. http://sergeyteplyakov.blogspot.com/2014/11/di-vs-dip-vs-ioc.html

  6. https://tech.badoo.com/ru/article/411/singlton-lokator-servisov-i-testy/

  7. https://habr.com/ru/company/joom/blog/514784/

Теги:
Хабы:
Всего голосов 29: ↑29 и ↓0 +29
Просмотры 13K
Комментарии Комментарии 7

Информация

Дата основания
Местоположение
Россия
Сайт
www.tinkoff.ru
Численность
свыше 10 000 человек
Дата регистрации
Представитель