Android UI architecture migration to MVVM
In the engineering team at Dashlane we do our best to keep our code base clean, up-to-date with the latest technological trends (when we believe it's worth the investment) and to keep our technical debt under control.
As a member of the Android team, I'm proud of the state of our Android app codebase. We adopted Kotlin around 2017, started using Kotlin's coroutines in early 2018 before its stable release, and today we still keep on updating our technology stack and the tools we use.
Today, amongst other projects, the Android team has started to migrate our App's codebase to a new UI architecture standard to both improve the state of the codebase and also pave the way for some future migration (Hello Jetpack Compose 👋).
Wait! What do you mean with "UI architecture "?
We're talking about the architecture pattern followed by any screen or feature in the application. The code responsible for things like:
- building the screen's UI
- fetching local or remote data to populate the UI with
- even handling configuration changes, like when the user rotates their device from portrait mode to landscape.
There's a lot of responsibilities to handle in a single Android screen! And for this reason it's important to have a well-defined architecture that we can use as a template to build any feature in the app.
Bonus points if it help us get things done with as little development pain as possible.
In software development many patterns exist: MVC, MVP, MVVM (See this article if you want more info about these patterns) and many other variations, all with their pros and cons.
Traditionally in the Dashlane Android app we used to build all features with the MVP pattern.
This architecture revolves around 3 entities that each have their own responsibilities and which interact with each others:
- The Model holds the data.
- The Presenter dialogs with the Model to load or update the data stored in it. It also provide the UI data to the View to refresh the display.
- The View displays the UI data provided by the Presenter, and signals UI events to the Presenter.
While this pattern fits most of our needs and was a perfectly fine solution in our early stage of development, our Android team started to feel some pain points which grew heavier and heavier as we kept adding features. We started to look for an alternative that would relieve most of these pain points.
Luckily for us, Android introduced a new system component a few years ago: ViewModel.
The MVVM pattern does not stray too far from the MVP we know.
The View and Model components are pretty much the same as before, but the ViewModel (VM for short) is slightly different than a Presenter:
- The VM has no dependency on the View and never interact with it directly. It's the View which depends on the VM.
- The VM exposes some states that can be observed by one, or several View(s). Whenever the state changes, any View that observes the UI state is notified of the change and can react to this event.
- The View signals UI events to the VM which then starts some heavy duty computing. Once the work is completed, the new state is automatically propagated to the View(s) which in turn update their UI accordingly, all thanks to the observable patterns which makes the whole flow more reactive.
Even though the flow and architecture layers are different between MVP and MVVM, the result is the same: we build a screen that the user can interact with and we start some work depending on their action, right?
The end result has not changed dramatically for the user. However this new architecture offers many advantages from both a developer and a user perspective:
|Problems to solve||MVP||MVVM|
|State Restoration||State restoration in Android is a serious topic. Whenever the user leaves the app to get back to it later on or whenever they rotate the device from portrait to landscape mode, the screen goes through a whole process of destruction/recreation of its components and UI. In this situation it's important to be able to restore the state of the screen as the user left it. We wouldn't want to have them re-type that long and complex credit card number, would we? (Although, Dashlane's Autofill services would happily help with this)|
As our MVP is tied to the screen's own lifecycle, it follows the same recreation flow (destruction, recreation) and requires implementing a lot of manual saving/restoration code to be able to keep and restore the screen's state. This is both a pain and a potential source of bugs.
|The ViewModel is not tied to the screen lifecycle, which allows the data to survive the configuration changes mentioned previously.|
In some more complex cases, like the systems closing the app to free some resources, we still have to save the data to persist, but the tooling is much more convenient!
|Dependency Injection||With MVP the Presenter depends on both the DataProvider, to fetch any business data, and on the View, to feed it with the necessary information to let it build its UI.|
But most of the time the View also depends on the Presenter in order to transmit user events (like refreshing a list with a pull-to refresh action) that can trigger some background work.
And there! We just created a circular dependency between the View and the Presenter.
To avoid this double dependency, the Presenter could only provide the View with the necessary callbacks it needs, but handling multiple callbacks can also be a pain and lead to obscure spaghetti code, both confusing and hard to maintain.
|As the ViewModel is a system component, it's supported out-of-the-box with the official DI framework on Android, Hilt, which automates most of the initialization steps and keep the dependency graph clean.|
|Separation of concern||"Who is responsible for sending logs?", "Should the Presenter sanitize the data fetched from the Model before passing it to the View?", "Should the View handle all UI logic or delegate some decisions to the Presenter?". |
These are some of the many questions we have to ask ourselves whenever implementing a new screen and the answers often vary based on the complexity of the feature or even according to the developer's personal preferences. More often than not, the Presenter is the one which takes care of most of the business logic, which can lead it to become a kind of God Object with too many responsibilities.
|The ViewModel holds the UI state, exposes it to the View which updates its UI accordingly. |
As simple as that, and way easier to maintain.
|Boilerplate setup code||There's a lot of setup code to build the MVP: create an instance of each component (Model, View and the Presenter), set up their internal dependencies, connect them together... repeat this process for every screen in the app (approximately 100 screens) and you get a lot of setup code with almost the same logic but which cannot be easily reused.||Because of the clean dependency structure the initialization step is made simpler compared to MVP: the View depends on the ViewModel, which in turns depends on the Model. We got rid of the circular dependency we used to have with MVP and the heavy boilerplate setup code it required.|
|It's a heavy structure||MVP is sometime too heavy for simple screens with no dynamic UI. Take the example of a simple introduction screen with only a title, some explanatory text and a button: in this scenario there is usually no need for a Model (to load or save data) so the MVP contract has barely any use.||No advantages here. MVVM is also too big of a contract for a simple screen informational screen.|
|The official architecture recommended on Android||MVVM is the official architecture recommended by Google who provide rich tooling for it. It is widely used in the Android world and so benefits from a huge community support.|
|Compose with the future||Another advantage of adopting the MVVM pattern is that we're paving the road for a future migration to Jetpack Compose, the new standard for building UI on Android. Because of Compose's reactive approach to data change, it will greatly benefit from the observable Data exposed by the ViewModel.|
Our MVP model has served us well for many years and it has earned a well deserved retirement. However MVVM still has a lot of catchup to do and the transition from MVP to MVVM won't happen overnight, so we needed to define a plan of action to lead this move.
The road to MVVM
Explore the road to a better architecture
Migrating to a new pattern is not an easy decision, especially when this means replacing the architecture template used by the whole team to build any new features in the application.
In this effort the Android team relied on Tech projects: a team initiative where a few developers would regularly meet to discuss and work on topics that don't always fall within the scope of our regular teams. Examples include testing strategies, in-app navigation system, dependency injection, Jetpack Compose etc.
It was in one of these tech project groups that the UI architecture effort started. We were not happy with our current MVP architecture and wanted to discuss together to understand where the pain was coming from and how we could fix this.
Over several meetings, we listed all pain points that the team would meet, explore solutions to improve the existing MVP (for instance using our DI framework to build the MVP and reduce boilerplate initialization code), define a guideline about the ownership of specific components, as well as exploring alternatives to MVP.
In the end, MVVM appeared as an obvious choice: it is now the recommended way to build Android applications, it solves many issues we faced with our MVP architecture and benefits from a huge support in the Android/Kotlin community which provides many built in tools to ease use and transition on existing projects (Android ViewModel, Flow + ViewModelScope for async work, Hilt for DI..).
Migrating the project
Once we had carefully evaluated the possibilities and chosen our path, it was time to get some work done!
However the Android project is a roughly 8 year old codebase with something close to a hundred existing screens built in MVP.
Also, part of the team was not familiar with MVVM so we had to dedicate some time to learn, hence it is clearly not a migration that can happen overnight.
To that end we split the migration in several steps:
Test the migration, gain experience: the first step was to test the migration to MVVM on a few existing screens. The goal was to identify all the tooling required to fit our need and explore the most common bumps and pitfalls we could face. Basically, train ourselves on this new pattern and collect lessons to establish the conventions for the next step of the effort.
Formalize and share the knowledge : from this experience, we wrote a migration guide that formalizes the steps to migrate from an existing screen to MVVM that any developers could follow. Thanks to this documentation, anyone in the team can jump in the migration effort and rely on the step-by-step guide to migrate the screen of their choice in order to avoid getting stuck in the process.
MVVM as the new default: All new screens that we have to implement should and will be made following the MVVM pattern that we formalized.
- Migrate, migrate, migrate..!: We defined the list of existing screens with an MVP contract, now we just have to migrate them. In the first round of migration anyone in the Android team has been assigned a screen to move to MVVM, usually one they were familiar with, so they can gain experience in a familiar environment. Today everyone has successfully migrated at least one screen to MVVM, and have thus the experience to carry on the migration effort on their own within their respective teams. Any occasion to work on a screen that is still on a MVP contract is a good excuse to dedicate some extra-time to migrate it. And when the occasions are scarce we start another round of migration where everyone pick a screen to migrate, just to keep the momentum going.
As of today the team has migrated around 25% of the screens to MVVM and the feedback has really been positive so far:
- From a developer perspective the code quality has greatly improved around the features that have been migrated. Each migration is usually a good occasion to review the logic and clean up our layers. Callbacks were deleted, dependencies were injected, suspend functions were correctly dispatched, and error handling were completed.
- From a user's point of view, the user experience improved quite a bit too! Features like the in-app "Search" gained in reactivity with the observable pattern that comes with MVVM.
The migration effort will continue to happen up until all screens have been migrated, one screen at a time.
We do believe this migration is worth the investment, both to improve the user experience today by migrating existing feature to a more modern and dynamic architecture, and to be able to keep adding new and useful features for our users in a clean and modern codebase.
Care to check for yourself how cool our Android app is now? Install Dashlane from the Play Store today!
Thanks! You're subscribed. Be on the lookout for updates straight to your inbox.