Внедрение зависимостей с DITranquillity

  • Tutorial

Dependency Injection — довольно популярный паттерн, позволяющий гибко конфигурировать систему и правильно выстраивать зависимости компонентов этой системы друг от друга. Благодаря типизации, Swift позволяет использовать удобные фреймворки с помощью которых можно очень коротко описать граф зависимостей. Сегодня я хочу немного рассказать об одном из таких фреймворков — DITranquillity.


В данном туториале будут рассмотрены следующие возможности библиотеки:


  • Регистрация типов
  • Внедрение с помощью инициализатора
  • Внедрение в переменную
  • Циклические зависимости компонентов
  • Использование библиотеки с UIStoryboard

Описание компонентов


Приложение будет состоять из следующих основных компонентов: ViewController, Router, Presenter, Networking — это довольно общие компоненты в любом iOS приложении.


Component Structure

ViewController и Router будут внедряться друг в друга циклически.


Подготовка


Для начала создадим Single View Application в XCode, добавим DITranquillity с помощью CocoaPods. Создадим необходимую иерархию файлов, затем добавим на Main.storyboard второй контроллер и соединим его с помощью StoryboardSegue. В итоге должна получиться следующая структура файлов:


File Structure

Создадим зависимости в классах следующим образом:


Объявление компонентов
protocol Presenter: class {
    func getCounter(completion: @escaping (Int) -> Void)
}

class MyPresenter: Presenter {

    private let networking: Networking

    init(networking: Networking) {
        self.networking = networking
    }

    func getCounter(completion: @escaping (Int) -> Void) {
        // Implementation
    }
}

protocol Networking: class {
    func fetchData(completion: @escaping (Result<Int, Error>) -> Void)
}

class MyNetworking: Networking {
    func fetchData(completion: @escaping (Result<Int, Error>) -> Void) {
        // Implementation
    }
}

protocol Router: class {
    func presentNewController()
}

class MyRouter: Router {
    unowned let viewController: ViewController

    init(viewController: ViewController) {
        self.viewController = viewController
    }

    func presentNewController() {
        // Implementation
    }
}

class ViewController: UIViewController {
    var presenter: Presenter!
    var router: Router!
}

Ограничения


В отличие от других классов, ViewController создается не нами, а библиотекой UIKit внутри реализации UIStoryboard.instantiateViewController, поэтому, пользуясь сторибордом, мы не можем внедрять зависимости в наследников UIViewController с помощью инициализатора. Так же дела обстоят и с наследниками UIView и UITableViewCell.


Заметьте, что во все классы внедряются объекты, скрытые за протоколами. В этом одна из основных задачь внедрения зависимостей — сделать зависимости не от реализаций, а от интерфейсов. Это поможет в будущем предоставить разные реализации протоколов для переиспользования или тестирования компонентов.


Внедрение зависимостей


После того, как все компоненты системы созданы, приступим к связи объектов между собой. В DITranquillity отправной точной является DIContainer, который добавляет в себя регистрации с помощью метода container.register(...). Для разделения зависимостей на части используются DIFramework и DIPart, которые необходимо реализовать. Для удобства создадим только один класс ApplicationDependency, который будет реализовывать DIFramework и будет служить местом регистраций всех зависимостей. Интерфейс DIFramework обязывает реализовать только один метод — load(container:).


class ApplicationDependency: DIFramework {
    static func load(container: DIContainer) {
        // registrations will be placed here
    }
}

Начнём с самой простой регистрации, у которой нет своих зависимостей — MyNetworking


container.register(MyNetworking.init)

Данная регистрация использует внедрение через инициализатор. Несмотря на то, что у самого компонента нет зависимостей, ининциализатор необходимо предоставить, чтобы дать понять библиотеке, как создавать компонент.


Аналогичным образом зарегистрируем MyPresenter и MyRouter.


container.register1(MyPresenter.init)
container.register1(MyRouter.init)

Note: Заметьте, что используется не register, а register1. К сожалению, так необходимо указывать, если объект имеет в инициализаторе одну и только одну зависимость. То есть, если зависимостей 0 или две и больше, необходимо использовать просто register. Данное ограничение является багом Swift версии 4.0 и больше.


Пришла пора регистрировать наш ViewController. Он внедряет объекты не через инициализатор, а напрямую в переменную, поэтому описание регистрации получится чуть больше.


container.register(ViewController.self)
    .injection(cycle: true, \.router)
    .injection(\.presenter)

Синтаксис вида \.presenter является SwiftKeyPath, благодаря которому можно лаконично внедрить зависимость. Так как Router и ViewController циклически зависят друг от друга, необходимо явно это указать с помощью cycle: true. Библиотека и сама может разрешить эти зависимости без явного указания, но данное требование было введено, чтобы человек, читающий граф, сразу понимал, что в цепочке зависимостей есть циклы. Так же обратите внимание, что используется НЕ ViewController.init, но ViewController.self. Об этом писалось выше в разделе Ограничения.


Также необходимо зарегистрировать UIStoryboard с помощью специального метода.


container.registerStoryboard(name: "Main")

