Хабрахабр

MVI и SwiftUI – одно состояние

Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.

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() }}

  1. Все события, которые получает View, передаются в Intent. Intent держит ссылку на актуальное состояние View у себя, так как именно он меняет состояния. Обертка @ObservedObject нужна для того, чтобы передавать во View все изменения, происходящие в Model (подробнее чуть ниже)
  2. Упрощает создание View, таким образом проще принимать данные от другого экрана (пример RootView.build() или HomeView.build(articul: 42))
  3. Передает событие цикла жизни View в Intent
  4. Функции, которые создают Custom elements
  5. Пользователь может видеть разные состояния экрана, все зависит от того, какие сейчас данные в Model. Если булевое значение атрибута intent.model.isLoading – true, пользователь видит загрузку, если false, то видит загруженный контент или ошибку. В зависимости от состояния пользователь будет видеть разные Custom elements.

Model

Model – держит у себя актуальное состояние экрана

 import SwiftUI // 1protocol 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?} 

  1. Протокол нужен для того, чтобы показывать View только то, что необходимо для отображения UI
  2. @Published нужен для реактивной передачи данных во View

Intent

Inent – ждет событий от View для дальнейших действий. Работает с бизнес логикой и базами данных, делает запросы на сервер и т.д.

import SwiftUIimport 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: - APIextension 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() }} 

  1. Intent содержит в себе ссылку на Model, и когда это необходимо, меняет данные у Model. RootModelIng – это протокол, который показывает атрибуты Model и не дает их менять
  2. Для того, чтобы изменить атрибуты в Intent, мы преобразуем RootModelProperties в RootModel
  3. Intent постоянно ждет изменения атрибутов у Model и передает их View. AnyCancellable позволяет не держать в памяти ссылку на ожидание изменений от Model. Таким нехитрым способом View получает самое актуальное состояние
  4. Эта функция получает событие от пользователя и скачивает картинку
  5. Так мы меняем состояние экрана

У этого подхода (менять состояния по очереди) есть недостаток: если атрибутов у 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: - APIextension 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>() 

  1. Точка входа. Через этот атрибут будем обращаться к 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) }} 

  1. Отдельный View, в котором находится вся логика и Custom elements, относящиеся к навигации
  2. Передает событие цикла жизни View в Intent

Intent собирает все необходимые данные для перехода

// MARK: - APIextension 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)) }} 

  1. Если по каким-либо причинам картинки нет, тогда передает все необходимые данные в Model для показа ошибки
  2. Передает необходимые данные в Model для открытия экрана с подробным описанием картинки
import SwiftUIimport 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() }}

  1. Enum с необходимыми данными для экранов
  2. Через этот атрибут будут передаваться события. По событиям мы будем понимать, какой экран надо показывать
  3. Это атрибут нужен для хранения данных для открытия экрана
  4. Меняем с false на true и нужный экран открывается

Заключение

SwiftUI так же, как и MVI, построен на реактивности, поэтому они хорошо подходят друг другу. Есть сложности с навигацией и большим Intent при сложной логике, но все решаемо. MVI позволяет реализовывать сложные экраны и с минимальными усилиями, очень динамично менять состояние экрана. Эта реализация, конечно, не единственно верная, всегда существуют альтернативы. Однако паттерн прекрасно ложится на новый подход к UI от Apple. Один класс для всех состояний экрана значительно упрощает работу с экраном.

Код из статьи можно посмотреть в GitHub.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть