На связи Станислав Потемкин, iOS Tech Lead в компании Jivo.

Среди архитектурных паттернов большой популярностью вполне заслуженно пользуются универсальные MVC, MVP, MVVM, VIPER, и слегка платформенный Clean Swift (VIP).

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

На хабре уже была статья многолетней давности с неплохим разбором особенностей большинства этих паттернов, а именно: MVC, MVP, MVVM, VIPER. Плюс также есть статья отдельно про Clean Swift (VIP).

Мы в Jivo сначала пользовались подобием MVP, но затем со временем (и по мере роста) нам начали открываться некоторые не очень комфортные особенности этого паттерна для наших реалий. Соответственно, была произведена попытка выбрать наиболее комфортный из других популярных, но везде встречались те или иные не очень приятные нюансы.

В итоге мы решили изобрести свой велосипед исследовать область архитектурного вопроса с нового ракурса, и в последствии пришли к собственному паттерну, который получил наименование Round Table.

Общая схема модуля в Round Table

Общий план повествования:

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

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

Причины появления

Причина #1: коммуникация между логикой и UI

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

Однако, у нас имеется Clean Swift (VIP), где эта проблема решена коммуникацией из UI напрямую в логику. Этот подход позволяет частично решить проблему маршрутизации, что приятно. Но другие сложности на этом не заканчиваются, потому идём дальше.

Причина #2: навигация между экранами

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

Так уж получилось, что семейство MV* и VIPER тоже по определённым причинам нам не очень подошли. Что же пошло не так?

Не думаю, что станет ошибкой заявить: в описании большинства UI паттернов выделено мало внимания вопросу навигации между экранами. Впрочем, VIPER и Clean Swift (VIP) получились чуть более детализированными в этом вопросе.

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

В случае с Clean Swift (VIP) задача роутинга лежит на компоненте Router, выход к которому есть у ViewController, что означает перенос логики маршрутизации в UI слой, а это не есть приятно, это привязывает нас к платформенным особенностям. Плюс, опять же, надобность проксирующего вызова в случаях, когда Interactor играет роль инициатора перехода. Нам был предпочтителен вариант оставить логику маршрутизации ближе к логике, чем к UI.

Причина #3: неудобство масштабирования

Когда с течением времени тот или иной компонент развивается и расширяется (будь то Interactor, Presenter, Mediator или какой-либо еще), в большинстве случаев нужно добавить соответствующую декларацию в два места: интерфейс и реализацию (мы трудимся с уважением к SOLID). Это распространённый классический подход, но нам хотелось добиться более компактного кода при расширении.

Причина #4: компоненты лезут друг в друга

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

Пример из MVP в стиле:
«эй, Presenter, выполни свой метод showGreeting()»

func didTapGreetingButton(button: UIButton) {
    self.presenter.showGreeting()
}

Пример из MVVM в стиле:
«эй, ViewModel, выполни свой метод showGreeting()»

func didTapGreetingButton(button: UIButton) {
    self.viewModel?.showGreeting()
}

Пример из VIPER в стиле:
«эй, Presenter, выполни свой метод showGreeting()»

func didTapGreetingButton(button: UIButton) {
    self.presenter.showGreeting()
}

Пример из Clean Swift (VIP) в стиле:
«эй, Interactor, выполни свой метод showGreeting()»

func didTapGreetingButton(button: UIButton) {
    self.interactor?.showGreeting()
}

С одной стороны, подобные варианты – это вполне нормальное положение вещей в мире ООП, но с другой стороны «терзают смутные сомнения»: в глубине души хотелось бы использовать событийный подход вместо императивного.


Суть нашего варианта

В итоге, взвесив ситуацию, мы принялись формировать наше архитектурное видение по этому вопросу. Итак, теперь рассмотрим, какие принципы построения в итоге решено было применить.

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

Принцип #1: компоненты модуля и их слои ответственности

Presenter: контролирует UI

Presenter умеет подготавливать модель данных для отображения к передаче во View и производить какие-то дополнительные трансформации (например, приписать символ валюты к стоимости товара или взять какую-то надпись из ресурсов-переводов).

Core: контролирует бизнес-логику

Core имеет доступ к функциональным компонентам системы (менеджеры, сервисы, адаптеры, итд) и, взаимодействуя с ними, умеет реализовывать бизнес-логику и обработку информации.

Joint: контролирует иерархию модулей

Joint (в переводе: сочленение, сустав) занимается управлением иерархией экранов, в том числе взаимодействует с навигаторами и табами, push и pop, present и dismiss.

State: хранит состояние и доступен для всех остальных

State может хранить в себе какие-то оперативные данные, которые нужны для функционирования данного модуля. Например, текст ошибки, которая возникла при обработке запроса в Core, и которую в итоге нужно отобразить во View. Или введённый в поле текст, который нужно будет обработать через некоторое время по кнопке. Доступ к State имеют все остальные функциональные компоненты (например, Core может туда что-то сохранить, а Presenter что-то прочитать при подготовке модели данных для View).

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

Assembly

Осталось упомянуть лишь про вспомогательную функцию сборки, которая занимается созданием компонентов и сборкой модуля воедино. Например, она может выглядеть таким образом (вспомогательная generic функция RTEModuleAssembly() является общей для всех и помогает соединить компоненты в нужном порядке):

func LoginModuleAssembly(trunk: Trunk) -> LoginModule {
    return RTEModuleAssembly(
        pipeline: LoginModulePipeline(),
        state: LoginModuleState(),
        coreBuilder: { pipeline, state in
            LoginModuleCore(
                pipeline: pipeline,
                state: state,
                authManager: trunk.authManager
            )
        },
        presenterBuilder: { pipeline, state in
            LoginModulePresenter(
                pipeline: pipeline,
                state: state
            )
        },
        viewBuilder: { pipeline in
            LoginModuleView(
                pipeline: pipeline
            )
        },
        jointBuilder: { pipeline, state, view in
            LoginModuleJoint(
                pipeline: pipeline,
                state: state,
                view: view,
                trunk: trunk
            )
        })
}

Принцип #2: коммуникация между компонентами

Компоненты ничего не знают о существовании друг друга, они являются изолированными. В качестве основы коммуникации выступает шина данных Pipeline. Pipeline распространяет сообщения от любого компонента ко всем остальным.

Если, например, произойдёт некое обновление данных внутри компонента Core, он может поделиться этой новостью с остальными посредством Pipeline. Для этого Core передаст команду в Pipeline, которая в свою очередь распространит его по всем остальным компонентам - Presenter и Joint. Похожим образом View посредством Pipeline может поделиться чем-то с Core, Presenter и Joint.

// Core sends [Event=dataUpdate] to Pipeline
 
enum CoreEvent {
    case dataUpdate
}
 
private func handleDataUpdated() {
    self.pipeline?.notify(event: .dataUpdate)
}
 
// Presenter receives [Event=dataUpdate] from Pipeline
 
override func handleCore(event: CoreEvent) {
    switch event {
        case .dataUpdate:
            self.updateView()
    }
}
 
// Joint receives [Event=dataUpdate] from Pipeline
 
override func handleCore(event: CoreEvent) {
    // ...
}

Как можно заметить, вместо вызова методов использованы перечисления с ассоциативными значениями.

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

Кроме того, для расширения функциональности достаточно добавить дополнительный case в перечисление и обработать его в switch (ну или вставить заземляющий default блок, при желании).

В итоге, например, вместо такого кода:

func didTapButton(button: UIButton) {
    self.viewModel?.showGreeting()
}

В Round Table используется такой:

func didTapButton(button: UIButton) {
    self.pipeline.notify(intent: .greetingButtonTap)
}

И еще одно крайне важное замечание. Pipeline организована таким образом, что управляющие события проходят по пути Core -> Presenter -> Joint. То есть, в зависимости от отправителя сигнала, он пойдёт по соседям следующим образом:

  • Event из Core – в Presenter и Joint

  • Intent из View – в Core, Presenter и Joint

  • Input из Joint – в Core и Presenter

Это намеренное правило, которое объясняется следующим образом: зачастую практически любое изменение (или команда извне) влияют на внутреннее состояние системы. Поэтому первым уведомление получает Core, чтобы иметь возможность обновить State (если нужно). Далее актуальное состояние системы имеет смысл отобразить на экране, поэтому вторым в очереди является Presenter. И последним будет проинформирован Joint на случай, если надо передать какую-то информацию вверх по иерархии, наружу.

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

Принцип #3: взаимодействие между разными модулями

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

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

// View sends [Intent=commentButtonTap] to Pipeline 
 
enum ViewIntent {
    case commentButtonTap
}
 
@objc private func didTapCommentButton() {
    self.pipeline.notify(intent: .commentButtonTap)
}
 
// Joint receives [Intent=commentButtonTap] from Pipeline
 
override func handleView(intent: ViewIntent) {
    switch intent {
        case .commentButtonTap:
            self.presentCommentModule()
    }
}
 
// Joint grabs the input and sends [Input=comment(text:)] to Pipeline
 
enum JointInput {
    case comment(text: String)
}
 
private func presentCommentModule() {
    let module = CommentModuleAssembly(trunk: trunk)
    //note// "module" contains {view, joint}
    
    module.joint.attach { [weak self] output in
        switch output {
            case .comment(let text):
                self?.pipeline?.notify(input: .comment(text: text)) // <--  
                self?.view?.dismiss(animated: true)
        }
    }
  
    self.view?.present(module.view, animated: true)
}
 
// Core receives [Input=comment(text:)] from Pipeline:
 
override func handleJoint(input: JointInput) {
    switch input {
        case .comment(let text):
            self.state.comment = text
    }
}
 
// Presenter receives [Input=comment(text:)] from Pipeline
// and then Presenter sends [Update=review(comment:)] to Pipeline
 
enum PresenterUpdate {
    case review(comment: String)
}
 
override func handleJoint(input: JointInput) {
    switch input {
        case .comment:
            self.pipeline?.notify(update: .review(comment: state.comment))
    }
}
 
// View receives [Update=review(comment:)] from Pipeline
 
override func handlePresenter(update: PresenterUpdate) {
    switch update {
        case .review(let comment):
            self.reviewLabel.text = comment
    }
}

Где найти, как использовать, какие планы

Познакомиться более подробно с описанием паттерна, а также посмотреть примеры использования и скачать шаблоны для Xcode, можно здесь:
https://github.com/JivoChat/RoundTable

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

Кроме того, рассматривается вариант дальнейшей доработки паттерна для внедрения в него управляющего элемента Coordinator.