Русский
Русский
English
Статистика
Реклама

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

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


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

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


Заключение


SwiftUI так же, как и MVI, построен на реактивности, поэтому они хорошо подходят друг другу. Есть сложности с навигацией и большим Intent при сложной логике, но все решаемо. MVI позволяет реализовывать сложные экраны и с минимальными усилиями, очень динамично менять состояние экрана. Эта реализация, конечно, не единственно верная, всегда существуют альтернативы. Однако паттерн прекрасно ложится на новый подход к UI от Apple. Один класс для всех состояний экрана значительно упрощает работу с экраном.
Код из статьи можно посмотреть в GitHub
Источник: habr.com
К списку статей
Опубликовано: 26.07.2020 20:06:58
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Swift

Программирование

Разработка мобильных приложений

Разработка под ios

Swiftui

Архитектура

Mvi

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru