MVI y SwiftUI: un estado





Digamos que necesitamos hacer un pequeño ajuste al funcionamiento de la pantalla. La pantalla cambia cada segundo porque hay muchos procesos en marcha al mismo tiempo. Como regla general, para regular todos los estados de la pantalla, es necesario referirse a variables, cada una de las cuales tiene su propia vida. Tenerlos en cuenta es muy difícil o completamente imposible. Para encontrar la fuente del problema, deberá comprender las variables y los estados de la pantalla, e incluso asegurarse de que nuestra solución no rompa algo en otro lugar. Digamos que pasamos mucho tiempo y aún hicimos las ediciones necesarias. ¿Fue posible resolver este problema más fácil y rápidamente? Vamos a averiguarlo.



MVI



Este patrón fue descrito por primera vez por el desarrollador de JavaScript Andre Stalz. Los principios generales se pueden encontrar en el enlace







Intención : espera eventos del usuario y los procesa

Modelo : espera eventos procesados ​​para cambiar el estado

Vista : espera cambios de estado y los muestra

Elemento personalizado : una subsección de la Vista, que en sí misma es un elemento de IU. Puede implementarse como MVI o como componente web. Opcional en Ver.



Frente a un enfoque reactivo. Cada módulo (función) espera algún evento y, después de recibirlo y procesarlo, pasa este evento al siguiente módulo. Resulta un flujo unidireccional. El estado único de la Vista reside en el Modelo y, por lo tanto, resuelve el problema de muchos estados difíciles de rastrear.



¿Cómo se puede aplicar esto en una aplicación móvil?



Martin Fowler y Rice David en su libro "Patrones de aplicaciones empresariales" escribieron que los patrones son patrones para resolver problemas y que en lugar de copiar uno a uno, es mejor adaptarlos a las realidades actuales. La aplicación móvil tiene sus propias limitaciones y características que deben tenerse en cuenta. View recibe un evento del usuario y luego puede ser enviado a Intent. El esquema se modifica ligeramente, pero el principio del patrón sigue siendo el mismo.







Implementación





Habrá mucho código a continuación.

El código final se puede ver debajo del spoiler a continuación.



Implementación de MVI
Ver



import SwiftUI

struct RootView: View {

    // Or @StateObject for iOS 14
    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
            imageView()
                .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: - Private - Views
private extension RootView {

    private func imageView() -> some View {
        Group { () -> AnyView  in
            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 {
        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()
    }
}




Modelo



import SwiftUI
import Combine

protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { 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?

    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()

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




Intención



import SwiftUI
import Combine

class RootIntent: ObservableObject {

    let model: RootModeling

    private var rootModel: RootModel! { model as? RootModel }
    private var cancellable: Set<AnyCancellable> = []

    init(model: RootModeling) {
        self.model = model
        cancellable.insert(rootModel.objectWillChange.sink { self.objectWillChange.send() })
    }
}

// MARK: - API
extension RootIntent {

    func onAppear() {
        rootModel?.update(state: .loading)

        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?.rootModel?.update(state: .failLoad(error: error ?? NSError()))
                    self?.rootModel?.routerSubject.send(.alert(title: "Error",
                                                               message: "It was not possible to upload a image"))
                }
                return
            }
            DispatchQueue.main.async {
                self?.rootModel?.update(state: .show(image: image))
            }
        }
        task.resume()
    }

    func onTapImage() {
        guard let image = rootModel?.image else {
            rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
            return
        }
        rootModel?.routerSubject.send(.descriptionImage(image: image))
    }
}




Enrutador



import SwiftUI
import Combine

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
    @State private var isFullImageVisible = false
    @State private var isAlertVisible = false

    var body: some View {
        Group {
            alertView()
            descriptionImageView()
        }.onReceive(screen, perform: { type in
            self.screenType = type
            switch type {
            case .alert:
                self.isAlertVisible = true

            case .descriptionImage:
                self.isFullImageVisible = true
            }
        })
    }
}

private extension RootRouter {

    private func alertView() -> some View {
        guard let type = screenType, case .alert(let title, let message) = type else {
            return EmptyView().toAnyView()
        }
        return Spacer().alert(isPresented: $isAlertVisible, content: {
            Alert(title: Text(title), message: Text(message))
        }).toAnyView()
    }

    private func descriptionImageView() -> some View {
        guard let type = screenType, case .descriptionImage(let image) = type else {
            return EmptyView().toAnyView()
        }
        return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
            self.screenType = nil
        }, content: {
            DescriptionImageView.build(image: image, action: { _ in
                // code
            })
        }).toAnyView()
    }
}






Ahora vayamos a examinar cada módulo por separado.



Antes de continuar con la implementación, necesitamos una extensión para la Vista, que simplificará la escritura del código y lo hará más legible.



extension View {
    func toAnyView() -> AnyView {
        AnyView(self)
    }
}




Ver