Теперь у нас описан весь граф зависимостей для одного экрана. Но доступа к этому графу пока нет. Необходимо создать DIContainer, позволяющий получить доступ к объектам в нём.


static let container: DIContainer = {
    let container = DIContainer() // 1
    container.append(framework: ApplicationDependency.self) // 2
    assert(container.validate(checkGraphCycles: true)) // 3
    return container
}()

  1. Инициализируем контейнер
  2. Добавляем описание графа к нему
  3. Проверяем, что мы всё сделали правильно. Если допущена ошибка, приложение упадёт не во время резолва зависимостей, а сразу при создании графа

Затем необходимо сделать контейнер отправной точкой старта приложения. Для этого в AppDelegate реализовываем метод didFinishLaunchingWithOptions вместо указания Main.storyboard как точко запуска в настройках проекта.


func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    window = UIWindow(frame: UIScreen.main.bounds)
    let storyboard: UIStoryboard = ApplicationDependency.container.resolve()
    window?.rootViewController = storyboard.instantiateInitialViewController()
    window?.makeKeyAndVisible()
    return true
}

Запуск


При первом запуске произойдёт падение и валидация не пройдёт по следующим причинам:


  • Контейнер не найдёт типы Router, Presenter, Networking, потому что мы зарегистрировали только объекты. Если мы хотим дать доступ не к реализациям, а к интерфейсам, необходимо явно указать интерфейсы
  • Контейнер не понимает, как ему разрешить циклическую зависимость, потому что необходимо явно указать, какие объекты при резолве графа не должны каждый раз пересоздаваться

Исправить первую ошибку просто — есть специальный метод, позволяющий указать, под какими протоколами доступен метод в контейнере.


container.register(MyNetworking.init)
    .as(check: Networking.self) {$0}

Описывая регистрацию так, мы говорим: объект MyNetworking доступен по протоколу Networking. Так нужно сделать для всех объектов, спрятанных под протоколами. {$0} добавляем для правильной проверки типов компилятором.


Со второй ошибкой чуть сложнее. Необходимо использовать так называемые scope, которые описывают, как часто создаётся и сколько живеёт объект. Для каждой регистрации, участвующей в циклической зависимости, необходимо указать scope равный objectGraph. Это даст понять контейнеру, что во время резолва необходимо переиспользовать одни и те же созданные объекты, а не создавать каждый раз заного. Таким образом, получится:


container.register(ViewController.self)
    .injection(cycle: true, \.router)
    .injection(\.presenter)
    .lifetime(.objectGraph)

container.register1(MyRouter.init)
    .as(check: Router.self) {$0}
    .lifetime(.objectGraph)

После повторного запуска контейнер успешно проходит валидацию и откроется наш ViewController с созданными зависимостями. Можете поставить брейкпоинт во viewDidLoad и удостовериться.


Переход между экранами


Далее создадим два небольших класса SecondViewController и SecondPresenter, добавим SecondViewController на сториборд и создадим между ними Segue с идентификатором "RouteToSecond", позволяющий открыть второй контроллер из первого.


Добавим в наш ApplicationDependency ещё две регистрации для каждого из новых классов:


container.register(SecondViewController.self)
    .injection(\.secondPresenter)

container.register(SecondPresenter.init)

Указывать .as нет необходимости, потому что мы не прятали SecondPresenter за протоколом, а пользуемся непосредственно реализацией. Затем в методе viewDidAppear первого контроллера вызываем performSegue(withIdentifier: "RouteToSecond", sender: self), запускаем, открывается второй контроллер, в котором котором должна быть проставлена зависимость secondPresenter. Как видно, контейнер увидел создание второго контроллера из UIStoryboard и успешно проставил зависимости.


Заключение


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


Ссылки


Полный пример кода в библиотеке на github


DITranquillity на github


Статья на английском

Поделиться публикацией

Комментарии 2

    0
    Note: Заметьте, что используется не register, а register1. К сожалению, так необходимо указывать, если объект имеет в инициализаторе одну и только одну зависимость.

    Эту проблему можно решить, используя в получаемой функции кортеж из n-элементов вместо n-аргументов.


    func register<D, R>(_ factory: @escaping (D) throws -> R) {
        print("register 1")
    }
    
    func register<D1, D2, R>(_ factory: @escaping ((D1, D2)) throws -> R) {
        print("register 2")
    }
    
    func myFunc(arg1: String, arg2: String) -> String {
        return arg1 + arg2
    }
    
    register(myFunc)
    
    register { (arg1: Int, arg2: Int) -> Int in
        return arg1 + arg2
    }

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


    Примеры использования этого приёма в других DI-фреймворках: 1, 2, 3

      0
      От автора библиотеки — большое спасибо за совет. Что-то я не подумал об этом. Правда на то есть причины — apple в 3 версии языка избавился от того что несколько параметров и картёж от нескольких параметров это одно и тоже. И в 3 версии это работало, а в 4 что-то снова поправили и сломали, но как-то частично…

      Собственно говоря, надо убедиться что в 3 версии со старым Xcode данный код также работает.

      И ещё раз спасибо — написать #if в крайнем случае на версию всегда можно.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое