MVI et SwiftUI - un état





Disons que nous devons apporter une petite modification au fonctionnement de l'écran. L'écran change toutes les secondes car de nombreux processus sont en cours en même temps. En règle générale, pour régler tous les états de l'écran, il est nécessaire de se référer à des variables, chacune ayant sa propre vie. Les garder à l'esprit est soit très difficile, soit totalement impossible. Pour trouver la source du problème, vous devrez comprendre les variables et les états de l'écran, et même vous assurer que notre correctif ne casse rien ailleurs. Disons que nous avons passé beaucoup de temps et avons encore apporté les modifications nécessaires. Était-il possible de résoudre ce problème plus facilement et plus rapidement? Découvrons-le.



MVI



Ce modèle a été décrit pour la première fois par le développeur JavaScript Andre Stalz. Les principes généraux se trouvent sur le lien







Intention : attend les événements de l'utilisateur et les traite

Modèle : attend les événements gérés pour changer d'état

Vue : attend les changements d'état et les affiche

Élément personnalisé : une sous-section de la vue, qui est elle-même un élément de l'interface utilisateur. Peut être implémenté en tant que MVI ou en tant que composant Web. Facultatif dans View.



Face à une approche réactive. Chaque module (fonction) attend un événement, et après l'avoir reçu et traité, il passe cet événement au module suivant. Il s'avère un flux unidirectionnel. L'état unique de la vue réside dans le modèle et résout ainsi le problème de nombreux états difficiles à suivre.



Comment cela peut-il être appliqué dans une application mobile?



Martin Fowler et Rice David dans leur livre "Patterns of Enterprise Applications" ont écrit que les modèles sont des modèles pour résoudre des problèmes et qu'au lieu de les copier un à un, il vaut mieux les adapter aux réalités actuelles. L'application mobile a ses propres limites et fonctionnalités qui doivent être prises en compte. View reçoit un événement de l'utilisateur, puis il peut être envoyé par proxy à l'intention. Le schéma est légèrement modifié, mais le principe du motif reste le même.







la mise en oeuvre





Il y aura beaucoup de code ci-dessous.

Le code final peut être consulté sous le spoiler ci-dessous.



Implémentation MVI
Vue



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




Modèle



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




Intention



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




Routeur



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






Passons maintenant à l'examen de chaque module séparément.



Avant de procéder à l'implémentation, nous avons besoin d'une extension pour la vue, ce qui simplifiera l'écriture du code et le rendra plus lisible.



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




Vue



Afficher - accepte les événements de l'utilisateur, les transmet à l'intention et attend un changement d'état du modèle



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. Tous les événements reçus par la vue sont transmis à l'intention. L'intention garde un lien vers l'état réel de la vue en elle-même, puisque c'est lui qui change les états. L'encapsuleur @ObservedObject est nécessaire pour transférer vers la vue toutes les modifications qui se produisent dans le modèle (plus de détails ci-dessous)
  2. Simplifie la création d'une vue, il est donc plus facile d'accepter des données d'un autre écran (exemple RootView.build () ou HomeView.build (articul: 42) )
  3. Envoie l'événement du cycle de vie de la vue à l' intention
  4. Fonctions qui créent des éléments personnalisés
  5. L'utilisateur peut voir différents états d'écran, tout dépend des données contenues dans Model. Si la valeur booléenne de l'attribut intent.model.isLoading est true , l'utilisateur voit le chargement, si false, alors il voit le contenu chargé ou une erreur. En fonction de l'état, l'utilisateur verra différents éléments personnalisés.


Modèle



Modèle - conserve l'état réel de l'écran



 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. Le protocole est nécessaire pour afficher la vue uniquement ce qui est nécessaire pour afficher l'interface utilisateur
  2. @Published est nécessaire pour le transfert de données réactif dans la vue


Intention



Inent - attend les événements de View pour d'autres actions. Fonctionne avec la logique métier et les bases de données, fait des requêtes au serveur, 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. L'intention contient un lien vers le modèle et, si nécessaire, modifie les données du modèle. RootModelIng est un protocole qui montre les attributs du modèle et les empêche d'être modifiés
  2. Afin de changer les attributs dans l'intention, nous convertissons le RootModelProperties en RootModel
  3. L'intention attend constamment que les attributs du modèle changent et les transmet à la vue. AnyCancellable vous permet de ne pas conserver en mémoire une référence pour attendre les modifications de Model. De cette manière simple, la vue obtient l'état le plus actuel.
  4. Cette fonction reçoit un événement de l'utilisateur et télécharge une image
  5. C'est ainsi que nous changeons l'état de l'écran


Cette approche (changer les états à son tour) a un inconvénient: si le modèle a beaucoup d'attributs, alors lors du changement d'attributs, vous pouvez oublier de changer quelque chose.



Une solution possible
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)
... 




Je pense que ce n’est pas la seule solution et que vous pouvez résoudre le problème d’une autre manière.



Il y a un autre inconvénient: la classe Intent peut se développer beaucoup avec beaucoup de logique métier. Ce problème est résolu en divisant la logique métier en services.



Et la navigation? MVI + R



Si vous parvenez à tout faire dans View, il n'y aura probablement aucun problème. Mais si la logique se complique, un certain nombre de difficultés surgissent. Il s'est avéré que créer un routeur avec transfert de données vers l'écran suivant et renvoyer les données à la vue qui a appelé cet écran n'est pas si facile. Le transfert de données peut être effectué via @EnvironmentObject, mais toutes les vues situées sous la hiérarchie auront accès à ces données, ce qui n'est pas bon. Nous refusons cette idée. Puisque les états de l'écran changent à travers le modèle, nous nous référons au routeur via cette entité.



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. Point d'entrée. Grâce à cet attribut, nous ferons référence au routeur


Afin de ne pas obstruer la vue principale, tout ce qui concerne les transitions vers d'autres écrans est retiré dans une vue séparée



 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. Une vue distincte qui contient tous les éléments logiques et personnalisés liés à la navigation
  2. Envoie l'événement du cycle de vie de la vue à l'intention


Intent collecte toutes les données nécessaires à la transition



// 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, pour une raison quelconque, il n'y a pas d'image, il transfère toutes les données nécessaires au modèle pour afficher l'erreur
  2. Envoie les données nécessaires au modèle pour ouvrir un écran avec une description détaillée de l'image




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. Enum avec les données requises pour les écrans
  2. Les événements seront envoyés via cet attribut. Par événements, nous comprendrons quel écran doit être affiché
  3. Cet attribut est nécessaire pour stocker les données d'ouverture de l'écran.
  4. Passez de faux à vrai et l'écran requis s'ouvre


Conclusion



SwiftUI, comme MVI, est construit autour de la réactivité, donc ils s'emboîtent bien. Il y a des difficultés avec la navigation et une grande intention avec une logique complexe, mais tout peut être résolu. MVI vous permet d'implémenter des écrans complexes et, avec un minimum d'effort, de modifier très dynamiquement l'état de l'écran. Cette implémentation, bien sûr, n'est pas la seule correcte, il existe toujours des alternatives. Cependant, le modèle correspond bien à la nouvelle approche d'interface utilisateur d'Apple. Une classe pour tous les états d'écran facilite grandement l'utilisation de l'écran.



Le code de l'article , ainsi que les modèles pour Xcode, peuvent être consultés sur GitHub.



All Articles