Ver: acepta eventos del usuario, los pasa al Intent y espera un cambio de estado del modelo



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. Todos los eventos que recibe la Vista se pasan a la Intención. Intent mantiene un vínculo con el estado real de la Vista en sí mismo, ya que es él quien cambia los estados. Se necesita el contenedor @ObservedObject para transferir a Ver todos los cambios que ocurren en el Modelo (más detalles a continuación)
  2. Simplifica la creación de una Vista, por lo que es más fácil aceptar datos de otra pantalla (ejemplo RootView.build () o HomeView.build (artículo: 42) )
  3. Envía el evento del ciclo de vida de la vista a la intención
  4. Funciones que crean elementos personalizados
  5. El usuario puede ver diferentes estados de pantalla, todo depende de los datos que haya en el modelo. Si el valor booleano del atributo intent.model.isLoading es verdadero , el usuario ve la carga, si es falso, entonces ve el contenido cargado o un error. Dependiendo del estado, el usuario verá diferentes elementos personalizados.


Modelo



Modelo: mantiene el estado real de la pantalla.



 import SwiftUI

// 1
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}

class RootModel: ObservableObject, RootModeling {
    // 2
    @Published var image: UIImage?
    @Published var isLoading: Bool = true
    @Published var error: Error?
} 


  1. El protocolo es necesario para mostrar la vista solo lo que se necesita para mostrar la interfaz de usuario
  2. Se necesita @Published para la transferencia de datos reactivos a la Vista


Intención



Inent: espera eventos de View para acciones adicionales. Trabaja con lógica de negocios y bases de datos, realiza solicitudes al servidor, etc.



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


  1. La intención contiene un enlace al modelo y, cuando es necesario, cambia los datos del modelo. RootModelIng es un protocolo que muestra los atributos del Modelo y no permite cambiarlos
  2. Para cambiar los atributos en Intent, convertimos RootModelProperties a RootModel
  3. Intent está constantemente esperando que cambien los atributos del modelo y los pasa a la Vista. AnyCancellable le permite no mantener en memoria una referencia para esperar cambios de Model. De esta manera simple, la Vista obtiene el estado más actual.
  4. Esta función recibe un evento del usuario y descarga una imagen.
  5. Así es como cambiamos el estado de la pantalla


Este enfoque (cambiar de estado a su vez) tiene un inconveniente: si el modelo tiene muchos atributos, al cambiar los atributos, puede olvidarse de cambiar algo.



Una posible solucion
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)
... 




Creo que esta no es la única solución y que puedes resolver el problema de otras formas.



Hay un inconveniente más: la clase Intent puede crecer mucho con mucha lógica empresarial. Este problema se resuelve dividiendo la lógica empresarial en servicios.



¿Y la navegación? MVI + R



Si logra hacer todo en Ver, lo más probable es que no haya problemas. Pero si la lógica se vuelve más complicada, surgen una serie de dificultades. Al final resultó que, hacer un enrutador con transferencia de datos a la siguiente pantalla y devolver los datos a la Vista que llamó a esta pantalla no es tan fácil. La transferencia de datos se puede realizar a través de @EnvironmentObject, pero luego todas las Vistas debajo de la jerarquía tendrán acceso a estos datos, lo cual no es bueno. Rechazamos esta idea. Dado que los estados de la pantalla cambian a través del Modelo, nos referimos al Enrutador a través de esta entidad.



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. Punto de entrada. Mediante este atributo nos referiremos a Router


Para no obstruir la Vista principal, todo lo relacionado con las transiciones a otras pantallas se elimina en una Vista separada.



 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. Una vista separada que contiene toda la lógica y los elementos personalizados relacionados con la navegación.
  2. Envía el evento del ciclo de vida de la vista a la intención


Intent recopila todos los datos necesarios para la transición



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


  1. Si por alguna razón no hay imagen, entonces transfiere todos los datos necesarios al Modelo para mostrar el error.
  2. Envía los datos necesarios al Modelo para abrir una pantalla con una descripción detallada de la imagen




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


  1. Enumeración con los datos necesarios para las pantallas
  2. Los eventos se enviarán a través de este atributo. Por eventos, entenderemos qué pantalla debe mostrarse.
  3. Este atributo es necesario para almacenar datos para abrir la pantalla.
  4. Cambie de falso a verdadero y se abrirá la pantalla requerida


Conclusión



SwiftUI, como MVI, se basa en la reactividad, por lo que encajan bien. Hay dificultades con la navegación y un Intent grande con lógica compleja, pero todo se puede resolver. MVI le permite implementar pantallas complejas y, con un mínimo esfuerzo, cambiar el estado de la pantalla de forma muy dinámica. Esta implementación, por supuesto, no es la única correcta, siempre hay alternativas. Sin embargo, el patrón encaja muy bien con el nuevo enfoque de la interfaz de usuario de Apple. Una clase para todos los estados de la pantalla hace que sea mucho más fácil trabajar con la pantalla.



El código del artículo , así como las plantillas para Xcode, se pueden ver en GitHub.



All Articles