SwiftUI и MVI

от автора

Я работал несколько лет с архитектурой 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 и Intant и дает доступ для View к свойствам экрана и не позволят View их менять

Диаграммы

Class Diagram

Class Diagram
Sequence Diagram

Sequence Diagram

Заключение

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/821591/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *