Software Rules Aren't Software Laws
Model-View-ViewModel and similar design patterns were all created with the same goals. Improve testability by separating business logic from the View, so the business logic can be unit tested thoroughly. There are a few basic, agreed-upon rules for MVVM:
- The View should not contain any business logic.
- The View should get its data from the ViewModel, generally by binding.
- The ViewModel should not have a reference to the View. Updates to the view should be triggered by bindings or by invoking a delegate.
- The ViewModel should get its data from the Model from method calls.
These are very good guidelines that can greatly help in pulling unit-testable code out of views, and allowing the Model to be mocked. However, I do have some general issues with the use of this pattern. To start, MVVM was originally concieved of with the Windows Presentation Framework (WPF) which is developed in C#. This made use of the tight coupling of a .xaml
file and a .xaml.cs
file which are linked together. The .xaml
has UI elements that can be bound to properties in the .xaml.cs
file. The code in the .xaml.cs
files are still part of the View, and are therefore very difficult to unit test, but they can programmatically interact with other classes that do business logic and provide data. This allows those external pieces to be unit tested in isolation of the View. The relationship between .xaml
and .xaml.cs
is very similar to that of .xib
and UIViewController
for iOS and .xml
and View
for Android
The issue I have is when developers start applying these patterns to architectures like Android and iOS, with absolute conformity to these rules. There are some semantics of these systems that do not play nicely with the rules of MVVM. For example, in both Android and iOS, navigating between views requires the use of an object that belongs to the View. For Android, you must call startActivity(Intent intent)
, or an overload of it, on your current Activity
in order to start and display a new Activity
. For iOS, you need a reference to UINavigationController
, which is an object that belongs to UIViewController
, in order to push a new UIViewController
on top of the stack. In order to adhere to MVVM rule number 3, we are not allowed to reference the View or things that directly belong to it that the ViewModel does not already have visibility of.
The problems come when you now need to trigger navigation on the result of some business logic. The common solution is to implement a singleton 'Navigator' class, which takes an innocuous variable like a String
or enum
and it uses its own reference to the View's navigation functions in order to instantiate the corresponding View and execute the navigation. This also allows the Navigator to be mocked for the sake of unit testing, so it doesn't actually attempt to initialize Views. While this can work, it requires the Navigator to have logic which parses whatever parameters are provided and translates that into a usable View. This means that the more screens there are, the more the Navigator grows in complexity. This problem stems from the first rule. If the navigation is based on the resulf of a service request, for example, then it could be considered business logic - regardless of the fact that it is fundamentally rooted in the View. Therefore, it would violate that first rule, and should not be done in accordance with MVVM.
However, if we acknowledge that there is the possibility that some work may need to happen in the View, we can completely get rid of the Navigator. We can still control and test the business logic in the ViewModel, without creating something like a Navigator in order to do work, and instead handle that behavior in the View. Potentially, each ViewModel could contain and invoke a Delegate, or Observable that the View is observing. The View could navigate on the trigger of the Observable. If there were multiple potential places to navigate, the ViewModel could invoke that Observable with a parameter of an enum or data object, or both if need be. The View would still have to execute the switch:case statement and navigate accordingly, but that's not more information than the Navigator implementation had, and it's limited to only the options that the single View can navigate to, as opposed to a massive singleton.
class ViewModel {
var navigationVariable = Variable<DataObject>()
var data:DataObject
init(data:DataObject){
self.data = data
}
func navigateWithDataObject(){
self.navigationObservable.value = self.data
}
}
class ViewController : UIViewController {
let viewModel = ViewModel()
override func viewDidLoad(){
super.viewDidLoad()
_ = self.viewModel.navigationVariable.asObservable()
.takeUntil(self.rx_deallocated)
.subscribeNext { data in
let newViewModel = NewViewModel(data)
let newViewController = NewViewController()
newViewController.viewModel = newViewModel
self.navigationController?.pushViewController(newViewController, animated:false)
}
}
}
This simple Swift example uses RxSwift in order to create the Observable needed to trigger the navigation action. It also demonstrates how data can even be passed on to the next view via its ViewModel. An argument against this pattern is the idea that allowing the data
object to be visible in this manner in the ViewController has the potential for allowing people to do business logic in the View. Unfortunately, even if we don't use the data
variable in this way, it would still be visibile throught the ViewModel, that belongs to the ViewController. We would have to make the variable private in order to protect against that, but if we need to display some data on that object on this View, then that can't be done either. It should fall on the developer to use follow these rules appropriately and to not perform business logic where it shouldn't happen.
The idea of MVVM is to improve the quality of code by enhancing testability, and the rules are loose guidelines to assist this. If they are taken too literally, and enforced unnecessarily just because 'that breaks the rules', then worse patterns will be implemented under the guise of being better, because they adhere to these guidelines.
Update: After having a few discussions, this solution was suggested in order to remove the reference to the DataObject
in the View. I like it.
class ViewModel {
var navigationVariable = Variable<NewViewModel>()
private var data:DataObject
init(data:DataObject){
self.data = data
}
func navigateWithDataObject(){
self.navigationObservable.value = NewViewModel(self.data)
}
}
class ViewController : UIViewController {
let viewModel = ViewModel()
override func viewDidLoad(){
super.viewDidLoad()
_ = self.viewModel.navigationVariable.asObservable()
.takeUntil(self.rx_deallocated)
.subscribeNext { newViewModel in
let newViewController = NewViewController()
newViewController.viewModel = newViewModel
self.navigationController?.pushViewController(newViewController, animated:false)
}
}
}