Инверсия управления в iOS

  • Tutorial

image


Евгений Ёлчев rsi, iOS-тимлид KODE


В последнее время я все чаще слышу о DI. Им интересуются мои студенты в Geek University, его упоминают в чатах. Хотя паттерн далеко не молод, многие не совсем верно его понимают.
Часто под DI подразумевают фреймворк, например, typhoon или swinject. В статье подробно разберем принципы реализации DI, а также принцип IoC. Если интересно, прошу под кат.


DI (внедрение зависимости, англ. Dependency injection) — процесс предоставления внешней зависимости программному компоненту. Является специфичной формой «IoC», когда она применяется к управлению зависимостями. В полном соответствии с принципом единственной ответственности объект отдаёт заботу о построении требуемых ему зависимостей внешнему, специально предназначенному для этого общему механизму.

IoC (Инверсия управления, англ. Inversion of Control) — важный принцип объектно-ориентированного программирования, используемый для уменьшения зацепления (связанности) в компьютерных программах.

Несмотря на то, что статья о DI, начнем мы свой путь не с него, а с IoC, по той причине, что DI -это лишь один из видов IoC и картину нужно видеть целиком.


IoC


Для начала разберемся с тем, что такое управление. Возьмем самый простой пример — консольный «Hello world»:


let firstWord = «hello»
let secondWord = "world!"
let phrase = firstWord + " " + secondWord
print(phrase)

В данном примере наши команды управляют данными, которые представлены строковыми литералами и переменными. На этом уровне абстракции больше нет никакого управления, но мы можем его добавить с помощью тернарного оператора:


let number = arc4random_uniform(1)
let firstWord = number == 0 ? "hello" : "bye"
let secondWord = "world!"
let phrase = firstWord + " " + secondWord
print(phrase)

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


В типичном iOS-приложении управление находится повсюду. Система, пользователь, сервер управляют приложением. Приложение управляет сервером, пользователем и системой. Наш код содержит огромное количество объектов, которые тоже управляют друг другом. Например, объект класса AuthViewController может управлять объектом класса AuthService.


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


Есть мнение, что ничего страшного в таких зависимостях нет. Как правило, наши контроллеры не переиспользуются и идут рука об руку вместе со своими сервисами все время поддержки приложения. Но те, кто занимался поддержкой долгоживущих приложений, знает, что это не так. Требования постоянно меняются, мы находим баги, меняем flow, делаем редизайн. Если при этом ваше приложение сложнее чем несколько контроллеров с парой кнопок и сервисов, которые просто обертки для URLSession, то оно тонет в зависимостях. Зависимости между классами образуют паутину, иногда можно обнаружить циклические зависимости. Вы не можете вносить изменения в ваши классы, потому что не ясно, как и где они используются, вам проще создать новый метод, чем изменить старый. Замена класса и вовсе превращается в боль. Вызов его конструктора раскидан по различным методам, которые вы тоже вынуждены изменять. В конце концов, вы перестаете понимать, что происходит, код превращается в обычный текст и, вооружившись поиском, вы начинаете заменять слова или предложения в этом тексте, проверяя только ошибки компилятора.


Чтобы не допустить такого исхода событий, придумано множество принципов и техник. Например, один из принципов SOLID принцип DIP описывает, как уменьшить связанность при вызове методов и это является IoC.


DIP (принцип инверсии зависимостей, англ. dependency inversion principle) — один пяти из принципов SOLID.

Формулировка:

Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Но все же, когда кто-то говорит «IoC», он имеет ввиду инверсию управления при создании зависимостей. Далее я буду использовать его только в этом значении. Кстати, DIP практически невозможно реализовать без IoC, но не наоборот. Использование IoC не гарантирует соблюдение DIP. Еще один важный нюанс. DIP и DI — это разные принципы.


На пути к IoC


На самом деле, IoC — это очень простая концепция, и не нужно читать много литературы, уходить на несколько лет в Тибет, чтобы постичь дзен и начать ее использовать.


В качестве примера я буду рассматривать класс «рыцаря» (Knight) и его «доспехов» (Armor), все классы показаны ниже.
image
Теперь посмотрим на реализацию класса Armor


class Armor {
    private var boots: Boots?
    private var pants: Pants?
    private var belt: Belt?
    private var chest: Сhest?
    private var bracers: Bracers?
    private var gloves: Gloves?
    private var helmet: Helmet?

    func configure() {
        self.boots = Boots()
        self.pants = Pants()
        self.belt = Belt()
        self.chest = Сhest()
        self.bracers = Bracers()
        self.gloves = Gloves()
        self.helmet = Helmet()
    }

}

и Knight


class Knight {

    private var armor: Armor?

    func prepareForBattle() {
        self.armor = Armor()
        self.armor.configure()
    }

}

На первый взгляд — все хорошо. Если нам понадобится рыцарь, мы просто его создадим.


let knight = Knight()

Но не все так просто. К сожалению, суррогатные примеры не могут передать всю боль, которую несет такой подход.


Наши классы спаяны вместе. В методе make у Armor создается 7 классов. Это делает классы закостенелыми. При таком подходе мы не можем просто определить, где и как создается класс. Если потребуется отнаследоваться от брони и создать, например, парадную броню, заменив шлем, нам придется переопределять весь метод.


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


Вот небольшой пример, как это может выглядеть в жизни:



class FightViewController: BaseViewController {

    var titleLabel: UIView!
    var knightList: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.title = "Турнир"
        // Далее в коде смешаны не связанные действия, что затрудняет изменение их по отдельности
        // Создание зависимости
        let backgroundView = UIView()
        // Добавление на экран
        self.view.addSubview(backgroundView)
        // Настройка внешнего вида
        backgroundView.backgroundColor = UIColor.red
        // Настройка позиционирования
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        backgroundView.topAnchor.constraint(equalTo: topAnchor).isActive = true
        backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true

        let title = Views.BigHeader.View()
        self.titleLabel = title
        title.labelView.text = "labelView"
        self.view.addSubview(title)
        title.translatesAutoresizingMaskIntoConstraints = false
        title.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        title.topAnchor.constraint(equalTo: topAnchor).isActive = true
        title.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        title.heightAnchor.constraint(equalToConstant: 56).isActive = true

        let knightList = Views.DataView.View()
        self.knightList = knightList
        knightList.titleView.text = "knightList"
        knightList.dataView.text = ""

        self.view.addSubview(knightList)
        knightList.translatesAutoresizingMaskIntoConstraints = false
        knightList.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        knightList.topAnchor.constraint(equalTo: title.topAnchor).isActive = true
        knightList.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        knightList.heightAnchor.constraint(equalToConstant: 45).isActive = true
    }

}

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


Как это можно улучшить? Воспользоваться паттерном «фабричный метод». Он не решит всех проблем, но сделает класс более гибким.


Фабричный метод (англ. Factory Method, также известен как Виртуальный конструктор (англ. Virtual Constructor)) — порождающий шаблон проектирования, предоставляющий подклассам интерфейс для создания экземпляров некоторого класса.

class Armor {
    private var boots: Boots?
    private var pants: Pants?

    func configure() {
        self.boots = makeBoots()
        self.pants = makePants()
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }
}

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


Но все же, нашему классу не стоит знать о подробностях создания своих зависимостей, он должен просто использовать их. Как с этим бороться? Необходимо выносить порождающую логику из класса на уровень выше.


Порождающая логика — код, создающий экземпляры класса или структуры. Другими словами — код, порождающий объекты.

class Armor {
    private var boots: Boots?
    private var pants: Pants?

    func configure(boots: Boots?, pants: Pants?) {
        self.boots = boots
        self.pants = pants
    }
}

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


Но вот у нашего класса Knight дела идут не так хорошо.


class Knight {

    private var armor: Armor?

    func preapreForBattle() {
        self.armor = Armor()
        let boots = makeBoots()
        let pants = makePants()
        self.armor?.make(boots: boots, pants: pants)
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }

}

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


На помощь нам придет другой порождающий паттерн — «фабрика».


Фабрика (англ. Factory) — объект, создающий другие объекты.

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


class Forge {
    func makeArmor() -> Armor {
        let armor = Armor()
        armor.boots = makeBoots()
        armor.pants = makePants()
        return armor
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }
}

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



class Armor {
    var boots: Boots?
    var pants: Pants?
}

class Knight {

    var armor: Armor?

}

Теперь перед нами встает вопрос: как, где и когда забрать зависимости из «фабрики» и передать нашим классам. А, значит, мы наконец пришли к понятиям DI и SL.


Сервис локатор (SL)


Начнем, пожалуй, с этого паттерна. Во-первых, он проще. Во-вторых, многие думают, что это и есть DI, хотя это не так.


SL (сервис локатор, англ. service locator) — это шаблон проектирования, используемый в разработке программного обеспечения для инкапсуляции процессов, связанных с получением какого-либо сервиса с сильным уровнем абстракции. Этот шаблон использует центральный реестр, известный как «локатор сервисов», который по запросу возвращает информацию (как правило, это объекты), необходимую для выполнения определенной задачи.

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


В этом случае наши классы будут выглядеть так:



class Forge {

    func makeArmor() -> Armor {
        let armor = Armor(forge: self)
        return armor
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }
}


class Knight {

    private let forge: Forge
    private var armor: Armor?

    init(forge: Forge) {
        self.forge = forge
        configure()
    }

    private func configure() {
        armor = forge.makeArmor()
    }
}

