Zunächst wurde das gesamte Projekt in Objective-C geschrieben und ReactiveCocoa Version 2.0 verwendet
Die Interaktion zwischen View und ViewModel wurde mithilfe von Bindungen der Eigenschaften des Ansichtsmodells durchgeführt, und alles wäre in Ordnung, außer dass das Debuggen eines solchen Codes sehr schwierig war. Alles aufgrund des Mangels an Eingabe und Brei in der Stapelverfolgung :(
Und jetzt ist es Zeit, Swift zu verwenden. Zuerst haben wir uns entschlossen, überhaupt ohne Reaktivität zu versuchen. Zeigen Sie explizit aufgerufene Methoden im ViewModel an, und ViewModel hat die Änderungen mithilfe eines Delegaten gemeldet:
protocol ViewModelDelegate { func didUpdateTitle(newTitle: String) } class View: UIView, ViewModelDelegate { var viewModel: ViewModel func didUpdateTitle(newTitle: String) { //handle viewModel updates } } class ViewModel { weak var delegate: ViewModelDelegate? func handleTouch() { //respond to some user action } }
Es sieht gut aus. Aber als das ViewModel wuchs, bekam der Delegierte eine Reihe von Methoden, um mit jedem vom ViewModel erzeugten Niesen fertig zu werden:
protocol ViewModelDelegate { func didUpdate(title: String) func didUpdate(subtitle: String) func didReceive(items: [SomeItem]) func didReceive(error: Error) func didChangeLoading(isLoafing: Bool) //... }
Jede Methode muss implementiert werden, und als Ergebnis erhalten wir einen riesigen Fußstoff aus den Methoden in der Ansicht. Es sieht nicht sehr cool aus. Gar nicht cool. Wenn Sie darüber nachdenken, wenn Sie RxSwift verwenden, erhalten Sie eine ähnliche Situation, aber anstatt Delegate-Methoden zu implementieren, gibt es eine Reihe von Ordnern für verschiedene ViewModel-Eigenschaften.
Die Ausgabe bietet sich an: Sie müssen alle Methoden zu einer und den Aufzählungseigenschaften wie folgt kombinieren:
enum ViewModelEvent { case updateTitle(String) case updateSubtitle(String) case items([SomeItem]) case error(Error) case loading(Bool) //... }
Auf den ersten Blick ändert sich das Wesen nicht. Aber anstelle von sechs Methoden erhalten wir eine mit einem Schalter:
func handle(event: ViewModelEvent) { switch event { case .updateTitle(let newTitle): //... case .updateSubtitle(let newSubtitle): //... case .items(let newItems): //... case .error(let error): //... case .loading(let isLoading): //... } }
Aus Gründen der Symmetrie können Sie im ViewModel eine weitere Aufzählung und ihren Handler erstellen:
enum ViewEvent { case touchButton case swipeLeft } class ViewModel { func handle(event: ViewEvent) { switch event { case .touchButton: //... case .swipeLeft: //... } } }
Alles sieht viel prägnanter aus und bietet einen einzigen Interaktionspunkt zwischen View und ViewModel, was sich sehr gut auf die Lesbarkeit des Codes auswirkt. Es stellt sich als Win-Win heraus - und die Überprüfung der Pull-Anfragen wird beschleunigt, und Neulinge rollen schnell in das Projekt ein.
Aber kein Allheilmittel. Probleme treten auf, wenn ein Ansichtsmodell seine Ereignisse an mehrere Ansichten melden möchte, z. B. ContainerView und ContentView (eine ist in die andere eingebettet). Die Lösung ergibt sich wiederum von selbst. Wir schreiben eine neue Klasse anstelle des Delegaten:
class Output<Event> { var handlers = [(Event) -> Void]() func send(_ event: Event) { for handler in handlers { handler(event) } } }
In der handlers
Eigenschaft speichern handlers
Lesezeichen mit Aufrufen der handle(event:)
-Methoden, und wenn wir die send(_ event:)
-Methode aufrufen, rufen wir alle Handler mit diesem Ereignis auf. Auch hier scheint das Problem gelöst zu sein, aber jedes Mal, wenn Sie View - ViewModel binden, müssen Sie Folgendes schreiben:
vm.output.handlers.append({ [weak view] event in DispatchQueue.main.async { view?.handle(event: event) } }) view.output.handlers.append({ [weak vm] event in vm?.handle(event: event) })
Nicht sehr cool.
Wir schließen View und ViewModel mit den Protokollen:
protocol ViewModel { associatedtype ViewEvent associatedtype ViewModelEvent var output: Output<ViewModelEvent> { get } func handle(event: ViewEvent) func start() } protocol View: ViewModelContainer { associatedtype ViewModelEvent associatedtype ViewEvent var output: Output<ViewEvent> { get } func setupBindings() func handle(event: ViewModelEvent) }
Warum die Methoden start()
und setupBindings()
benötigt werden, werden wir später beschreiben. Wir schreiben Erweiterungen für das Protokoll:
extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return } vm.output.handlers.append({ [weak self] event in DispatchQueue.main.async { self?.handle(event: event) } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) setupBindings() vm.start() } }
Und wir erhalten eine vorgefertigte Methode zum Verknüpfen von View - ViewModel, deren Ereignisse übereinstimmen. Die start()
-Methode stellt sicher, dass die Ansicht bei ihrer Ausführung bereits alle Ereignisse empfängt, die vom ViewModel gesendet werden, und die setupBindings()
-Methode wird benötigt, wenn Sie das ViewModel in Ihre eigenen Unteransichten werfen müssen, sodass diese Methode standardmäßig in der Erweiterung implementiert werden kann. ' e.
Es stellt sich heraus, dass für die Beziehung zwischen View und ViewModel ihre spezifischen Implementierungen absolut nicht wichtig sind. Hauptsache, dass View ViewModel-Ereignisse verarbeiten kann und umgekehrt. Um in der Ansicht keinen bestimmten Link zum ViewModel, sondern dessen verallgemeinerte Version zu speichern, können Sie einen zusätzlichen TypeErasure-Wrapper schreiben (da es unmöglich ist, Eigenschaften des Protokolltyps mit dem associatedtype
Typ zu verwenden):
class AnyViewModel<ViewModelEvent, ViewEvent>: ViewModel { var output: Output<ViewModelEvent> let startClosure: EmptyClosure let handleClosure: (ViewEvent) -> Void let vm: Any? private var isStarted = false init?<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = vm else { return nil } self.output = vm.output self.vm = vm self.startClosure = { [weak vm] in vm?.start() } self.handleClosure = { [weak vm] in vm?.handle(event: $0) }//vm.handle } func start() { if !isStarted { isStarted = true startClosure() } } func handle(event: ViewEvent) { handleClosure(event) } }
Weiter mehr
Wir haben uns entschlossen, weiter zu gehen und die Eigenschaft offensichtlich nicht in der Ansicht zu speichern, sondern sie über die Laufzeit festzulegen. Insgesamt stellte sich die Erweiterung für das View
Protokoll folgendermaßen heraus:
extension View where Self: NSObject { func bind<ViewModelType: ViewModel>(with vm: ViewModelType?) where ViewModelType.ViewModelEvent == ViewModelEvent, ViewModelType.ViewEvent == ViewEvent { guard let vm = AnyViewModel(with: vm) else { return } vm.output.handlers.append({ [weak self] event in if #available(iOS 10.0, *) { RunLoop.main.perform(inModes: [.default], block: { self?.handle(event: event) }) } else { DispatchQueue.main.async { self?.handle(event: event) } } }) output.handlers.append({ [weak vm] event in vm?.handle(event: event) }) p_viewModelSaving = vm setupBindings() vm.start() } private var p_viewModelSaving: Any? { get { return objc_getAssociatedObject(self, &ViewModelSavingHandle) } set { objc_setAssociatedObject(self, &ViewModelSavingHandle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } var viewModel: AnyViewModel<ViewModelEvent, ViewEvent>? { return p_viewModelSaving as? AnyViewModel<ViewModelEvent, ViewEvent> } }
Es ist ein kontroverser Moment, aber wir haben beschlossen, dass es bequemer ist, diese Eigenschaft nicht jedes Mal zu deklarieren.
Muster
Dieser Ansatz passt perfekt zu Xcode-Vorlagen und ermöglicht es Ihnen, Module mit wenigen Klicks sehr schnell zu generieren. Beispielvorlage für Ansicht:
final class ___VARIABLE_moduleName___ViewController: UIView, View { var output = Output<___VARIABLE_moduleName___ViewModel.ViewEvent>() override func viewDidLoad() { super.viewDidLoad() setupViews() } private func setupViews() { //Do layout and more } func handle(event: ___VARIABLE_moduleName___ViewModel.ViewModelEvent) { } }
Und für ViewModel:
final class ___VARIABLE_moduleName___ViewModel: ViewModel { var output = Output<ViewModelEvent>() func start() { } func handle(event: ViewEvent) { } } extension ___VARIABLE_moduleName___ViewModel { enum ViewEvent { } enum ViewModelEvent { } }
Das Erstellen der Modulinitialisierung im Code dauert nur drei Zeilen:
let viewModel = SomeViewModel() let view = SomeView() view.bind(with: viewModel)
Fazit
Als Ergebnis haben wir eine flexible Möglichkeit zum Austausch von Nachrichten zwischen View und ViewModel erhalten, die über einen einzigen Einstiegspunkt verfügt und auf der Generierung von Xcode-Code basiert. Dieser Ansatz ermöglichte es, die Entwicklung von Funktionen und Pull-Request-Überprüfungen zu beschleunigen, außerdem die Lesbarkeit und Einfachheit des Codes zu verbessern und das Schreiben von Tests zu vereinfachen (aufgrund der Tatsache, dass es bei Kenntnis der gewünschten Reihenfolge des Empfangs von Ereignissen aus dem Ansichtsmodell einfach ist, Unit-Tests zu schreiben, mit denen diese Reihenfolge vorliegt kann garantiert werden). Obwohl dieser Ansatz erst seit kurzem bei uns angewendet wird, hoffen wir, dass er sich vollständig rechtfertigt und die Entwicklung erheblich vereinfacht.
PS
Und eine kleine Ankündigung für Liebhaber der Entwicklung für iOS - bereits an diesem Donnerstag, dem 25. Juli, werden wir einen iOS-Mitap in ART-SPACE abhalten. Der Eintritt ist frei.