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: ObservableObject {
let intent: Intent
let model: Model
private var cancellable: Set = []
/* К сожалению, мы не можете указать тип 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
/* Эти свойства можно не писать, но они упрощают доступ к 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
/* Эти свойства можно не писать, но они упрощают доступ к 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
Sequence Diagram
Заключение
Я описал основные принципы работы MVI для более подробной информации вы можете посмотреть проект на GitHub.
MVI — это реактивная и однонаправленная архитектура. Она позволяет реализовать сложные экраны и динамические менять состояния экрана и одновременно эффективно разделять обязанности. Эта реализация, конечно, не единственно правильная. Всегда есть альтернативы, и вы можете экспериментировать с этой архитектурой, добавлять или упрощать по своему усмотрению. В любом случае эта архитектура хорошо сочетается с реактивным SwiftUI и помогает упростить работу с тяжелыми экранами.