class Armor {

    private let forge: Forge

    private var boots: Boots?
    private var pants: Pants?

    init(forge: Forge) {
        self.forge = forge
        configure()
    }

    private func configure() {
        boots = forge.makeBoots()
        pants = forge.makePants()
    }

}

let forge = Forge()
let knight = Knight(forge: forge)

Лично у меня такой подход вызывает двоякое чувство. С одной стороны, порождающая логика находится в «фабрике», с другой стороны, процесс получения зависимостей несколько запутан. Но самый главный недостаток состоит в том, что, глядя на класс, нельзя однозначно определить его зависимости. Он может получить из «фабрики» все что угодно, типичной ошибкой разработки является создание одной такой «фабрики» на все приложение. При этом «фабрика» превращается в огромную свалку барахла и порождает искушение доставать внутри классов то, что им на самом деле не нужно. У классов пропадает контакт, ограничения.


Можно представить, что нашему рыцарю подарили сундук с сокровищами, из которого он может достать необходимую ему броню, но в довесок никто не помешает ему набрать ненужных украшений.
Именно по этой причине этот паттерн пересек черту добра и зла и превратился в антипаттерн. Если у вас есть выбор между DI и SL, всегда выбирайте DI.


DI


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


Суть этого паттерна заключается в том, что зависимости внедряются в класс извне, при этом граф зависимостей строится внутри DI контейнера, который является «фабрикой» или набором «фабрик».


Наши классы при этом выглядят так:


class Armor {
    var boots: Boots?
    var pants: Pants?
}

class Knight {
    var armor: Armor?
}

class Forge {

    func makeArmor() -> Armor {
        let armor = Armor()
        armor.boots = makeBoots()
        armor.pants = makePants()
        return armor
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }

}


class Garrison {
    lazy var forge: Forge = {
        return Forge()
    }()

    func makeKnight() -> Knight {
        let knight = Knight()
        knight.armor = forge.makeArmor()
        return knight
    }
}

let garrison = Garrison()
let knight = garrison.makeKnight()

В данном случае классы выглядят чистыми, в них полностью отсутствует порождающая логика. Всю ответственность по сборке на себя взяли две «фабрики»: Garrison и Forge. При желании количество этих «фабрик» можно увеличивать, чтобы не допускать разрастания классов. Хорошей практикой является создание «фабрики», ответственной за создание каких-либо родственных объектов. Например, эта «фабрика» может создать сервисы, контроллеры для конкретной user story.


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


Типы DI


Initializer Injection — внедрение зависимостей через конструктор. Этот подход используется в случае, когда класс не может существовать без своих зависимостей, но даже если это не так, то его можно использовать для более явного определения контракта класса. Если все зависимости объявлены в качестве аргументов конструктора, определить их проще простого. Но не стоит увлекаться, если у класса десяток зависимостей, то лучше не передавать их в конструкторе (а еще лучше разобраться, зачем вашему классу столько зависимостей).



class Armor {
    let boots: Boots
    let pants: Pants

    init(boots: Boots, pants: Pants) {
        self.boots = boots
        self.pants = pants
    }
}

class Forge {

    func makeArmor() -> Armor {
        let boots = makeBoots()
        let pants = makePants()
        let armor = Armor(boots: boots, pants: pants)

        return armor
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }

}

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


class Armor {
    var boots: Boots?
    var pants: Pants?
}

class Forge {

    func makeArmor() -> Armor {
        let armor = Armor()
        armor.boots = makeBoots()
        armor.pants = makePants()
        return armor
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }

}

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


class Knight {
    private var armor: Armor?

    func winTournament(armor: Armor) {
        self.armor = armor
        defeatEnemy()
        seducePrincess()
        self.armor = nil
    }

    func defeatEnemy() {}

    func seducePrincess() {}
}

class Garrison {
    lazy var forge: Forge = {
        return Forge()
    }()

    func makeKnight() -> Knight {
        let knight = Knight()
        return knight
    }
}

let garrison = Garrison()
let knight = garrison.makeKnight()

let armor = garrison.forge.makeArmor()
knight.winTournament(armor: armor)

По моим наблюдениям наиболее распространенными типами являются Initializer Injection и Property Injection, реже используется Method Injection. И хотя я описал типичные случаи выбора того или иного типа, надо помнить, что Swift является очень гибким языком, предоставляя больше возможностей для выбора типа. Так, например, даже имея необязательные зависимости, можно реализовать конструктор с опциональными аргументами и nil по умолчанию. В таком случае можно использовать Initializer Injection вместо Property Injection. В любом случае это компромисс, который может улучшить или ухудшить ваш код, и выбор остается за вами.


DIP


