Бюджетный DI на антипаттернах

в 5:03, , рубрики: Блог компании Tinkoff.ru

image

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

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

Советую запастись дошираком и энергетиками. Перед вами — серия статей о том, как я ни в чем себе не отказывал, реализуя MVVM в одном из своих домашних проектов. Сегодня пилотный выпуск — про управление зависимостями.

Введение

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

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

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

Хорошее содержание

Принципы

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

  1. Не выпендривайся. Тупой и понятный код в большинстве случаев лучше умного и непонятного.
  2. Будь краток. Кода должно быть настолько мало, чтобы его не жалко было в любой момент выкинуть и написать заново за один день.
  3. Удобство превыше правил. Если можно облегчить себе жизнь, пожертвовав принципами SOLID, пожертвуй принципами SOLID.
  4. Получай удовольствие. Если есть разные варианты решения проблемы, выбирай более веселый.

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

Проблема управления зависимостями

Проблема управления зависимостями довольно типичная в программировании. Мало какая сущность в коде может похвастаться независимостью как твоя бывшая. Обычно все от кого-нибудь зависят. В MVVM, например, вью-контроллер зависит от вью-модели, которая подготавливает для него данные. Вью-модель зависит от сервиса, который за этими данными ходит в сеть. Сервис зависит от другого сервиса — низкоуровневой реализации сети, и так далее. Все эти сущности, которых может быть великое множество, нужно где-то создавать и как-то доставлять до потребителей. Для любой типичной проблемы, как правило, есть типичное решение — паттерн. В случае с проблемой управления зависимостями таким паттерном является Dependency Injection (DI) контейнер.

