Initialement, l'ensemble du projet a été écrit en Objective-C et utilisé ReactiveCocoa version 2.0
L'interaction entre View et ViewModel a été réalisée au moyen de liaisons des propriétés du modèle de vue, et tout irait bien, sauf que le débogage d'un tel code était très difficile. Tout cela en raison du manque de saisie et de bouillie dans la trace de la pile :(
Et maintenant, il est temps d'utiliser Swift. Au début, nous avons décidé d'essayer sans aucune réactivité. Affichez les méthodes explicitement appelées sur ViewModel et ViewModel a signalé ses modifications à l'aide d'un délégué:
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 } }
Ça a l'air bien. Mais à mesure que le ViewModel grandissait, nous avons commencé à avoir un tas de méthodes dans le délégué pour gérer chaque éternuement produit par le ViewModel:
protocol ViewModelDelegate { func didUpdate(title: String) func didUpdate(subtitle: String) func didReceive(items: [SomeItem]) func didReceive(error: Error) func didChangeLoading(isLoafing: Bool) //... }
Chaque méthode doit être implémentée, et en conséquence, nous obtenons un énorme footcloth des méthodes dans la vue. Ça n'a pas l'air très cool. Pas du tout cool. Si vous y réfléchissez, si vous utilisez RxSwift, vous obtiendriez une situation similaire, mais au lieu d'implémenter les méthodes déléguées, il y aurait un tas de liants pour différentes propriétés ViewModel.
La sortie se suggère: vous devez combiner toutes les méthodes en une et les propriétés d'énumération quelque chose comme ceci:
enum ViewModelEvent { case updateTitle(String) case updateSubtitle(String) case items([SomeItem]) case error(Error) case loading(Bool) //... }
À première vue, l'essence ne change pas. Mais au lieu de six méthodes, nous en obtenons une avec un interrupteur:
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): //... } }
Pour la symétrie, vous pouvez créer une autre énumération et son gestionnaire dans le ViewModel:
enum ViewEvent { case touchButton case swipeLeft } class ViewModel { func handle(event: ViewEvent) { switch event { case .touchButton: //... case .swipeLeft: //... } } }
Tout cela semble beaucoup plus concis, et il donne un point d'interaction unique entre View et ViewModel, ce qui affecte très bien la lisibilité du code. Il s'avère gagnant-gagnant - et l'examen des demandes de tirage est accéléré, et les nouveaux arrivants se lancent rapidement dans le projet.
Mais pas une panacée. Des problèmes commencent à survenir lorsqu'un modèle de vue souhaite signaler ses événements à plusieurs vues, par exemple ContainerView et ContentView (l'une est intégrée dans l'autre). La solution, encore une fois, se pose d'elle-même, nous écrivons une nouvelle classe à la place du délégué:
class Output<Event> { var handlers = [(Event) -> Void]() func send(_ event: Event) { for handler in handlers { handler(event) } } }
Dans la propriété handlers
, handlers
stockons des signets avec des appels aux méthodes handle(event:)
, et lorsque nous appelons la méthode send(_ event:)
, nous appelons tous les gestionnaires avec cet événement. Et encore une fois, le problème semble être résolu, mais chaque fois que vous liez View - ViewModel, vous devez écrire ceci:
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) })
Pas très cool.
Nous fermons View et ViewModel avec les protocoles:
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) }
Pourquoi les méthodes start()
et setupBindings()
sont nécessaires - nous décrirons plus tard. Nous écrivons des extensions pour le protocole:
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() } }
Et nous obtenons une méthode prête à l'emploi pour lier n'importe quel View - ViewModel, dont les événements correspondent. La méthode start()
garantit que lorsqu'elle est exécutée, la vue recevra déjà tous les événements qui seront envoyés à partir du ViewModel, et la méthode setupBindings()
sera nécessaire si vous devez lancer le ViewModel dans vos propres sous-vues, afin que cette méthode puisse être implémentée par défaut dans l'extension '' e.
Il s'avère que pour la relation entre View et ViewModel, leurs implémentations spécifiques ne sont absolument pas importantes, l'essentiel est que View puisse gérer les événements ViewModel, et vice versa. Et pour stocker dans la vue non pas un lien spécifique vers le ViewModel, mais sa version généralisée, vous pouvez écrire un wrapper TypeErasure supplémentaire (car il est impossible d'utiliser les propriétés du type de protocole avec le type associatedtype
):
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) } }
Plus plus
Nous avons décidé d'aller plus loin, et évidemment de ne pas stocker la propriété dans la vue, mais de la définir pendant l'exécution, au total, l'extension du protocole View
s'est révélée comme suit:
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> } }
C'est un moment controversé, mais nous avons décidé qu'il serait plus pratique de ne pas déclarer cette propriété à chaque fois.
Patterns
Cette approche s'intègre parfaitement avec les modèles Xcode et vous permet de générer très rapidement des modules en quelques clics. Exemple de modèle pour View:
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) { } }
Et pour 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 { } }
Et la création de l'initialisation du module dans le code ne prend que trois lignes:
let viewModel = SomeViewModel() let view = SomeView() view.bind(with: viewModel)
Conclusion
En conséquence, nous avons eu un moyen flexible d'échanger des messages entre View et ViewModel, qui a un point d'entrée unique et est bien basé sur la génération de code Xcode. Cette approche a permis d'accélérer le développement des fonctionnalités et des révisions de pull-request, en plus d'augmenter la lisibilité et la simplicité du code et de simplifier l'écriture des tests (du fait que, connaissant la séquence souhaitée de réception des événements du modèle de vue, il est facile d'écrire des tests unitaires avec lesquels cette séquence peut être garanti). Bien que cette approche ait commencé à être utilisée avec nous tout récemment, nous espérons qu'elle se justifiera pleinement et simplifiera considérablement le développement.
PS
Et une petite annonce pour les amateurs de développement pour iOS - déjà ce jeudi 25 juillet, nous organiserons un mitap iOS dans ART-SPACE , l'entrée est gratuite, venez.