MVI und SwiftUI - ein Zustand





Nehmen wir an, wir müssen die Funktionsweise des Bildschirms geringfügig anpassen. Der Bildschirm ändert sich jede Sekunde, da viele Prozesse gleichzeitig ablaufen. Um alle Bildschirmzustände zu regeln, muss in der Regel auf Variablen verwiesen werden, von denen jede ihr eigenes Leben hat. Es ist entweder sehr schwierig oder völlig unmöglich, sie im Auge zu behalten. Um die Ursache des Problems zu finden, müssen Sie die Variablen und Zustände des Bildschirms verstehen und sogar sicherstellen, dass unser Fix an anderer Stelle nichts kaputt macht. Nehmen wir an, wir haben viel Zeit verbracht und trotzdem die erforderlichen Änderungen vorgenommen. War es möglich, dieses Problem einfacher und schneller zu lösen? Lass es uns herausfinden.



MVI



Dieses Muster wurde erstmals vom JavaScript-Entwickler Andre Stalz beschrieben. Die allgemeinen Prinzipien finden Sie unter dem Link







Absicht : Wartet auf Ereignisse vom Benutzer und verarbeitet sie.

Modell : Wartet auf behandelte Ereignisse, um den Status zu ändern.

Ansicht : Wartet auf Statusänderungen und zeigt sie an.

Benutzerdefiniertes Element : Ein Unterabschnitt der Ansicht, der selbst ein UI-Element ist. Kann als MVI oder als Webkomponente implementiert werden. Optional in Ansicht.



Angesichts eines reaktiven Ansatzes. Jedes Modul (jede Funktion) erwartet ein Ereignis und übergibt dieses Ereignis nach Empfang und Verarbeitung an das nächste Modul. Es stellt sich ein unidirektionaler Fluss heraus. Der einzelne Status der Ansicht befindet sich im Modell und löst somit das Problem vieler schwer zu verfolgender Zustände.



Wie kann dies in einer mobilen Anwendung angewendet werden?



Martin Fowler und Rice David haben in ihrem Buch "Patterns of Enterprise Applications" geschrieben, dass Muster Muster zur Lösung von Problemen sind. Anstatt sie eins zu eins zu kopieren, ist es besser, sie an die aktuellen Realitäten anzupassen. Die mobile Anwendung hat ihre eigenen Einschränkungen und Funktionen, die berücksichtigt werden müssen. View empfängt ein Ereignis vom Benutzer und kann dann an die Absicht weitergeleitet werden. Das Schema ist leicht modifiziert, aber das Prinzip des Musters bleibt dasselbe.







Implementierung





Es wird unten viel Code geben.

Der endgültige Code kann unter dem Spoiler unten angezeigt werden.



MVI-Implementierung
Aussicht



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




Modell



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




Absicht



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




Router



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






Lassen Sie uns nun jedes Modul einzeln untersuchen.



Bevor wir mit der Implementierung fortfahren, benötigen wir eine Erweiterung für die Ansicht, die das Schreiben des Codes vereinfacht und ihn lesbarer macht.



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




Aussicht



Ansicht - akzeptiert Ereignisse vom Benutzer, übergibt sie an die Absicht und wartet auf eine Statusänderung aus dem Modell



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. Alle Ereignisse, die die Ansicht empfängt, werden an die Absicht übergeben. Intent behält eine Verbindung zum tatsächlichen Status der Ansicht an sich bei, da er es ist, der die Status ändert. Der @ ObservedObject-Wrapper wird benötigt, um alle im Modell auftretenden Änderungen in die Ansicht zu übertragen (weitere Details siehe unten).
  2. Vereinfacht das Erstellen einer Ansicht, sodass es einfacher ist, Daten von einem anderen Bildschirm zu akzeptieren (Beispiel RootView.build () oder HomeView.build (articul: 42) ).
  3. Sendet das Lebenszyklusereignis der Ansicht an die Absicht
  4. Funktionen, die benutzerdefinierte Elemente erstellen
  5. Der Benutzer kann verschiedene Bildschirmzustände sehen. Dies hängt davon ab, welche Daten sich derzeit im Modell befinden. Wenn der boolesche Wert des Attributs intent.model.isLoading true ist , sieht der Benutzer das Laden. Wenn false, sieht er den geladenen Inhalt oder einen Fehler. Je nach Status werden dem Benutzer verschiedene benutzerdefinierte Elemente angezeigt.


Modell



Modell - behält den aktuellen Status des Bildschirms bei



 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. Das Protokoll wird benötigt, um der Ansicht nur das anzuzeigen, was zur Anzeige der Benutzeroberfläche erforderlich ist
  2. @Published wird für die reaktive Datenübertragung in der Ansicht benötigt