У меня нет намерения подробно объяснять, что такое DI-контейнер. Про это классно рассказывают в двух статьях из репозитория Ninject: раз, два (уберите от экрана детей, там код на С#). Еще есть небольшое объяснение в репозитории самого популярного DI-контейнера под iOS — Swinject (заметили, что Swinject — это Ninject на Swift?). Хардкорщикам могу предложить статью Фаулера от 2004 года.

Тем не менее не могу отказать себе в удовольствии немного поумничать и скажу, что DI-контейнер — это такая шляпа, из которой как кролика за уши можно достать практически любую сущность вашей программы. Если эта сущность зависит от других сущностей, а те, в свою очередь, еще от каких-то — DI-контейнер знает, что со всем этим графом зависимостей делать. Если у вас в проекте есть DI-контейнер, то на извечный вопрос «как мне прокинуть зависимость A до сущности B» всегда будет один и тот же ответ: «сущность B следует достать из контейнера, который сам рекурсивно разрешит все ее зависимости».

Решение

Существует несколько довольно популярных реализаций DI-контейнеров под iOS (Swinject, Cleanse, Dip, DITranquility, EasyDI), но использовать чужую реализацию, согласитесь, скучно. Гораздо веселее использовать мою.

Готовы немного развлечься и написать DI-контейнер с нуля? Похожую реализацию мне показал однажды один из самых крутых iOS-разработчиков, простой сибирский парень teanet, за что ему огромное спасибо. Я ее немного переосмыслил и готов поделиться с вами. Начнем с протокола IContainer:

protocol IContainer: AnyObject {
    func resolve<T: IResolvable>(args: T.Arguments) -> T
}

Привычка из прошлой жизни — я всегда пишу I перед протоколами. Буква I значит interface. У нашего интерфейса протокола всего один метод resolve(args:), который от нас принимает какие-то аргументы T.Arguments, а взамен возвращает экземпляр типа T. Как видно, не любая сущность может быть Т. Чтобы стать полноправным T, нужно реализовать IResolvable. IResolvable — это еще один протокол, о чем нам услужливо подсказывает буква I в начале имени. Он выглядит вот так:

protocol IResolvable: AnyObject {
    associatedtype Arguments

    static var instanceScope: InstanceScope { get }
    init(container: IContainer, args: Arguments)
}

Все кролики, которые хотят быть доступны из шляпы, обязаны реализовать IResolvable.

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

Свойство instanceScope отвечает за область видимости, в которой будет существовать экземпляр объекта:

enum InstanceScope {
    case perRequest
    case singleton
}

Это довольно стандартная для DI-контейнеров штуковина. Значение perRequest означает, что для каждого вызова resolve(args:) будет создан новый экземпляр T. Значение singleton означает, что экземпляр T будет создан единожды — при первом вызове resolve(args:). При последующих вызовах resolve(args:) в случае singleton будет отдаваться закэшированная копия.

С протоколами разобрались, приступаем к реализации:

class Container {
    private var singletons: [ObjectIdentifier: AnyObject] = [:]

    func makeInstance<T: IResolvable>(args: T.Arguments) -> T {
        return T(container: self, args: args)
    }
}

Тут ничего особенного: кэш синглтонов будем хранить в виде словаря singletons. Ключом словаря нам послужит ObjectIdentifier — это стандартный тип, поддерживающий Hashable и представляющий собой уникальный идентификатор объекта ссылочного типа (через него, кстати, реализован оператор === в Swift). Метод makeInstance(args:) умеет на лету создавать любые экземпляры T благодаря тому, что мы обязали все T реализовать один и тот же инициализатор.

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

extension Container: IContainer {
    func resolve<T: IResolvable>(args: T.Arguments) -> T {
        switch T.instanceScope {
        case .perRequest:
            return makeInstance(args: args)
        case .singleton:
            let key = ObjectIdentifier(T.self)
            if let cached = singletons[key], let instance = cached as? T {
                return instance
            } else {
                let instance: T = makeInstance(args: args)
                singletons[key] = instance
                return instance
            }
        }
    }
}

Здесь все довольно прозаично: если T хочет быть perRequest, сразу возвращаем новый экземпляр. В противном случае нужно залезть в кэш. Что в кэше найдем — достаем, отдаем, чего в кэше не найдем — создаем, в кэш кладем, отдаем.

Вот, собственно, и все. Мы только что написали свой DI-контейнер в 50 строк кода. Но как этой штукой вообще пользоваться? Да очень просто.

Пример использования

Для примера рассмотрим хрестоматийную историю с клиентами и их заказами. Пусть мы хотим отобразить список заказов конкретного клиента на определенную дату. Заведем для наших целей две сущности: OrdersProvider и OrdersVM — эти ребята должны быть доступны из контейнера, а значит, им придется реализовать IResolvable.

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

Полезный экстеншен номер раз:

protocol ISingleton: IResolvable where Arguments == Void { }
extension ISingleton {
    static var instanceScope: InstanceScope {
        return .singleton
    }
}

И второй такой же, но другой:

protocol IPerRequest: IResolvable { }
extension IPerRequest {
    static var instanceScope: InstanceScope {
        return .perRequest
    }
}

Теперь вместо IResolvable можно конформить более лаконичным ISingleton/IPerRequest и сэкономить тем самым несколько секунд жизни, потратив их на саморазвитие. А вот и реализация OrdersProvider подъехала:

class OrdersProvider: ISingleton {
    required init(container: IContainer, args: Void) { }

    func loadOrders(for customerId: Int, date: Date) {
        print("Loading orders for customer '(customerId)', date '(date)'")
    }
}

Мы предоставили required init, как того требует протокол, но, так как OrdersProvider ни от чего не зависит, этот инициализатор у нас пустой. Каждый раз, когда мы будем доставать OrdersProvider из контейнера, мы будем получать один и тот же экземпляр, потому что такова дефолтная реализация instanceScope для ISingleton.

А вот и модель представления собственной персоной:

final class OrdersVM: IPerRequest {
    struct Args {
        let customerId: Int
        let date: Date
    }

    private let ordersProvider: OrdersProvider
    private let args: Args

    required init(container: IContainer, args: Args) {
        self.ordersProvider = container.resolve()
        self.args = args
    }

    func loadOrders() {
        ordersProvider.loadOrders(for: args.customerId, date: args.date)
    }
}

Эта вью-модель не может существовать без аргументов OrdersVM.Args, которые мы получаем через required init. В этот инициализатор также попадает сам контейнер, из которого мы без лишней суеты извлекаем экземпляр OrdersProvider посредством вызова resolve().

Вызов метода loadOrders() использует ordersProvider для загрузки заказов, предоставляя ему необходимые для работы аргументы. Каждый раз, когда мы будем доставать OrdersVM из контейнера, мы будем получать новый экземпляр, потому что такова дефолтная реализация instanceScope для IPerRequest.

Финальный штрих. Чтобы получить готовый экземпляр вью-модели, просто достанем его из контейнера, вот так:

let container = Container()
let viewModel: OrdersVM = container.resolve(args: .init(customerId: 42, date: Date()))
viewModel.loadOrders()

Для справки, на момент написания этих строк, код выше производит следующий консольный вывод:

Loading orders for customer '42', date '2020-04-22 17:41:49 +0000'

Критика

Использовать или не использовать DI-контейнер из этой статьи в вашем проекте — решать не мне. Как ответственный автор, я могу всего лишь предлагать варианты и рассказывать о плюсах и минусах одинаково объективно.

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

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

final class OrdersVM {
    private let ordersProvider: IOrdersProvider
    init(ordersProvider: IOrdersProvider) {
       self.ordersProvider = ordersProvider
    }
}

Если вы осмелились использовать Service Locator, тогда ваша сущность, вероятно, достает зависимости из какого-нибудь сомнительного места типа статической фабрики. Например, вот так:

final class OrdersVM {
    private let ordersProvider: IOrdersProvider
    init() {
        self.ordersProvider = ServiceLocator.shared.resolve()
    }
}

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

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

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

Все наши сущности, если приглядеться, вообще ни капельки не зависят от абстракций. Напротив, они сами решают, какую конкретную реализацию своих зависимостей следует использовать. Например, OrdersVM достает из контейнера совершенно конкретный OrdersProvider, а не какой-нибудь протокол IOrdersProvider.

С практической точки зрения это затрудняет подмену одной реализации IOrdersProvider на другую реализацию этого протокола. Между тем, такая подмена может вам пригодиться не только в разработке, но и в рамках рефакторинга, а также при написании юнит-тестов.

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

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

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

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

Вот вам смысл этого многословного раздела в виде двух списков.

Короче, минусы

  • Зависимости достаем в конструкторе прямо из контейнера (Service Locator).
  • Не получится закрыть зависимость протоколом (принцип на букву D).

Короче, плюсы

  • Простая и лаконичная реализация (50 строк кода).
  • Не надо регистрировать зависимости (вообще не надо).
  • Извлечение из контейнера никогда не сломается (совсем никогда).
  • Нельзя передать невалидные аргументы (не скомпилируется).

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

One More Thing: автоматическое внедрение зависимостей через обертки свойств

В 2019 году в компании Apple придумали инкапсулировать повторяющуюся логику гетеров и сетеров в переиспользуемые атрибуты и назвали это обертками свойств (property wrappers). С помощью таких оберток ваши свойства волшебным образом могут получить новое поведение: запись значения в Keychain или UserDefaults, потокобезопасность, валидацию, логирование — да много чего.

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

Чтобы написать свою обертку свойства в минимальной комплектации, нужно создать класс или структуру, предоставить свойство wrappedValue и пометить все это дело атрибутом @propertyWrapper:

@propertyWrapper
struct Resolvable<T: IResolvable> where T.Arguments == Void {
    private var cache: T?

    var wrappedValue: T {
        mutating get {
            if let cache = cache {
                return cache
            }
            let resolved: T = ContainerHolder.container.resolve()
            cache = resolved
            return resolved
        }
    }
}

Из этого незамысловатого кода мы видим, что наш property wrapper называется Resolvable. Он работает со всеми типами Т, которые реализуют одноименный протокол и не требуют аргументов при инициализации.

Волшебство происходит при обращении к свойству wrappedValue: мы возвращаем закэшированное значение, если таковое имеется. Если нет — достаем это значение из контейнера и сохраняем в кэш. Чтобы наша обертка получила доступ к контейнеру, пришлось провернуть грязный трюк — поместить контейнер в статическое свойство класса ContainerHolder:

final class ContainerHolder {
    static var container: IContainer!
}

Имея в своем арсенале обертку Resolvable<T>, мы можем применить ее к какой-нибудь зависимости, например к ordersProvider:

@Resolvable
private var ordersProvider: OrdersProvider

Это приведет к тому, что компилятор сгенерирует за нас примерно такой код:

private var _ordersProvider = Resolvable<OrdersProvider>()

var ordersProvider: OrdersProvider {
  get { return _ordersProvider.wrappedValue }
}

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

Теперь знакомая нам модель представления может позволить себе не извлекать из контейнера OrdersProvider в инициализаторе, а просто пометить соответствующее свойство атрибутом @Resolvable. Вот так:

final class OrdersVM: IPerRequest {
    struct Args {
        let customerId: Int
        let date: Date
    }

    @Resolvable
    private var ordersProvider: OrdersProvider
    private let args: Args

    required init(container: IContainer, args: Args) {
        self.args = args
    }

    func loadOrders() {
        ordersProvider.loadOrders(for: args.customerId, date: args.date)
    }
}

Самое время собрать все вместе и порадоваться, что все работает как прежде:

ContainerHolder.container = Container()
let viewModel: OrdersVM = ContainerHolder.container.resolve(
    args: .init(customerId: 42, date: Date()))
viewModel.loadOrders()

Для справки. Этот код производит следующий консольный вывод:

Loading orders for customer '42', date '2020-04-23 18:47:36 +0000'

Бюджетный DI на антипаттернах - 2

Unit-тесты, раздел под звездочкой

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

Чтобы немного разогреться, начнем с первого варианта, который попроще. Допустим, для нужд тестирования мы хотим замокать OrdersProvider, чтобы он не лез в сеть, а отдавал тестовые данные. Для начала закроем его протоколом:

protocol IOrdersProvider {
    func loadOrders(for customerId: Int, date: Date)
}

extension OrdersProvider: IOrdersProvider {}

Теперь во вью-модели можем сделать второй инициализатор, который будет принимать этот протокол:

final class OrdersVM: IPerRequest {
    struct Args {
        let customerId: Int
        let date: Date
    }

    private let ordersProvider: IOrdersProvider
    private let args: Args

    required convenience init(container: IContainer, args: Args) {
        self.init(
            ordersProvider: container.resolve() as OrdersProvider,
            args: args)
    }

    init(ordersProvider: IOrdersProvider, args: Args) {
        self.args = args
        self.ordersProvider = ordersProvider
    }

    func loadOrders() {
        ordersProvider.loadOrders(for: args.customerId, date: args.date)
    }
}

Такой подход позволяет в реальном приложении создавать сущности через контейнер, используя required init, а в тестах пользоваться вторым инициализатором и создавать сущности с замоканными зависимостями.

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

Забегая вперед, скажу, что далее от нас потребуется хранить объекты IResolvable в некоторой коллекции. Однако если мы попробуем сделать это, то столкнемся с суровой действительностью в виде ошибки, до боли знакомой каждому iOS-разработчику: protocol 'IResolvable' can only be used as a generic constraint because it has Self or associated type requirements. Типичный способ как-то справиться с этой ситуацией — налить себе чего-нибудь покрепче и применить механизм с пугающим названием «стирание типов» (type erasure).

Стирание типов предполагает, что вместо неполноценного протокола IResolvable мы будем использовать самую обычную структуру AnyResolvable, не обремененную ассоциированным типом. Такую структуру можно спокойно хранить в массиве, но она, конечно, лишена полезной функциональности IResolvable. Последнее мы компенсируем с помощью замыкания. Внимание на код:

struct AnyResolvable {
    private let factory: (IContainer, Any) -> Any?

    init<T: IResolvable>(resolvable: T.Type) {
        self.factory = { container, args in
            guard let args = args as? T.Arguments else { return nil }
            return T(container: container, args: args)
        }
    }

    func resolve(container: IContainer, args: Any) -> Any? {
        return factory(container, args)
    }
}

Кода здесь немного, но он хитрый. В инициализатор мы принимаем настоящий живой тип T, который не можем никуда сохранить. Вместо этого мы сохраняем замыкание, обученное создавать экземпляры этого типа. Замыкание впоследствии используется по своему прямому назначению в методе resolve(container:args:), который понадобится нам позже.

Вооружившись AnyResolvable, мы можем написать контейнер для unit-тестов в 20 строк, который позволит нам выборочно мокать часть зависимостей. Вот он:

final class ContainerMock: Container {
    private var substitutions: [ObjectIdentifier: AnyResolvable] = [:]

    public func replace<Type: IResolvable, SubstitutionType: IResolvable>(
        _ type: Type.Type, with substitution: SubstitutionType.Type) {

        let key = ObjectIdentifier(type)
        substitutions[key] = AnyResolvable(resolvable: substitution)
    }

    override func makeInstance<T: IResolvable>(args: T.Arguments) -> T {
        return makeSubstitution(args: args) ?? super.makeInstance(args: args)
    }

    private func makeSubstitution<T: IResolvable>(args: T.Arguments) -> T? {
        let key = ObjectIdentifier(T.self)
        let substitution = substitutions[key]
        let instance = substitution?.resolve(container: self, args: args)
        return instance as? T
    }
}

Давайте разбираться.

Класс ContainerMock наследуется от обычного Container, переопределяя метод makeInstance(args:), используемый контейнером для создания сущностей. Новая реализация пытается создать подставную зависимость вместо настоящей. Если ей это не удается, она печально разводит руками и фолбечится на реализацию базового класса.

Метод replace(_:with:) позволяет сконфигурировать моковый контейнер, указав тип зависимости и соответствующий ей тип мока. Эта информация хранится в словаре substitutions, который использует уже знакомый нам ObjectIdentifier для ключа и AnyResolvable для хранения типа мока.

Для создания моков используется метод makeInstance(args:), который по ключу пытается достать нужный AnyResolvable из словаря substitutions и создать соответствующий экземпляр с помощью метода resolve(container:args:).

Использовать все это дело мы будем следующим образом. Создаем моковый OrdersProvider, переопределяя метод loadOrders(for:date:):

final class OrdersProviderMock: OrdersProvider {
    override func loadOrders(for customerId: Int, date: Date) {
        print("Loading mock orders for customer '(customerId)', date '(date)'")
    }
}

Создаем моковый контейнер и конфигурируем его. Вью-модель достаем из контейнера обычным образом. Контейнер создает экземпляр вью-модели, разрешая все ее зависимости с учетом подстановок:

let container = ContainerMock()
container.replace(OrdersProvider.self, with: OrdersProviderMock.self)
let viewModel: OrdersVM = container.resolve(args: .init(customerId: 42, date: Date()))
viewModel.loadOrders()

Для справки, этот код производит следующий консольный вывод:

Loading mock orders for customer '42', date '2020-04-24 17:47:40 +0000'

Заключение

Сегодня мы вероломно поступились принципом инверсии зависимостей и в очередной раз изобрели велосипед, реализовав бюджетный DI с помощью анти-паттерна Service Locator. Попутно мы познакомились с парой полезных техник iOS-разработки, таких как type erasure и property wrappers, и не забыли про unit-тесты.

Автор не рекомендует использовать код из этой статьи в приложении для управления ядерным реактором, но если у вас небольшой проект и вы не боитесь экспериментировать — свайп вправо, it’s a match <3


Весь код из этой статьи можно скачать в виде Swift Playground.

Автор: Александр Волохин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js