UrbanClap’s stream to MVVM
Often times we need to extend an existing feature without breaking flows, reuse code (as much as possible) and test the changes easily. A good architecture can help us achieve all of this.
There are many architectures, some old and well known like MVP, MVVM and some new Unidirectional patterns like MVI & Redux. Rather than talking about all of them, I will focus on the one architecture we chose at Urbanclap, why we chose it and the issues faced during migration to it.
MVVM, we choose you!!!
After going through all popular and major architectures, we evaluated and finally decided to go ahead with MVVM. Here is why:
- Testability: Viewmodel contains all the business logic for which Unit tests can be written.
- Reusability: Viewmodel provides us reusability. Viewmodel contains all logical and non-android related code. Single view model can be used with multiple views. A view holds a reference to the ViewModel but not vice-versa.
- Reducing boilerplate: Using android data-binding and architectural components like ViewModel and LiveData makes implementation clean and error free.
How our code looked before the migration
- Activity/Fragment — activity/fragment calls operation that is responsible for getting data from the server and return it to activity. Activity/Fragment implements interface IOperation which provides 2 methods: onSuccess and onError. Activity/Fragment is responsible for all the logic of rendering of view, validation on user input data, calculations, and pretty much everything that view and controller can do.
- Operation — Responsible for making API call and transforming JSON data to model as needed by activity/fragment. It keeps a weak reference of the activity/fragment.
- An extra layer is added between activity/fragment and operation, which is responsible for saving the data into db before returning the response. When getData() is called, DB returns with cached data immediately and issues a background operation to fetch recent data. After this, when an operation returns data it is saved in db and again the response is given to the caller with fresh data.
- In this case, operation holds the weak reference of db layer and activity calls getData() on singleton instance of the db layer. The caller gets two callbacks one with cached data and other with fresh data
- Additional functionality for skipping the db layer getting only fresh data or getting only cached data is also present in the db layer.
- Multiple activities can subscribe to the same data repository.
The code was maintained and written following some good practices, making it uniform as well as readable. Still, activity and fragment were doing almost all of the heavy lifting. Reusability was available in form of operation for API calls and by making some helper and util classes. There were a few issues:
- Large activity and fragments
- Android and Java code tightly coupled to each other
- Not testable
- Reusability of views was not easy. A lot of code duplication.
What we wanted from our code
- Clean code with separation of concern.
- Decouple android and java code so that business logic could be contained in a single place that is independent of the Android framework and can be easily understood by anyone with knowledge of language rather than the framework.
- Creating small and dumb activity/fragment.
- Reusability of code.
- Testable Code with good code coverage.
How we started refactoring
- We created a ViewModel, which is just a java class in which all the business logic was contained. Activity passes all the action to ViewModel. ViewModel takes the decision of what has to be done.
- For a UI update, ViewModel informs activity/fragment via a callback.
- If some data is needed, ViewModel tries to get it from the data repository. The data repository is passed to ViewModel in the constructor. It is the job of the data repository to get data from db if present or get fresh data from API, store it in db and then return it to ViewModel.
- The operation is the same as before, makes an API call returns the expected model.
- When ViewModel gets data, it checks for all the validation or condition and takes the decision. It then gives simple callbacks to activity like loadSomeView(), destroySomeView(), loadErrorView().
- The data repository is an interface which has a different implementation in testing to read data from fix json.
- Activity is dumb, it only has methods that are straight commands for the Android framework. Thus, making ViewModel testable without any android component and also this ViewModel can be reused with other views.
Issues in this approach
- A large number of interfaces
- A lot of boilerplate code in the activity
- The issues with the lifecycle of ViewModel.
- Most of the time in android, resources like string, colours are used based on some conditions. These conditions are part of business logic and should be inside ViewModel but to fetch resources we need context which is not present in ViewModel.
- To reduce the number of interfaces we moved to observers.
- For boilerplate code, we started using data binding.
- To handle lifecycle issues we started using android architecture component ViewModel with live data.
- A resource provider interface was created which used application context for getting resources. Through this, we stop ViewModel from using the application context. Moreover, during testing, a resource provider is given a stub implementation.
- Activity is responsible for creating a view model, binding it to the view and navigation.
- Using android data binding, a lot of boilerplate code is removed. Since live data is used we get two -way binding, so UI is always updated when data is changed.
- ViewModel is an android architecture component so you don’t need to worry about its lifecycle. You don’t need to handle creating it or destroying it. It will also handle activity re-creation like in case of screen rotation.
- Flow becomes simple. Activity creates viewmodel, binds it’s UI. An Event is directly listened to by ViewModel due to data binding. Viewmodel takes the decision, live data gets updated which also updates the UI.
- Flow or communication between activity and ViewModel is still done using an interface.
- In case, where data is fetched from an API endpoint, a data repository is passed to ViewModel by activity. ViewModel asks for data from the repository which returns data in form of live data.
Testing while refactoring
Refactoring without test cases can be troublesome. Every refactoring must go through rigorous testing. Manual testing is a time-consuming process and error-prone. It also eats up into development time. Due to lack of any previous Unit Tests, we started with UI Tests. They can be written for a codebase following any architecture or any language. Any re-engineering or refactoring still must pass all UI tests.
So, before starting migration we wrote UI tests for the feature we were going to refactor so that its testing is fast and easy. This helped not only in fast releases but also removes the dependency on manual QA to some extent.
Future changes pending
- Use of SingleLiveEvent, this will reduce some of the interfaces from VM to activity.
- Inside VM, we can use an observable pattern where one variable may be dependent on other.
- Divide Binding component into different classes, saving it from becoming a monster class.
- Better use of dependency injection by exploring options such as Dagger2 or koin.