Я работал несколько лет с архитектурой MVI в SwiftUI и продолжаю работать. Ранее я писал об этом подходе и недавно я решил обновить и отрефакторить репозиторий с примером MVI на SwiftUI, многое упростил и сделал удобнее, а также решил написать актуальную русскую версию статьи об архитектуре MVI на SwiftUI.

UIKit впервые появился в iOS 2 и до сих пор остаётся с нами. Со временем мы привыкли к нему и научились с ним работать. Мы нашли различные архитектурные подходы, и MVVM стал самым популярным, на мой взгляд. С выходом SwiftUI, MVVM ещё больше укрепил свои позиции, в то время как другие архитектуры не очень хорошо себя чувствую на SwiftUI.

Однако архитектуры, которые, казалось бы, не могут быть использованы со SwiftUI, возможно адаптировать к SwiftUI, и они даже могут быть удобнее, чем MVVM.

Я хочу рассказать об одной из таких архитектур — MVI. Но сначала совсем немного теории.

Двунаправленные и однонаправленные архитектуры

Все существующие архитектуры можно разделить на два типа:

  • Двунаправленные

  • Однонаправленные

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

Двунаправленная архитектура
Двунаправленная архитектура

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

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

Однонаправленная архитектура
Однонаправленная архитектура

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

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

Я немного лукавил, когда говорил, что существуют архитектур, которые могут быть удобнее чем MVVM. Такие архитектуры действительно существуют, но они больше подходят для крупных проектов. Я говорю о MVI, Clean Swift и других однонаправленных архитектурах.

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

С теорией разобрались, давайте рассмотрим одну из однонаправленных архитектур.

MVI - краткая история и принцип работы

Впервые этот паттерн был описан разработчиком JavaScript Андре Штальцем. Общие принципы можно найти здесь

В архитектуре MVI для web делиться на компоненты следующим образом:

  • Intent: Функция, которая преобразует поток (Observable) событий пользователя в поток "действий". Тоесть, пользовательские события (например, клики, ввод текста) преобразуются в действия, которые система будет обрабатывать.

  • Model: Функция, которая преобразует поток (Observable) "действий" в поток состояния. Тоесть обработка действий и изменение состояния экрана.

  • View: Функция, которая преобразует поток (Observable) состояния в поток рендеринга. Тоесть, пользовательский интерфейс обновляется в соответствии текущего состояние экрана.

  • Custom element: подраздел View, который является UI-компонентом. Они опциональны, их может не быть, Также могут быть построены на MVI

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

В мобильном приложении диаграмма MVI очень похожа на оригинальную (для web версии), с лишь незначительными изменениями:

  • View получает изменения состояния от Model и отображает их. Также получает ивенты от пользователя и отправляет их Intent

  • Intent получает события от View и взаимодействует с бизнес-логикой.

  • Model получает данные от Intent и подготавливает их для отображения. Также Model хранит текущее состояние View.

Чтобы обеспечить однонаправленный поток данных, необходимо, чтобы View имел ссылку на Intent, Intent на Model, а Model на View. Однако сделать это в SwiftUI большая проблема, потому что View является структурой, и Model не может напрямую ссылаться на него.

Чтобы решить эту проблему, можно ввести дополнительный модуль, называемый Container. Основная роль Container заключается в поддержании ссылок на Intent и Model и обеспечении доступности между модулями, гарантируя однонаправленный поток данных.

Хотя это может показаться сложным в теории, на практике всё довольно просто.

Реализация контейнера

Давайте напишем экран, показывающий небольшой список видео WWDC. Я опишу только базовые вещи, а полную реализацию вы сможете увидеть на GitHub.

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

/* Контейнер предоставит View доступ к свойствам экрана,
   но не даст изменять их напрямую,  только через Intent */
final class MVIContainer<Intent, Model>: ObservableObject {

    let intent: Intent
    let model: Model

    private var cancellable: Set<AnyCancellable> = []

    /* К сожалению, мы не можете указать тип ObjectWillChangePublisher через 
       дженерики, поэтому укажем его с помощью дополнительного свойства */
    init(intent: Intent, model: Model, modelChangePublisher: ObjectWillChangePublisher) {
        self.intent = intent
        self.model = model

        /* Это необходимо для того, чтобы изменения в Model получала View, 
           а не только Container */
        modelChangePublisher
            .receive(on: RunLoop.main)
            .sink(receiveValue: objectWillChange.send)
            .store(in: &cancellable)
    }
}

View

Инициализация View будет выглядеть следующим образом:

/* ListView будет показывать список видео с WWDC */
struct ListView: View{

    @StateObject var container: MVIContainer<ListIntentProtocol, ListModelStatePotocol>

    /* Эти свойства можно не писать, но они упрощают доступ к Intent и View, 
       иначе было бы container.intent и container.state */
    private var intent: ListIntentProtocol { container.intent }
    private var state: ListModelStatePotocol { container.model }

    init() {
        let model = ListModel()
        let intent = ListIntent(model: model)
        let container = MVIContainer(
            intent: intent as ListIntentProtocol,
            model: model as ListModelStatePotocol,
            modelChangePublisher: model.objectWillChange
        )
        self._container = StateObject(wrappedValue: container)
    }

