
Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.
Отредактировано: Подход доработан и улучшен, тут я описываю его подробнее https://habr.com/ru/articles/821591/
MVI
Первым этот паттерн описал JavaScript разработчик Андрэ Штальц. С общими принципами можно ознакомиться по ссылке

Intent: ждет событий от пользователя и обрабатывает их
Model: ждет обработанные события для изменения состояния
View: ждет изменений состояния и показывает их
Custom element: подраздел View, который сам по себе является UI элементом. Может быть реализован как MVI или как веб-компонент. Необязательно использовать во View.
На лицо реактивный подход. Каждый модуль (function) ожидает какое-либо событие, а после его получения и обработки передает это событие в следующий модуль. Получается однонаправленный поток. Единое состояние View находится в Model, и таким образом решается проблема множества трудноотслеживаемых состояний.
Как это можно применить в мобильном приложении?
Мартин Фаулер и Райс Дейвид в книге "Шаблоны корпоративных приложений" писали, что паттерны – это шаблоны решения проблем, и вместо того, чтобы копировать один в один, лучше адаптировать их под текущие реалии. У мобильного приложения есть свои ограничения и особенности, которые надо учитывать. View получает событие от пользователя, а дальше его можно проксировать в Intent. Схема немного видоизменяется, но принцип работы паттерна остается прежним.

