📜 ⬆️ ⬇️

How we at QIWI came to a common style of interaction between View and ViewModel within MVVM

Initially, the entire project was written in Objective-C and used ReactiveCocoa version 2.0


The interaction between the View and ViewModel was carried out by means of bindings of the properties of the view model, and all would be fine, except that debugging such code was very difficult. All due to the lack of typing and porridge in the stack trace :(


And now it's time to use Swift. At first, we decided to try without reactivity at all. View explicitly called methods on ViewModel, and ViewModel reported its changes using a delegate:


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

Looks good. But as the ViewModel grew, we began to get a bunch of methods in the delegate to handle every sneeze produced by the ViewModel:


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

Each method needs to be implemented, and as a result we get a huge footcloth from the methods in the view. It doesn't look very cool. Not at all cool. If you think about using RxSwift, you would get a similar situation, but instead of implementing the delegate methods, there would be a bunch of binders for different ViewModel properties.


The output suggests itself: you need to combine all the methods into one and the enumeration properties something like this:


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

At first glance, the essence does not change. But instead of six methods, we get one with a switch:


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

For symmetry, you can create another enumeration and its handler in the ViewModel:


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

It all looks a lot more concise, plus it gives a single point of interaction between View and ViewModel, which very well affects the readability of the code. It turns out win-win - and the pull request review is accelerated, and newcomers quickly roll into the project.


But not a panacea. Problems begin to arise when one view model wants to report its events to several views, for example, ContainerView and ContentView (one is embedded in the other). The solution, again, arises by itself, we write a new class instead of the delegate:


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

In the handlers property, handlers store bookmarks with calls to the handle(event:) methods, and when we call the send(_ event:) method, we call all handlers with this event. And again, the problem seems to be resolved, but every time you bind View - ViewModel, you have to write this:


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

Not very cool.
Close the View and ViewModel protocols:


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

Why the start() and setupBindings() methods are needed - we will describe later. We are writing extensions for the protocol:


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

And we get a ready-made method for linking any View - ViewModel, the events of which match. The start() method ensures that when it is executed, the view will already receive all the events that will be sent from the ViewModel, and the setupBindings() method will be needed if you need to throw the ViewModel into your own subviews, so this method can be implemented by default in extension ' e.


It turns out that the specific implementation is absolutely not important for the relationship between View and ViewModel, the main thing is that View can handle ViewModel events, and vice versa. And in order to store in the view not a specific link to the ViewModel, but its generalized version, you can write an additional TypeErasure wrapper (since it is impossible to use properties of the protocol type with 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) } } 

Further more


We decided to go further, and obviously not store the property in the view, but set it through the runtime, in total, the extension for the View protocol turned out like this:


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

It’s a controversial moment, but we decided that it would just be more convenient not to declare this property every time.


Patterns


This approach fits perfectly with Xcode templates and allows you to very quickly generate modules in a couple of clicks. Example template for 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) { } } 

And for 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 { } } 

And creating module initialization in the code takes only three lines:


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

Conclusion


As a result, we got a flexible way of exchanging messages between View and ViewModel, which has a single entry point and is well based on Xcode code generation. This approach made it possible to speed up the development of features and pull-request reviews, in addition, it increased the readability and simplicity of the code and simplified the writing of tests (due to the fact that, knowing the desired sequence of receiving events from the view model, it is easy to write Unit-tests with which this sequence can be guaranteed). Although this approach has begun to be used with us quite recently, we hope that it will fully justify itself and greatly simplify the development.


PS


And a small announcement for lovers of development for iOS - already this Thursday, July 25, we will hold an iOS mitap in ART-SPACE , admission is free, come.


')

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


All Articles