Я работал несколько лет с архитектурой 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 их менять
Диаграммы
Заключение
Я описал основные принципы работы MVI для более подробной информации вы можете посмотреть проект на GitHub.
MVI — это реактивная и однонаправленная архитектура. Она позволяет реализовать сложные экраны и динамические менять состояния экрана и одновременно эффективно разделять обязанности. Эта реализация, конечно, не единственно правильная. Всегда есть альтернативы, и вы можете экспериментировать с этой архитектурой, добавлять или упрощать по своему усмотрению. В любом случае эта архитектура хорошо сочетается с реактивным SwiftUI и помогает упростить работу с тяжелыми экранами.
Bardakan
Расскажите, как вы поступаете в MVI в случаях, когда View должен запросить данные Model, обновить UI, еще запросить данные, еще обновить UI и т.д., т.е. вам нужно получить и отобразить данные в несколько последовательных шагов.
И еще насколько это будет проще по сравнению с двунаправленной архитектурой, где все шаги вы можете обозначить в одном методе?
VAnsimov Автор
Каждый раз когда надо обновить данные View запрашивает это у Intent, если нужно до обогатить данные, Intent делает это, если нет тогда сразу передает ивент в Model, тот в свою очередь обновляет данные для показа и View показывает
View —(событие)—> Intent —(до обогащенное данными событие)—> Model —(новое состояние)—> View
На каждом шаге, каждый раз необходимо проходить этот круг.
В двунаправленных архитектурах все эти шаги можно обозначить одной функцией, в однонаправленных нужно делать круг. Как я писал выше это один из моментов критики таких архитектур