Всем привет! Меня зовут Илья, я — 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, как было показано в примере, то теряется возможность внедрять эти зависимости.
- Еще один минус — резко возрастающее количество протоколов.
- Иногда код может выглядеть не таким понятным, как при использовании обычных подходов.
Положительные стороны данного подхода следующие:
- Самое главное и очевидное—сильно уменьшается дублирование.
- К методам протокола применяется статическое связывание. Это означает, что определение реализации метода будет происходить на этапе компиляции. Следовательно, во время выполнения программы не будет расходоваться дополнительное время на поиск реализации (хотя это время и не особо значительное).
- Благодаря тому, что протоколы представляют собой небольшие «кирпичики», из них можно легко составить любую композицию. Плюс в карму к гибкости в использовании.
- Простота рефакторинга, тут без комментариев.
- Начать использовать данный подход можно на любой стадии проекта, так как он не затрагивает весь проект целиком.
Считать это решение хорошим или нет — личное дело каждого. Наш опыт применения этого подхода был положительным и позволил решить проблемы.
На этом все!
Автор: NoFearJoe