Простое использование IoC, как в примерах выше, само по себе приносит неплохие дивиденды, но можно пойти дальше и добиться соблюдения принципа DIP из SOLID. Для этого мы закроем зависимости протоколами, и только «фабрики» будут знать, какая же конкретно кроется реализация за этим протоколом.



class Knight {
    var armor: AbstractArmor?
}

class Forge {

    func makeArmor() -> AbstractArmor {
        let armor = Armor()
        armor.boots = makeBoots()
        armor.pants = makePants()
        return armor
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }

}

В этом случае мы можем без проблем подменять реализацию брони на альтернативную.


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


Области видимости


Само по себе управление областью видимости объектов не является частью IoC концепции, это скорее детали ее реализации, но тем не менее это очень мощный механизм, который позволяет отказаться от синглтонов и решить другие проблемы с общими зависимостями. Область видимости определяет, как долго будет жить зависимость, созданная внутри «фабрики», будет ли оно создаваться каждый раз заново или сохраняться после первого создания и просто передаваться по ссылке.


Так как области видимости не описаны в паттернах, каждый реализует и именует их как считает нужным. Мы рассмотрим два наиболее часто используемых типа.


Стандартная область видимости — это то поведение, какое мы реализовали во всех примерах выше. «Фабрика» создает объект, отдает его и забывает о его существовании. При повторном вызове фабричного метода будет создан новый объект.


Область видимости контейнера — это поведение схожее с синглтоном. При первом вызове фабричного метода создается новый объект, затем «фабрика» сохраняет ссылку на него и возвращает как результат работы фабричного метода, при всех остальных вызовах метода новый объект не создается, а возвращается ссылка на первый объект.


class Forge {
    private var armor: AbstractArmor?

    func makeArmor() -> AbstractArmor {
        // Если броня уже создана ранее вернеем ее
        if let armor = self.armor { return armor }
        let armor = Armor()
        armor.boots = makeBoots()
        armor.pants = makePants()
        self.armor = armor
        return armor
    }

    func makeBoots() -> Boots {
        return Boots()
    }

    func makePants() -> Pants {
        return Pants()
    }

}

Как вы видите, в примере выше броня создается только один раз, во всех остальных случаях возвращается ранее созданный экземпляр. Аналогично синглтону, мы всегда будем работать с одним и тем же экземпляром класса, при этом без глобальной области видимости.


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


Как и любые другие принципы в программировании IoC не является серебряной пулей, у него есть свои плюсы:


  • Уменьшает связанность классов;
  • Проще переиспользовать классы;
  • Более компактные классы за счет выноса поражающей логики;
  • Инкапсулирует порождающую логику, что делает ее рефакторинг проще;
  • Скрывает реализацию;
  • Упрощает замену реализации;
  • Упрощает тестирование: подменив “фабрики”, можно заменить зависимости моками;
  • Позволяет шарить объекты в приложении без использования синглтона.

И минусы:


  • Увеличивает количество классов при сокрытии реализации за абстракцией;
  • Увеличивает время погружения в проект;
  • Легко может привести к оверинжинирингу.

Хотя мое мнение что, главный и единственный минус — это оверинжиниринг в результате безудержного желания четко следовать принципу DIP. Часто можно увидеть, как создается пачка классов, закрывается вдвое более пухлой пачкой протоколов, и это добро проксирует вызов одного метода.


Если вы подумаете, что хорошо бы создать протокол, сначала подумайте зачем. У вас есть взаимозаменяемые классы, которые необходимо спрятать за общим интерфейсом? Вам требуется сокрыть реализацию, а нельзя обойтись просто модификатором доступа? Для тестирования? Может, для мока хватит простого наследования?


Подводя итог


На мой взгляд, соблюдение принципов IoC является обязательным условием при разработке проекта, который будет поддерживаться, а не просто забыт после релиза. Если мы заглянем за пределы iOS-песочницы, то обнаружим, что в android-разработке использование DI, воплотившегося в виде фреймворка dagger, стало почти стандартом. В мире бэкенда, например, в spring управление зависимостями лежит в основе всей архитектуры фреймворка. Даже php-фреймворки, такие как, например, Laravel призывают использовать DI и предоставляют необходимые инструменты из коробки. В iOS, к сожалению, так и не появилось ни коробочного решения, ни фреймворка, который бы стал стандартом. Да для Objective-C можно считать таковым тайфун, но не для swift.


К счастью, вам необязательно использовать именитый фреймворк. Одной из целей этой статьи как раз было показать, что IoC — это не фрейворк, и то, что если в проекте нет тайфуна, это не значит, что там нет DI. Для реализации IoC в проекте неважно, выберете вы DI или SL, достаточно обычной «фабрики», которую вполне можно написать самому. Такая «фабрика» является самым простым DI контейнером.

Redmadrobot
№1 в разработке цифровых решений для бизнеса

Похожие публикации

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

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

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