Реализация
Далее будет очень много кода.
Итоговый код можно посмотреть под спойлером ниже.
View
import SwiftUI struct RootView: View { // Or @ObservedObject for iOS 13 @StateObject private var intent: RootIntent var body: some View { ZStack { imageView() .cornerRadius(6) .shadow(radius: 2) .frame(width: 100, height: 100) .onTapGesture(perform: intent.onTapImage) errorView() loadView() } .overlay(RootRouter(screen: intent.model.routerSubject)) .onAppear(perform: intent.onAppear) } static func build() -> some View { let model = RootModel() let intent = RootIntent(model: model) let view = RootView(intent: intent) return view } } // MARK: - Custom elements private extension RootView { private func imageView() -> some View { guard let image = intent.model.image else { return Color.gray.toAnyView() } return Image(uiImage: image) .resizable() .toAnyView() } private func loadView() -> some View { guard intent.model.isLoading else { return EmptyView().toAnyView() } return ZStack { Color.white Text("Loading") }.toAnyView() } private func errorView() -> some View { guard intent.model.error != nil else { return EmptyView().toAnyView() } return ZStack { Color.white Text("Fail") }.toAnyView() } }
Model
import SwiftUI import Combine protocol RootStateModel { var image: UIImage? { get } var isLoading: Bool { get } var error: Error? { get } var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get } } protocol RootDisplayModel { func dispalyLoading() func display(image: UIImage) func dispaly(loadingFailError: Error) func routeTodDescriptionImage() } // MARK: - RootModel & RootStateModel class RootModel: ObservableObject, RootStateModel { @Published private(set) var image: UIImage? @Published private(set) var isLoading: Bool = true @Published private(set) var error: Error? let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>() } // MARK: - RootDisplayModel extension RootModel: RootDisplayModel { func dispalyLoading() { isLoading = true error = nil image = nil } func display(image: UIImage) { self.image = image isLoading = false } func dispaly(loadingFailError: Error) { self.error = loadingFailError isLoading = false routerSubject.send(.alert(title: "Error", message: "It was not possible to upload a image")) } func routeTodDescriptionImage() { guard let image = self.image else { routerSubject.send(.alert(title: "Error", message: "Failed to open the screen")) return } routerSubject.send(.descriptionImage(image: image)) } }
Intent
import SwiftUI import Combine class RootIntent: ObservableObject { let model: RootStateModel private var displayModel: RootDisplayModel private var cancellable: Set<AnyCancellable> = [] init(model: RootModel) { self.model = model self.displayModel = model cancellable.insert(model.objectWillChange.sink { [weak self] in self?.objectWillChange.send() }) } } // MARK: - API extension RootIntent { func onAppear() { displayModel.dispalyLoading() let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg") let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in guard let data = data, let image = UIImage(data: data) else { DispatchQueue.main.async { self?.displayModel.dispaly(loadingFailError: error ?? NSError()) } return } DispatchQueue.main.async { self?.displayModel.display(image: image) } } task.resume() } func onTapImage() { displayModel.routeTodDescriptionImage() } }
Router
struct RootRouter: View { enum ScreenType { case alert(title: String, message: String) case descriptionImage(image: UIImage) } let screen: PassthroughSubject<ScreenType, Never> @State private var screenType: ScreenType? = nil var body: some View { displayView().onReceive(screen, perform: { self.screenType = $0 }) } } private extension RootRouter { private func displayView() -> some View { let isVisibleScreen = Binding<Bool> { screenType != nil } set: { if !$0 { screenType = nil } } // Screens switch screenType { case .alert(let title, let message): return Spacer().alert(isPresented: isVisibleScreen, content: { Alert(title: Text(title), message: Text(message)) }).toAnyView() case .descriptionImage(let image): return Spacer().sheet(isPresented: isVisibleScreen, content: { DescriptionImageView.build(image: image) }).toAnyView() default: return EmptyView().toAnyView() } } }
Теперь приступим к рассмотрению каждого модуля по отдельности.
Прежде чем приступить к реализации, нам понадобится расширение для View, которое упростит написание кода и сделает его более читабельным.
extension View { func toAnyView() -> AnyView { AnyView(self) } }
View
View – принимает событие от пользователя, передает их в Intent и ждет изменения состояния от Model
import SwiftUI struct RootView: View { // 1 @ObservedObject private var intent: RootIntent var body: some View { ZStack { // 4 imageView() .cornerRadius(6) .shadow(radius: 2) .frame(width: 100, height: 100) errorView() loadView() } // 3 .onAppear(perform: intent.onAppear) } // 2 static func build() -> some View { let intent = RootIntent() let view = RootView(intent: intent) return view } private func imageView() -> some View { // 5 guard let image = intent.model.image else { return Color.gray.toAnyView() } return Image(uiImage: image) .resizable() .toAnyView() } private func loadView() -> some View { // 5 guard intent.model.isLoading else { return EmptyView().toAnyView() } return ZStack { Color.white Text("Loading") }.toAnyView() } private func errorView() -> some View { // 5 guard intent.model.error != nil else { return EmptyView().toAnyView() } return ZStack { Color.white Text("Fail") }.toAnyView() } }
- Все события, которые получает View, передаются в Intent. Intent держит ссылку на актуальное состояние View у себя, так как именно он меняет состояния. Обертка @ObservedObject нужна для того, чтобы передавать во View все изменения, происходящие в Model (подробнее чуть ниже)
- Упрощает создание View, таким образом проще принимать данные от другого экрана (пример RootView.build() или HomeView.build(articul: 42))
- Передает событие цикла жизни View в Intent
- Функции, которые создают Custom elements
- Пользователь может видеть разные состояния экрана, все зависит от того, какие сейчас данные в Model. Если булевое значение атрибута intent.model.isLoading – true, пользователь видит загрузку, если false, то видит загруженный контент или ошибку. В зависимости от состояния пользователь будет видеть разные Custom elements.
Model
Model – держит у себя актуальное состояние экрана
import SwiftUI // 1 protocol RootStateModel { var image: UIImage? { get } var isLoading: Bool { get } var error: Error? { get } } // 2 protocol RootDisplayModel { var image: UIImage? { get set } var isLoading: Bool { get set } var error: Error? { get set } } class RootModel: ObservableObject, RootStateModel { // 3 @Published var image: UIImage? @Published var isLoading: Bool = true @Published var error: Error? }
- Протокол нужен для того, чтобы показывать View только то, что необходимо для отображения UI
- Протокол нужен для того, чтобы дать доступ Intent к данным. Для однонаправленного потока данных, лучше не давать доступ к свойствам Model, а в протоколе указывать функции и данные менять через эти функции
- @Published нужен для реактивной передачи данных во View
Intent
Inent – ждет событий от View для дальнейших действий. Работает с бизнес логикой и базами данных, делает запросы на сервер и т.д.
import SwiftUI import Combine class RootIntent: ObservableObject { // 1 let model: RootStateModel // 2 private var displayModel: RootDisplayModel // 3 private var cancellable: Set<AnyCancellable> = [] init() { self.model = RootModel() self. displayModel = RootModel() // 3 let modelCancellable = model.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } cancellable.insert(modelCancellable) } } // MARK: - API extension RootIntent { // 4 func onAppear() { displayModel.isLoading = true displayModel.error = nil let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg") let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in guard let data = data, let image = UIImage(data: data) else { DispatchQueue.main.async { // 5 self?.displayModel.error = error ?? NSError() self?.displayModel.isLoading = false } return } DispatchQueue.main.async { // 5 self?.displayModel.image = image self?.displayModel.isLoading = false } } task.resume() } }
- Intent содержит в себе ссылку на Model, и когда это необходимо, меняет данные у Model. RootStateModel – это протокол, который показывает атрибуты Model и не дает их менять
- Общение с Model и изменение данных происходит через протокол. Для того, чтобы Intent имел доступ к нужным ему данным и не имел доступа к данным для View
- Intent постоянно ждет изменения атрибутов у Model и передает их View. AnyCancellable позволяет не держать в памяти ссылку на ожидание изменений от Model. Таким нехитрым способом View получает самое актуальное состояние
- Эта функция получает событие от пользователя и скачивает картинку
- Так мы меняем состояние экрана
У этого подхода (менять состояния по очереди) есть недостаток: если атрибутов у Model много, то при смене атрибутов можно что-то забыть поменять. Есть одно из возможных решений, которое еще и обеспечивает однонаправленный поток данных.
protocol RootStateModel { var image: UIImage? { get } var isLoading: Bool { get } var error: Error? { get } } protocol RootDisplayModel { func dispalyLoading() func display(image: UIImage) func dispaly(loadingFailError: Error) } // MARK: - RootModel & RootStateModel class RootModel: ObservableObject, RootStateModel { @Published private(set) var image: UIImage? @Published private(set) var isLoading: Bool = true @Published private(set) var error: Error? } // MARK: - RootDisplayModel extension RootModel: RootDisplayModel { func dispalyLoading() { isLoading = true error = nil image = nil } func display(image: UIImage) { self.image = image isLoading = false } func dispaly(loadingFailError: Error) { self.error = loadingFailError isLoading = false } } // MARK: - API extension RootIntent { func onAppear() { displayModel.dispalyLoading() ...
Верю, что это не единственное решение и можно решить проблему другими способами.
Есть еще один недостаток – класс Intent может сильно вырасти при большом количестве бизнес логики. Это проблема решается разбиением бизнес логики на сервисы.
А что с навигацией? MVI+R
Если удается все делать во View, то проблем, скорее всего, не будет. Но если логика усложняется, возникает ряд трудностей. Как оказалось, сделать Router с передачей данных на следующий экран и возвратом данных обратно во View, который вызвал этот экран, не так-то просто. Передачу данных можно сделать через @EnvironmentObject, но тогда доступ к этим данным будут у всех View ниже иерархии, что нехорошо. От этой идеи отказываемся. Так как состояния экрана меняются через Model, обращение к Router делаем через эту сущность.
protocol RootStateModel { ... // 1 var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get } } class RootModel: ObservableObject, RootStateModel { ... // 1 let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()
- Точка входа. Через этот атрибут будем обращаться к Router
Чтобы не засорять основной View, все, что касается переходов на другие экраны, выносим отдельным View
struct RootView: View { @ObservedObject private var intent: RootIntent var body: some View { ZStack { imageView() .cornerRadius(6) .shadow(radius: 2) .frame(width: 100, height: 100) // 2 .onTapGesture(perform: intent.onTapImage) errorView() loadView() } // 1 .overlay(RootRouter(screen: intent.model.routerSubject)) .onAppear(perform: intent.onAppear) } }
- Отдельный View, в котором находится вся логика и Custom elements, относящиеся к навигации
- Передает событие цикла жизни View в Intent
Intent собирает все необходимые данные для перехода
// MARK: - API extension RootIntent { func onTapImage() { guard let image = displayModel.image else { // 1 displayModel.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen")) return } // 2 displayModel.routerSubject.send(.descriptionImage(image: image)) } }
- Если по каким-либо причинам картинки нет, тогда передает все необходимые данные в Model для показа ошибки
- Передает необходимые данные в Model для открытия экрана с подробным описанием картинки
import SwiftUI import Combine struct RootRouter: View { // 1 enum ScreenType { case alert(title: String, message: String) case descriptionImage(image: UIImage) } // 2 let screen: PassthroughSubject<ScreenType, Never> // 3 @State private var screenType: ScreenType? = nil var body: some View { displayView() // 3 .onReceive(screen) { self.screenType = $0 } } } private extension RootRouter { private func displayView() -> some View { // 4 let isVisibleScreen = Binding<Bool> { screenType != nil } set: { if !$0 { screenType = nil } } // 3 switch screenType { case .alert(let title, let message): return Spacer().alert(isPresented: isVisibleScreen, content: { Alert(title: Text(title), message: Text(message)) }).toAnyView() case .descriptionImage(let image): return Spacer().sheet(isPresented: isVisibleScreen, content: { // 6 DescriptionImageView.build(image: image) }).toAnyView() default: // 5 return EmptyView().toAnyView() } } }
- Enum с необходимыми данными для экранов
- Через этот атрибут будут передаваться события. По событиям мы будем понимать, какой экран надо показывать
- Это атрибут нужен для хранения данных экрана который будет показан. И для о пределение какой экран показывать.
- Когда меняем State private var screenType: ScreenType? меняется get значение, если true экран показывается, если false, то скрывается. Пока у нас есть какое-то значение в screenType: ScreenType? мы видим новый экран, когда значение nil, новые экраны скрыты.
- Если данных нет то ничего не показываем. Не храним в памяти лишние экраны.
- Таким образом (.build(image: image)) можно передавать данные другим экранам
Заключение
SwiftUI так же, как и MVI, построен на реактивности, поэтому они хорошо подходят друг другу. Есть сложности с навигацией и большим Intent при сложной логике, но все решаемо. MVI позволяет реализовывать сложные экраны и с минимальными усилиями, очень динамично менять состояние экрана. Эта реализация, конечно, не единственно верная, всегда существуют альтернативы. Однако паттерн прекрасно ложится на новый подход к UI от Apple. Один класс для всех состояний экрана значительно упрощает работу с экраном.
Код из статьи, а также Шаблоны для Xcode можно посмотреть в GitHub.
p.s.
Важное дополнение.
В примерах я использовал @ObservedObject, он привязан к циклу жизни View. В SwiftUI у View конструктор (init) может вызываться несколько раз и Intent, при каждом вызове, будет сбрасываться. Чтобы отвязать Intetn от цикла жизни View, надо использовать @StateObject.
StateObject доступен с iOS 14, для iOS 13 оптимального решения этой проблемы мне найти пока не удалось.
Отредактировано: Подход доработан и улучшен, тут я описываю подробнее https://habr.com/ru/post/583376/