    ...
}

Давайте посмотрим как работает View:

struct ListView: View {

    @StateObject private var container: MVIContainer<ListIntent, ListModelStatePotocol>
    
    /* Эти свойства можно не писать, но они упрощают доступ к Intent и View, 
       иначе было бы container.intent и container.state */
    private var intent: ListIntentProtocol { container.intent }
    private var state: ListModelStatePotocol { container.model }

    ...

    var body: some View {

        /* View получает готовые к отображению данные из Model */
        Text(state.text)
            .padding()
            .onAppear(perform: {

                /* Уведомляет Inten о событиях, происходящих в View */
                intent.viewOnAppear()
            })
    }
}

В этом примере кода View получает данные от Model и не может их менять, только показывать. View также уведомляет Intent о разных событиях, в данном случае что View стал виден. Что Intent будет делать с этим событием, View не знает.

Intent

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

final class ListIntent {

    /* ListModelActionsProtocol скрывает свойства экрана от Intent 
       и дает возможность Intent передавать данные в Model */
    private weak var model: ListModelActionsProtocol?
    
    private let numberService: NumberServiceProtocol

    init(
      model: ListModelActionsProtocol, 
      numberService: NumberServiceProtocol
    ) {
        self.model = model
        self.numberService = numberService
    }

    func viewOnAppear() {
        /* Синхронно или асинхронно получает бизнес-данные */
        numberService.getNumber(completion: { [weak self] number in

          /* После получения данных Intent отправляет данные в Model */
          self?.model?.parse(number: number)
        })
    }
}

В этом примере кода, функцию viewOnAppear вызвала View, тем самым известила Intent о событии показа экрана.  Intent асинхронно получило данные и передало в Model.

Model

Model получает данные от Intetn и готовит их к отображению. Model также держит у себя текущее состояние экрана.

Model будет иметь два протокола: один для Intent, который позволяет Intetn передавать данные в Model, и другой для View, которое обеспечивает доступ к текущему состоянию экрана. Протокол ObservableObject позволяет View реактивно получать обновления данных.

Давайте посмотрим на все это поближе.

/* Через этого протокола Model дает доступ к текущему состоянию экрана. 
   View видит только свойства и не может их менять */
protocol ListModelStatePotocol {

    var text: String { get }
}

/* Через этот протокол Intent может передавать данные в Model. 
   И этот протокол скрывает все свойства экрана от Intent */
protocol ListModelActionsProtocol: AnyObject {

    func parse(number: Int)
}

Реализация Model:

/* Чтобы использовать всю мощь SwiftUI, мы можем использовать 
   ObservableObject. Когда мы будем менять любое свойство, 
   помеченное как @Published, все изменения будет автоматически 
   получать View и отображать их */
final class ListModel: ObservableObject, ListModelStatePotocol {

    @Published var text: String = ""
}

extension ListModel: ListModelActionsProtocol {

    func parse(number: Int) {

      /* Model подготавливает полученные данные к отображению */
      let showText = "Random number: " + String(number)

      /* После подготовки Model обновляет состояние экрана.
         Поскольку свойство text помечено как @Published, View 
         получит данные практически сразу как они изменяться */
      text = showText
    }
}

p.s.

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

Может возникнуть вопрос. Зачем придумывать контейнер? Почему мы не можем сделать это так:

struct ListView {

    let intent: ListIntentProtocol

    @StateObject var model: ListModel

    ...
}

Работать через протокол ListModelProtocol var model не сможет, потому что @StateObject требует, чтобы тип был ObservableObject, а без протокола View может изменять данные у Model, что нарушает однонаправленный поток данных. Именно по этой причине необходим контейнер.

Кратко о контейнере. Поскольку View является структурой, а Model не может держать ссылку на View, логику Model необходимо было разделить на протоколы (один для View, другой для Intent). Контейнер держит ссылки на Model и Intent и дает доступ для View к свойствам экрана и не позволят View их менять

Диаграммы

Class Diagram
Class Diagram
Sequence Diagram
Sequence Diagram

Заключение

Я описал основные принципы работы MVI для более подробной информации вы можете посмотреть проект на GitHub.

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

Комментарии (2)


  1. Bardakan
    13.06.2024 23:38

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

    Расскажите, как вы поступаете в MVI в случаях, когда View должен запросить данные Model, обновить UI, еще запросить данные, еще обновить UI и т.д., т.е. вам нужно получить и отобразить данные в несколько последовательных шагов.

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


    1. VAnsimov Автор
      13.06.2024 23:38
      +1

      Каждый раз когда надо обновить данные View запрашивает это у Intent, если нужно до обогатить данные, Intent делает это, если нет тогда сразу передает ивент в Model, тот в свою очередь обновляет данные для показа и View показывает

      View —(событие)—> Intent —(до обогащенное данными событие)—> Model —(новое состояние)—> View

      На каждом шаге, каждый раз необходимо проходить этот круг.

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

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