company_banner

Как избавить проект от лишних килограммов



    Всем привет! Меня зовут Илья, я — iOS разработчик в Tinkoff.ru. В этой статье я хочу рассказать о том, как уменьшить дублирование кода в presentation слое при помощи протоколов.

    В чем проблема?


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

    Пример из жизни


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

    Рассмотрим самый распространенный метод в router — метод, закрывающий экран:

    func close() {
        self.transitionHandler.dismiss(animated: true, completion: nil)
    }
    

    Он присутствует во многих router, и лучше написать его только один раз.

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

    Что лучше наследования? Конечно же композиция.

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

    struct CloseRouter {
        let transitionHandler: UIViewController
        func close() {
            self.transitionHandler.dismiss(animated: true, completion: nil)
        }
    }
    

    Нам все равно придется объявить этот метод в Input протоколе роутера и реализовать его в самом роутере:

    protocol SomeRouterInput {
        func close()
    }
    class SomeRouter: SomeRouterInput {
        var transitionHandler: UIViewController!
        lazy var closeRouter = { CloseRouter(transitionHandler: self. transitionHandler) }()
    
        func close() {
            self.closeRouter.close()
        }
    }
    

    Получилось слишком много кода, который просто проксирует вызов метода close. Ленивый Хороший программист не оценит.

    Решение с протоколами


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

    Вот так это будет выглядеть:

    protocol CloseRouterTrait {
        var transitionHandler: UIViewController! { get }
        func close()
    }
    extension CloseRouterTrait {
        func close() {
            self.transitionHandler.dismiss(animated: true, completion: nil)
        }
    }
    

    Возникает вопрос, почему в названии протокола фигурирует слово trait? Это просто — так можно указать, что этот протокол реализует свои методы в extension и должен использоваться как примесь к другому типу для расширения его функциональности.

    Теперь, посмотрим как будет выглядеть использование такого протокола:

    class SomeRouter: CloseRouterTrait {
        var transitionHandler: UIViewController!
    }
    

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

    Что в этом подходе необыкновенного?


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

    Вот мой список:

    • Trait's не должны хранить и менять состояние. Они могут иметь только зависимости в виде сервисов и т.п., которые являются get-only свойствами
    • Traits's не должны иметь методов, которые не реализованы в extension, так как это нарушает их концепцию
    • Названия методов в trait должны явно отражать, что они делают, без привязки к названию протокола. Это поможет избежать коллизии названий и сделает код понятнее

    От VIPER к MVP


    Если полностью перейти на использование данного подхода с протоколами, то классы router и interactor будут выглядеть примерно так:

    class SomeRouter: CloseRouterTrait, OtherRouterTrait {
        var transitionHandler: UIViewController!
    }
    
    class SomeInteractor: SomeInteractorTrait {
        var someService: SomeServiceInput!
    }
    

    Это относится не ко всем классам, в большинстве случаев в проекте останутся просто пустые routers и interactors. В таком случае, можно нарушить структуру VIPER модуля и плавно перейти к MVP при помощи добавления протоколов-примесей к presenter.

    Примерно так:

    class SomePresenter: 
        CloseRouterTrait, OtherRouterTrait,
        SomeInteractorTrait, OtherInteractorTrait {
    
        var transitionHandler: UIViewController!
        var someService: SomeSericeInput!
    }
    

    Да, потеряна возможность внедрять router и interactor как зависимости, но в некоторых случаях это имеет место быть.

    Единственный недостаток — transitionHandler = UIViewController. А по правилам VIPER Presenter ничего не должен знать о слое View и о том, с помощью каких технологий он реализован. Решается это в данном случае просто — методы переходов из UIViewController «закрываются» протоколом, например — TransitionHandler. Так Presenter будет взаимодействовать с абстракцией.

    Меняем поведение trait


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

    В качестве примера возьмем простой интерактор с методом, который выполняет сетевой запрос:

    protocol SomeInteractorTrait {
        var someService: SomeServiceInput! { get }
        func performRequest(completion: @escaping (Response) -> Void)
    }
    extension SomeInteractorTrait {
        func performRequest(completion: @escaping (Response) -> Void) {
            someService.performRequest(completion)
        }
    }
    

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

    protocol Mock {}
    
    extension SomeInteractorTrait where Self: Mock {
        func performRequest(completion: @escaping (Response) -> Void) {
            completion(MockResponse())
        }
    }
    

    Здесь реализация метода performRequest изменена для типов, которые реализуют протокол Mock. Теперь нужно реализовать протокол Mock у того класса, который будет реализовывать SomeInteractor:

    class SomePresenter: SomeInteractorTrait, Mock {
        // Implementation
    }
    

    Для класса SomePresenter будет вызвана реализация метода performRequest, находящаяся в extension, где Self удовлетворяет протоколу Mock. Стоит убрать протокол Mock и реализация метода performRequest будет взята из обычного extension к SomeInteractor.

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

    Подводим итоги


    В заключении стоит отметить плюсы и минусы данного подхода и то, в каких случаях, по моему мнению, его стоит использовать.

    Начнем с минусов:

    • Если избавиться от router и interactor, как было показано в примере, то теряется возможность внедрять эти зависимости.
    • Еще один минус — резко возрастающее количество протоколов.
    • Иногда код может выглядеть не таким понятным, как при использовании обычных подходов.

    Положительные стороны данного подхода следующие:

    • Самое главное и очевидное—сильно уменьшается дублирование.
    • К методам протокола применяется статическое связывание. Это означает, что определение реализации метода будет происходить на этапе компиляции. Следовательно, во время выполнения программы не будет расходоваться дополнительное время на поиск реализации (хотя это время и не особо значительное).
    • Благодаря тому, что протоколы представляют собой небольшие «кирпичики», из них можно легко составить любую композицию. Плюс в карму к гибкости в использовании.
    • Простота рефакторинга, тут без комментариев.
    • Начать использовать данный подход можно на любой стадии проекта, так как он не затрагивает весь проект целиком.

    Считать это решение хорошим или нет — личное дело каждого. Наш опыт применения этого подхода был положительным и позволил решить проблемы.

    На этом все!
    Tinkoff.ru
    150,00
    IT’s Tinkoff.ru — просто о сложном
    Поделиться публикацией

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

      0
      Хм паттерн на паттерне сидит и паттерном погоняет.

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

      И если НУЖНО!!! Разработчику!!! Включался специальный «Супермен» и разворачивал этот байт код в абстракции и прочие «сахары»
        0
        Для этого нужен язык который будет описывать что надо, а не как делать. И потом учитывая ограничения синтезировать монолитный код.
        +1
        NoFearJoe, как думаешь, можно ли считать проблемой в таком подходе, что в протокол открывается внутренняя реализация? Т.е. наружу от роутера начинает торчать не только close(), но и transitionHandler, а от интерактора service?

        И, как понимаю, сделать их private тоже нельзя, т.к. тогда нельзя будет инжектить снаружи.
          0

          Да, верно — все зависимости становятся доступными извне, но только для чтения. Это можно обойти, правда, потребуется больше протоколов :). Примерно так это может выглядеть:


          protocol TransitionHandlerHolder {
              var transitionHandler: UIViewController! { get }
          }
          
          protocol CloseRouterTrait {
              func close()
          }
          
          extension CloseRouterTrait where Self: TransitionHandlerHolder {
              func close() {
                  self.transitionHandler.dismiss(animated: true, completion: nil)
              }
          }
          0
          Меня беспокоит следующее соображение.

          Интерфейсы, протоколы, расширения и т.д.… Ведь это всё подмножество наследования классов в общем и множественного наследования в частности.

          Зачем отказываются от общего механизма (множественного наследования), чтобы затем придумывать частные решения той же задачи, плодя сущности на ровном месте?

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

          Зачем весь этот зоопарк технологий и разномастных названий одного и того же?
            0
            Я просто оставлю это здесь:

            Август 2016 года:


            Сентябрь 2018:

              –1
              Сорян, не с первого раза картинка правильно вставилась
              0
              Выглядит как абстракции ради абстракций. Статья с названием «Как избавить проект от лишних килограммов» содержит в минусах «резко возрастающее количество протоколов» и «Иногда код может выглядеть не таким понятным, как при использовании обычных подходов»
                0
                Несмотря на то, что количество протоколов увеличивается, общий объем кода уменьшается сильнее, так как большие блоки кода перестают дублироваться. Парадокс, в общем.
                  0
                  Объем кода уменьшается, но
                  Иногда код может выглядеть не таким понятным, как при использовании обычных подходов
                  . Если сейчас это и выглядит хорошем решением, имхо, в будущем сделает код только хуже.
                    0
                    Это я про те случаи, когда в один класс добавлено много traits и становится непонятно откуда какой метод. Хотя, при использовании любого архитектурного решения, главное — делать это в меру, тогда не должно быть существенных проблем.
                    0
                    В строчках исходного когда уменьшается. А в байтах бинарника может и увеличиваться. В Убере вон в борьбе за размер бинарника повыпиливали протоколы везде где можно.
                      0
                      Чем плох размер бинарника? Или это Apple продаёт память по цене в 50 раз дороже, чем он д.б. была ставить? Вопрос без иронии, увеличить кругозор.

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

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