Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.
Отредактировано: Подход доработан и улучшен, тут я описываю его подробнее https://habr.com/ru/post/583376/
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/