Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.
MVI
Первым этот паттерн описал JavaScript разработчик Андрэ Штальц. С общими принципами можно ознакомиться по ссылке
Intent: ждет событий от пользователя и обрабатывает их
Model: ждет обработанные события для изменения состояния
View: ждет изменений состояния и показывает их
Custom element: подраздел View, который сам по себе является UI элементом. Может быть реализован как MVI или как веб-компонент. Необязательно использовать во View.
На лицо реактивный подход. Каждый модуль (function) ожидает какое-либо событие, а после его получения и обработки передает это событие в следующий модуль. Получается однонаправленный поток. Единое состояние View находится в Model, и таким образом решается проблема множества трудноотслеживаемых состояний.
Как это можно применить в мобильном приложении?
Мартин Фаулер и Райс Дейвид в книге «Шаблоны корпоративных приложений» писали, что паттерны – это шаблоны решения проблем, и вместо того, чтобы копировать один в один, лучше адаптировать их под текущие реалии. У мобильного приложения есть свои ограничения и особенности, которые надо учитывать. View получает событие от пользователя, а дальше его можно проксировать в Intent. Схема немного видоизменяется, но принцип работы паттерна остается прежним.
Реализация
Прежде чем приступить к реализации, нам понадобится расширение для 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()
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 {
Group { () -> AnyView in
// 5
if let image = intent.model.image {
return Image(uiImage: image)
.resizable()
.toAnyView()
} else {
return Color.gray.toAnyView()
}
}
.cornerRadius(6)
.shadow(radius: 2)
.frame(width: 100, height: 100)
}
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 RootModeling {
var image: UIImage? { get }
var isLoading: Bool { get }
var error: Error? { get }
}
class RootModel: ObservableObject, RootModeling {
// 2
@Published private(set) var image: UIImage?
@Published private(set) var isLoading: Bool = true
@Published private(set) var error: Error?
}
- Протокол нужен для того, чтобы показывать View только то, что необходимо для отображения UI
- @Published нужен для реактивной передачи данных во View
Intent
Inent – ждет событий от View для дальнейших действий. Работает с бизнес логикой и базами данных, делает запросы на сервер и т.д.
import SwiftUI
import Combine
class RootIntent: ObservableObject {
// 1
let model: RootModeling
// 2
private var rootModel: RootModel! { model as? RootModel }
// 3
private var cancellable: Set<AnyCancellable> = []
init() {
self.model = RootModel()
// 3
let modelCancellable = rootModel.objectWillChange.sink { self.objectWillChange.send() }
cancellable.insert(modelCancellable)
}
}
// MARK: - API
extension RootIntent {
// 4
func onAppear() {
rootModel.isLoading = true
rootModel.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?.rootModel.error = error ?? NSError()
self?.rootModel.isLoading = false
}
return
}
DispatchQueue.main.async {
// 5
self?.model.image = image
self?.model.isLoading = false
}
}
task.resume()
}
}
- Intent содержит в себе ссылку на Model, и когда это необходимо, меняет данные у Model. RootModelIng – это протокол, который показывает атрибуты Model и не дает их менять
- Для того, чтобы изменить атрибуты в Intent, мы преобразуем RootModelProperties в RootModel
- Intent постоянно ждет изменения атрибутов у Model и передает их View. AnyCancellable позволяет не держать в памяти ссылку на ожидание изменений от Model. Таким нехитрым способом View получает самое актуальное состояние
- Эта функция получает событие от пользователя и скачивает картинку
- Так мы меняем состояние экрана
У этого подхода (менять состояния по очереди) есть недостаток: если атрибутов у Model много, то при смене атрибутов можно что-то забыть поменять.
protocol RootModeling {
var image: UIImage? { get }
var isLoading: Bool { get }
var error: Error? { get }
}
class RootModel: ObservableObject, RootModeling {
enum StateType {
case loading, show(image: UIImage), failLoad(error: Error)
}
@Published private(set) var image: UIImage?
@Published private(set) var isLoading: Bool = true
@Published private(set) var error: Error?
func update(state: StateType) {
switch state {
case .loading:
isLoading = true
error = nil
image = nil
case .show(let image):
self.image = image
isLoading = false
case .failLoad(let error):
self.error = error
isLoading = false
}
}
}
// MARK: - API
extension RootIntent {
func onAppear() {
rootModel?.update(state: .loading)
...
Верю, что это не единственное решение и можно решить проблему другими способами.
Есть еще один недостаток – класс Intent может сильно вырасти при большом количестве бизнес логики. Это проблема решается разбиением бизнес логики на сервисы.
А что с навигацией? MVI+R
Если удается все делать во View, то проблем, скорее всего, не будет. Но если логика усложняется, возникает ряд трудностей. Как оказалось, сделать Router с передачей данных на следующий экран и возвратом данных обратно во View, который вызвал этот экран, не так-то просто. Передачу данных можно сделать через @EnvironmentObject, но тогда доступ к этим данным будут у всех View ниже иерархии, что нехорошо. От этой идеи отказываемся. Так как состояния экрана меняются через Model, обращение к Router делаем через эту сущность.
protocol RootModeling {
var image: UIImage? { get }
var isLoading: Bool { get }
var error: Error? { get }
// 1
var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}
class RootModel: ObservableObject, RootModeling {
// 1
let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()
- Точка входа. Через этот атрибут будем обращаться к Router
Чтобы не засорять основной View, все, что касается переходов на другие экраны, выносим отдельным View
struct RootView: View {
@ObservedObject private var intent: RootIntent
var body: some View {
ZStack {
imageView()
// 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 = rootModel?.image else {
// 1
rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
return
}
// 2
model.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
// 4
@State private var isFullImageVisible = false
@State private var isAlertVisible = false
var body: some View {
Group {
alertView()
descriptionImageView()
}
// 2
.onReceive(screen, perform: { type in
self.screenType = type
switch type {
case .alert:
self.isAlertVisible = true
case .descriptionImage:
self.isFullImageVisible = true
}
}).overlay(screens())
}
private func alertView() -> some View {
// 3
guard let type = screenType, case .alert(let title, let message) = type else {
return EmptyView().toAnyView()
}
// 4
return Spacer().alert(isPresented: $isAlertVisible, content: {
Alert(title: Text(title), message: Text(message))
}).toAnyView()
}
private func descriptionImageView() -> some View {
// 3
guard let type = screenType, case .descriptionImage(let image) = type else {
return EmptyView().toAnyView()
}
// 4
return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
self.screenType = nil
}, content: {
DescriptionImageView.build(image: image)
}).toAnyView()
}
}
- Enum с необходимыми данными для экранов
- Через этот атрибут будут передаваться события. По событиям мы будем понимать, какой экран надо показывать
- Это атрибут нужен для хранения данных для открытия экрана
- Меняем с false на true и нужный экран открывается
Заключение
SwiftUI так же, как и MVI, построен на реактивности, поэтому они хорошо подходят друг другу. Есть сложности с навигацией и большим Intent при сложной логике, но все решаемо. MVI позволяет реализовывать сложные экраны и с минимальными усилиями, очень динамично менять состояние экрана. Эта реализация, конечно, не единственно верная, всегда существуют альтернативы. Однако паттерн прекрасно ложится на новый подход к UI от Apple. Один класс для всех состояний экрана значительно упрощает работу с экраном.
Код из статьи, а также Шаблоны для Xcode можно посмотреть в GitHub.
dempfi
Вот очень не люблю когда используют слово intent для, по сути, моделирования действий для достижения этого самого замысла. Как пример, я как пользователь, открываю офисный пакет с замыслом написать новое резюме на новое место работы о чем офисный пакет и понятия не имеет — он только видит действие (запуск программы).
В этом же и есть вся сложность разработки, что инженер пытается дать пользователю как можно больше возможных действий и упрощать их комбинирование для того, чтобы пользователю было легко реализовать его intent. И при этом инженер никогда и не знает о замысле пользователей — он только гадает и предполагает, и закладывает поддержку действий исходя из своих предположений.
И это очень кривая дорожка, когда инженер начинает предполагать, что совершение действий и есть замысел пользователя — ну вот никогда не будет у пользователя замысла нажать кнопку «Д». Он не будет просыпаться с утра и думать «ах да, надо не забыть нажать кнопку «Д» в программе Х». Все это ведёт к появлению абсолютно бесполезных программ и неработающих концепций.
Извините что немного не по теме статьи написал, затриггерило :)