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
Modell
Absicht
Router
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()
}
}
- 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).
- Vereinfacht das Erstellen einer Ansicht, sodass es einfacher ist, Daten von einem anderen Bildschirm zu akzeptieren (Beispiel RootView.build () oder HomeView.build (articul: 42) ).
- Sendet das Lebenszyklusereignis der Ansicht an die Absicht
- Funktionen, die benutzerdefinierte Elemente erstellen
- 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?
}
- Das Protokoll wird benötigt, um der Ansicht nur das anzuzeigen, was zur Anzeige der Benutzeroberfläche erforderlich ist
- @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()
}
}
- 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
- Um die Attribute in der Absicht zu ändern, konvertieren wir die RootModelProperties in RootModel
- 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.
- Diese Funktion empfängt ein Ereignis vom Benutzer und lädt ein Bild herunter
- 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>()
- 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)
}
}
- Eine separate Ansicht, die alle logischen und benutzerdefinierten Elemente enthält, die sich auf die Navigation beziehen
- 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))
}
}
- Wenn aus irgendeinem Grund kein Bild vorhanden ist, werden alle erforderlichen Daten an das Modell übertragen, um den Fehler anzuzeigen
- 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()
}
}
- Aufzählung mit den erforderlichen Daten für Bildschirme
- Ereignisse werden über dieses Attribut gesendet. Unter Ereignissen werden wir verstehen, welcher Bildschirm angezeigt werden soll
- Dieses Attribut wird benötigt, um Daten zum Öffnen des Bildschirms zu speichern.
- 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.