Cómo en QIWI llegamos a un estilo común de interacción entre View y ViewModel dentro de MVVM

Inicialmente, todo el proyecto fue escrito en Objective-C y utilizó ReactiveCocoa versión 2.0


La interacción entre View y ViewModel se llevó a cabo mediante enlaces de las propiedades del modelo de vista, y todo estaría bien, excepto que la depuración de dicho código era muy difícil. Todo debido a la falta de tipeo y gachas en el seguimiento de la pila :(


Y ahora es el momento de usar Swift. Al principio, decidimos probar sin reactividad en absoluto. Ver métodos llamados explícitamente en ViewModel, y ViewModel informó sus cambios utilizando un delegado:


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

Se ve bien Pero a medida que creció ViewModel, comenzamos a obtener un montón de métodos en el delegado para manejar cada estornudo producido por ViewModel:


 protocol ViewModelDelegate { func didUpdate(title: String) func didUpdate(subtitle: String) func didReceive(items: [SomeItem]) func didReceive(error: Error) func didChangeLoading(isLoafing: Bool) //...  } 

Cada método debe implementarse y, como resultado, obtenemos un gran espacio de los métodos en la vista. No se ve muy bien. Para nada genial. Si lo piensa, si usa RxSwift, obtendría una situación similar, pero en lugar de implementar los métodos de delegado, habría un montón de carpetas para diferentes propiedades de ViewModel.


El resultado se sugiere a sí mismo: debe combinar todos los métodos en uno y las propiedades de enumeración más o menos así:


 enum ViewModelEvent { case updateTitle(String) case updateSubtitle(String) case items([SomeItem]) case error(Error) case loading(Bool) //...  } 

A primera vista, la esencia no cambia. Pero en lugar de seis métodos, obtenemos uno con un interruptor:


 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): //... } } 

Por simetría, puede crear otra enumeración y su controlador en ViewModel:


 enum ViewEvent { case touchButton case swipeLeft } class ViewModel { func handle(event: ViewEvent) { switch event { case .touchButton: //... case .swipeLeft: //... } } } 

Todo parece mucho más conciso, además ofrece un único punto de interacción entre View y ViewModel, lo que afecta muy bien la legibilidad del código. Resulta que todos ganan, y la revisión de la solicitud de extracción se acelera, y los recién llegados entran rápidamente en el proyecto.


Pero no es una panacea. Los problemas comienzan a surgir cuando un modelo de vista desea informar sus eventos a varias vistas, por ejemplo, ContainerView y ContentView (una está incrustada en la otra). La solución, de nuevo, surge por sí sola, escribimos una nueva clase en lugar del delegado:


 class Output<Event> { var handlers = [(Event) -> Void]() func send(_ event: Event) { for handler in handlers { handler(event) } } } 

En la propiedad de handlers , almacenamos marcadores con llamadas al método handle(event:) , y cuando llamamos al método send(_ event:) , llamamos a todos los controladores con este evento. Y nuevamente, el problema parece estar resuelto, pero cada vez que vincula View - ViewModel, debe escribir esto:


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

No muy guay.
Cerramos View y ViewModel con los protocolos:


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

Por qué se necesitan los métodos start() y setupBindings() , lo describiremos más adelante. Estamos escribiendo extensiones para el protocolo:


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

Y obtenemos un método listo para vincular cualquier Vista - ViewModel, cuyos eventos coinciden. El método start() asegura que cuando se ejecute, la vista ya recibirá todos los eventos que se enviarán desde ViewModel, y el método setupBindings() será necesario si necesita lanzar ViewModel en sus propias subvistas, por lo que este método puede implementarse de manera predeterminada en la extensión ' e.


Resulta que para la relación entre View y ViewModel, sus implementaciones específicas no son absolutamente importantes, lo principal es que View pueda manejar los eventos de ViewModel y viceversa. Y para almacenar en la vista no un enlace específico al ViewModel, sino su versión generalizada, puede escribir un contenedor adicional TypeErasure (ya que es imposible usar propiedades del tipo de protocolo con el tipo 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) } } 

Más más


Decidimos ir más allá y, obviamente, no almacenar la propiedad en la vista, sino configurarla durante el tiempo de ejecución, en total, la extensión para el protocolo de visualización resultó así:


 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 un momento controvertido, pero decidimos que sería más conveniente no declarar esta propiedad cada vez.


Patrones


Este enfoque encaja perfectamente con las plantillas de Xcode y le permite generar módulos muy rápidamente en un par de clics. Plantilla de ejemplo para Ver:


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

Y para 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 { } } 

Y crear la inicialización del módulo en el código solo toma tres líneas:


 let viewModel = SomeViewModel() let view = SomeView() view.bind(with: viewModel) 

Conclusión


Como resultado, obtuvimos una forma flexible de intercambiar mensajes entre View y ViewModel, que tiene un único punto de entrada y está bien basado en la generación de código Xcode. Este enfoque permitió acelerar el desarrollo de características y revisiones de solicitudes de extracción, además de aumentar la legibilidad y simplicidad del código y simplificó la escritura de pruebas (debido al hecho de que, conociendo la secuencia deseada de eventos de recepción del modelo de vista, es fácil escribir pruebas unitarias con las que esta secuencia puede ser garantizado) Aunque este enfoque ha comenzado a usarse con nosotros recientemente, esperamos que se justifique por completo y simplifique en gran medida el desarrollo.


PS


Y un pequeño anuncio para los amantes del desarrollo para iOS: ya este jueves, 25 de julio, tendremos un mitap para iOS en ART-SPACE , la entrada es gratuita, ven.

Source: https://habr.com/ru/post/460875/


All Articles