Главная » Хабрахабр » Инверсия управления в iOS

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

image

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

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

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

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

Несмотря на то, что статья о DI, начнем мы свой путь не с него, а с IoC, по той причине, что DI -это лишь один из видов 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. Чтобы не допустить такого исхода событий, придумано множество принципов и техник.

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

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

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

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

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

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

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

class Armor }

и 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. Теперь перед нами встает вопрос: как, где и когда забрать зависимости из «фабрики» и передать нашим классам.

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

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

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

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


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 контейнера, который является «фабрикой» или набором «фабрик».

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

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 и доступных на данный момент фреймворках.

Этот подход используется в случае, когда класс не может существовать без своих зависимостей, но даже если это не так, то его можно использовать для более явного определения контракта класса. 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() } }

Этот способ очень похож на Property Injection, но с его помощью можно внедрить временную зависимость только на момент выполнения какого-либо действия или более тесно связать внедрение зависимости с логикой класса. Method 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)

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

Для этого мы закроем зависимости протоколами, и только «фабрики» будут знать, какая же конкретно кроется реализация за этим протоколом. Простое использование 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.

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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Из песочницы] Внедрение зависимостей в сервис Apache Ignite.NET

Разрабатывая различные приложения, использующие популярную библиотеку Castle Windsor для внедрения зависимостей и Apache Ignite.NET в качестве «ключика», который открывает дверь в «облачные» вычисления, я столкнулся с небольшим неудобством: у меня не было никакой возможности внедрить зависимость в сервис, запускаемый через ...

[recovery mode] Сравнение станков лазерной резки Raylogic 11G и Raylogic V12

Всем добрый день! Сегодня мы решили сделать для вас сравнение станков лазерной резки Raylogic 11G и Raylogic V12. С вами компания 3Dtool. Ну, а если говорить конкретней, то мы расскажем вам об изменениях в лазерных станках Raylogic. Как оказалось, при ...