Absicht



Inent - Wartet auf Ereignisse aus View für weitere Aktionen. Arbeitet mit Geschäftslogik und Datenbanken, stellt Anforderungen an den Server usw.



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. Die Absicht enthält einen Link zum Modell und ändert bei Bedarf die Daten des Modells. RootModelIng ist ein Protokoll, das die Attribute des Modells anzeigt und verhindert, dass sie geändert werden
  2. Um die Attribute in der Absicht zu ändern, konvertieren wir die RootModelProperties in RootModel
  3. Die Absicht wartet ständig darauf, dass sich die Attribute des Modells ändern, und übergibt sie an die Ansicht. Mit AnyCancellable können Sie keine Referenz im Speicher behalten, um auf Änderungen am Modell zu warten. Auf diese einfache Weise erhält die Ansicht den aktuellsten Status.
  4. Diese Funktion empfängt ein Ereignis vom Benutzer und lädt ein Bild herunter
  5. So ändern wir den Status des Bildschirms


Dieser Ansatz (das Ändern von Zuständen) hat einen Nachteil: Wenn das Modell viele Attribute hat, können Sie beim Ändern von Attributen vergessen, etwas zu ändern.



Eine mögliche Lösung
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)
... 




Ich glaube, dass dies nicht die einzige Lösung ist und dass Sie das Problem auf andere Weise lösen können.



Es gibt noch einen weiteren Nachteil: Die Intent-Klasse kann mit viel Geschäftslogik sehr wachsen. Dieses Problem wird gelöst, indem die Geschäftslogik in Dienste aufgeteilt wird.



Was ist mit der Navigation? MVI + R.



Wenn Sie es schaffen, alles in View zu erledigen, gibt es höchstwahrscheinlich keine Probleme. Wenn die Logik jedoch komplizierter wird, treten eine Reihe von Schwierigkeiten auf. Wie sich herausstellte, ist es nicht so einfach, einen Router mit Datenübertragung zum nächsten Bildschirm und zur Rückgabe von Daten an die Ansicht, die diesen Bildschirm aufgerufen hat, zu erstellen. Die Datenübertragung kann über @EnvironmentObject erfolgen, aber dann haben alle Ansichten unterhalb der Hierarchie Zugriff auf diese Daten, was nicht gut ist. Wir lehnen diese Idee ab. Da sich die Bildschirmzustände durch das Modell ändern, verweisen wir über diese Entität auf den 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. Eingangspunkt. Durch dieses Attribut verweisen wir auf Router


Um die Hauptansicht nicht zu verstopfen, wird alles, was mit Übergängen zu anderen Bildschirmen zusammenhängt, in einer separaten Ansicht entfernt



 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. Eine separate Ansicht, die alle logischen und benutzerdefinierten Elemente enthält, die sich auf die Navigation beziehen
  2. Sendet das Lebenszyklusereignis der Ansicht an die Absicht


Intent sammelt alle notwendigen Daten für den Übergang



// 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. Wenn aus irgendeinem Grund kein Bild vorhanden ist, werden alle erforderlichen Daten an das Modell übertragen, um den Fehler anzuzeigen
  2. Sendet die erforderlichen Daten an das Modell, um einen Bildschirm mit einer detaillierten Beschreibung des Bildes zu öffnen




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. Aufzählung mit den erforderlichen Daten für Bildschirme
  2. Ereignisse werden über dieses Attribut gesendet. Unter Ereignissen werden wir verstehen, welcher Bildschirm angezeigt werden soll
  3. Dieses Attribut wird benötigt, um Daten zum Öffnen des Bildschirms zu speichern.
  4. Wechseln Sie von false zu true und der gewünschte Bildschirm wird geöffnet


Fazit



SwiftUI basiert wie MVI auf Reaktivität, sodass sie gut zusammenpassen. Es gibt Schwierigkeiten mit der Navigation und große Absichten mit komplexer Logik, aber alles kann gelöst werden. Mit MVI können Sie komplexe Bildschirme implementieren und mit minimalem Aufwand den Status des Bildschirms sehr dynamisch ändern. Diese Implementierung ist natürlich nicht die einzig richtige, es gibt immer Alternativen. Das Muster passt jedoch gut zum neuen UI-Ansatz von Apple. Eine Klasse für alle Bildschirmzustände erleichtert die Arbeit mit dem Bildschirm erheblich.



Der Code aus dem Artikel sowie Vorlagen für Xcode können auf GitHub angezeigt werden.



All Articles