Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.
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 SwiftUIstruct 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// 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?}
- Протокол нужен для того, чтобы показывать View только то, что необходимо для отображения UI
- @Published нужен для реактивной передачи данных во View
Intent
Inent ждет событий от View для дальнейших действий. Работает с бизнес логикой и базами данных, делает запросы на сервер и т.д.
import SwiftUIimport Combineclass 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() }}
- 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: - 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>()
- Точка входа. Через этот атрибут будем обращаться к 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: - 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)) }}
- Если по каким-либо причинам картинки нет, тогда передает все необходимые данные в Model для показа ошибки
- Передает необходимые данные в Model для открытия экрана с подробным описанием картинки
import SwiftUIimport Combinestruct 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. Один класс для всех состояний экрана значительно упрощает работу с экраном.
Код из статьи можно посмотреть в GitHub