In the past three years, we've changed the Monzo Android app dramatically.
When we first launched it back in 2016, it only had a couple of screens that implemented a waiting list. People could sign up to join the list and be the first to get Monzo on Android. They just had to wait for us to build the app!
Today, we have more than 1 million Monzo users on Android, and we've added lots of new features that have made the app much larger. 16 Android engineers work on the codebase, which consists of about 270,000 lines of code. The app has more than 500 screens, each with distinct functionality.
During this time, we realised we couldn't scale some of the assumptions we made back in 2016 along with our ambitions. One of those assumptions was that we could build screens in isolation, without worrying about using the same screen more than once.
As it turned out, reusing screens was crucial to being able to rapidly adapt to design changes and implement more new features faster. For that purpose we introduced a new architectural pattern in our codebase, that lets us reuse existing screens in a way that:
is scalable
requires very little boilerplate
promotes loose coupling
is easily testable
The app is made up of screens and flows
The Android team uses these terms when communicating with our colleagues from other teams (design, product, iOS etc):
A screen is a UI element that takes up the whole device display, along with its associated logic. In an Android app, this could be implemented via an Activity, a Fragment, even a custom View.
A flow is a bunch of screens, chained together. This could be implemented on Android via multiple Activities, an Activity with multiple Fragments, a Fragment with multiple Views etc.
For example, here's the flow for creating a new Pot in the Monzo app, along with the name of each screen in that flow:
Create Pot flow
The Monzo app has a lot of flows and a lot of screens. Although each individual screen doesn't necessarily have tonnes of complicated logic in itself, if you combine that with all the flow-specific logic, there is a lot of complexity to address.
The new architectural pattern we've used mostly suits apps like this. If your app is pretty small, or only has a few screens but very 'deep' functionality, then the overhead of this pattern might not be worth it. A simpler architecture would probably be better suited for your case.
In the early days, we used the Model-View-Presenter pattern (MVP)
For the first two years of the Android app (until about summer 2018), our approach to building screens was simple and effective.
We'd use an adapted version of the MVP (Model-View-Presenter) pattern, as is explained in this article. For each new screen, we'd write a new Activity and a Presenter. The Activity had minimal logic, and the Presenter did most of the heavy lifting. When we needed to go from one screen to the next, we'd simply call startActivity(NextActivity.buildIntent(this))
.
In the example of creating a new Pot, here's a diagram of the classes required to implement this flow:
There were some clear benefits to this approach:
The Presenter didn't have any Android code, so it was easy to unit test.
We established strict conventions about how we did MVP - like when the Presenter is registered/unregistered, how we'd use RxJava to connect to streams of user events, how we'd write unit tests, and more. So it was straightforward to spin up a new screen whenever we needed it.
Because we were using Activities, we could mostly avoid the complicated world of Fragment transitions and lifecycles.
The app was pretty small. But because each screen was separate, we'd rarely step on each other's toes and end up with merge conflicts.
Designers wanted to 'reuse' screens, but we had to build them from scratch every time
As we continued improving Monzo and introducing new features, we noticed something happening more and more frequently. When designing new features, our design team would take existing screens from part of the app, and drop them into the new flow. They assumed that since it was 'the same screen', it would take very little time for engineers to implement it.
For example, one of the first things we built was the signup flow. We built a lot of screens for that flow, assuming we'd only use them there. Part of the signup flow asks you to enter your profile information: your name, date of birth, and address. Here's what this 'sub-flow' looks like:
Flow 1: Setting up your profile during signup
A few months later though, we needed to build a new flow in the app, to let you change your address from the 'Profile' screen. Here's what that looks like:
Flow 2: Updating your address (accessible from the 'Profile' screen)
As you've probably noticed, the 'Address' screens in both flows are the same. The only difference is the colours of the UI elements.
But our MVP approach meant there was no clear way to reuse the existing screen. We might be tempted to reuse the 'Address' Activity from our signup flow and apply a 'light' theme. But because the Activity has logic in its Presenter that's specific to the signup flow, we can't swap it out for the new logic that's specific to the update address flow. This meant we'd have to use a new Activity for every single screen.
In this example, the first flow would consist of the SignupUserDetailsActivity
, and the SignupAddressActivity
. And the second flow would consist of the ProfileUpdateAddressActivity
, the UpdateAddressPinEntryActivity
and the UpdateAddressConfirmationActivity
.
For a while, this didn't create any real issues. Since the app was small, there just weren't that many screens to reuse. But as the app grew, we'd find ourselves reusing more and more existing screens in new places, and duplicating lots of code in the process.
This had some clear drawbacks:
If we needed to change the design of a screen the design team considered 'reusable', we had to do it in all the separate instances of that screen.
Producing new flows on Android was far too slow. We had to do everything from scratch, every single time. The iOS team were reusing screens, and would complete new flows much faster!
We tried two ways to fix this
We tried a couple of different approaches to let us reuse screens while keeping our existing architecture.
Solution 1: Entry point arguments
This approach is quite straightforward. We can pass an entry point enum to our Activity (via Intent extras), which signifies where we came from. We could then use this enum to implement different logic in each screen, depending on the flow it's in. This includes both presentation logic (i.e. load data from a different place), and navigation logic (i.e. move to a different screen when you're done).
But there are some drawbacks to this approach:
Scalability. How many different pieces of logic can fit in one screen? For example, we only use the address screen in a couple of places, but we use the screen asking you to enter your PIN dozens of times.
Encapsulation. The reusable screen would need to be aware of logic from all the different flows it appears in. This is a nightmare if you're trying to modularise your app. Lots of places need to know about this screen because it's reusable. But the screen also needs to have lots of dependencies to satisfy each piece of logic. The more we reuse this screen, the more tightly pieces of code are coupled.
Solution 2: Inheritance
Another approach we tried out was having a parent Activity (and Presenter) for each reusable screen, and a different subclass each time it appears in a different flow. Shared logic would live in the super class, and flow-specific logic would live in the subclass.
This solution's a bit better, but has its own drawbacks:
It needs a lot of boilerplate. Every time we wanted to use a 'reusable' screen, we have to subclass both the Activity and the Presenter, and implement the missing functionality.
It's generally hard to navigate through code when inheritance hierarchies are involved, and understand if something happens in the parent or the subclass.
It's also harder to unit test. Do we test all the parent class functionality for every subclass?
After lots of research, we discovered the Coordinator Pattern
To try and find other ways to approach the issue, we did lots of research. But in the end, we realised the best solution was right in front of us!
The iOS team had been using a pattern called the 'Coordinator Pattern' with great success. Here's the article the iOS team based their implementation on. It sounded like this approach could solve our reusability issue, without any of the drawbacks of the other solutions.
The basic idea of the pattern is that you have a class that lives on a level above each individual screen, and 'coordinates' the navigation between these screens within a particular flow (i.e. you have on 'Coordinator' for each flow). The Coordinator also hosts all flow-specific logic, making individual screens agnostic to the flow they appear in.
This pattern is fairly straightforward to implement in an iOS codebase. But Android is fundamentally different. How navigation works and the fact that we don't 'own' the creation of system components like Activities and Fragments would make this harder to implement on Android.
We adapted the pattern for Android
After we agreed on the general Coordinator approach, we did lots and lots of reading, deliberation and experimentation to find the best way to adapt this pattern to the peculiarities of Android. Here's our solution, and how the core components of the pattern fit together:
The
CoordinatorHost
creates and has a reference to theCoordinator
The
CoordinatorHost
creates theFlowNavigator
The
Coordinator
has a reference to theFlowNavigator
The
FlowNavigator
creates theFragments
Each
Fragment
has a reference to aViewModel
The
Coordinator
creates theViewModels
The
Coordinator
has a reference to theFeatureNavigator
And here's how it looks in practice
The pattern isn't easy to understand in isolation. So it's worth looking at some concrete implementations for the flows we've been using as examples:
Signup profile creation flow
Update address flow
The AddressFragment
and the AddressViewModel
are now exactly the same, and shared between two different flows. In a similar way, we could take any of the Fragments and ViewModels and drop them into another flow.
Each component in the flow does something different
The flows contain a lot of components, and each one of them has unique responsibilities.
The CoordinatorHost
A CoordinatorHost
is a class that contains a Coordinator
. It's a simple interface that looks like this:
The CoordinatorHost is implemented by an Android system class, like an Activity or a fragment (or even a view). In the examples above, the SignupProfileCreationActivity
and the UpdateAddressActivity
both implement this interface.
A CoordinatorHost
is in charge of:
Constructing the
Coordinator
(or, if you use Dagger or some other DI framework, theCoordinator
can be injected to theCoordinatorHost
).Creating an implementation of the
FlowNavigator
interface, and providing it to the constructor of theCoordinator
. This is a class that helps theCoordinator
navigate between different screens in the same flow (read the next section to learn more about this class).It also provides any input to the flow into the
Coordinator
. The input is given as intent extras (for Activities) or in the arguments bundle (for Fragments). Then, the input is provided to theCoordinator
as constructor parameters.Finally, it calls a couple of lifecycle method on the
Coordinator
(see the next section to see what these are).
Overall, this Activity (or Fragment) doesn't have a layout. It just 'glues' together the other components of the pattern.
The Coordinator
The Coordinator
is also an interface:
The Screen class
Each Coordinator
takes a generic type S
. This is a sealed class that represents each screen in our flow. For example, for the flow to update the address, this class would look like this:
This way, the Coordinator
is only aware of Screens
and not Fragments, Views or anything else Android-specific. It's the FlowNavigator
's job to translate actions on Screen
objects into actual Android Fragment (or View) transitions.
The Coordinator has some clear responsibilities
The Coordinator
is responsible for:
Kick-starting the flow by displaying the first screen when the
start()
method is called. This would look something like this:
Storing some states as the user progresses through the flow. It also provides functionality to store that state in the event the Activity (or Fragment) is recreated. For example, if you have a 'form' type flow, where the user makes a choice on each screen, and then we need to send all the choices in a network request, there's no need to pass this information through each and every screen. The
Coordinator
can accumulate the choices as we progress through them. If the Activity (and the Coordinator it hosts) is recreated, we can restore this.Providing ViewModels to the screens that appear within the flow. That means it can decide how to construct them, and pass flow-specific information to them. This is how we can have different logic in a screen that we reuse in two different flows.
We do this all in the
onCreateViewModel()
method. It looks something like this:
These ViewModels can also send events back to the
Coordinator
that created them. In that case, the we call theonEvent()
method. There, we can write code that responds to these events, almost always with a navigation function (either within the same flow, or moving out of it). TheCoordinator
's main job is to 'coordinate' all the different screens. Here's an example:
More importantly, there are things that the Coordinator
doesn't do. Having clear responsibilities is one of the main benefits of this pattern:
It doesn't do any networking or storage requests.
If we need to do any of these, we need a screen for that (if only to display a loading indicator), with a related
ViewModel
.It doesn't touch any Android-specific classes. The
Coordinator
code should work no matter which underlying implementation we use to navigate between screens (or between different Coordinators).
The ViewModel
When we moved to the Coordinator pattern, we also decided to move our screens to use the MVVM (Model-View-ViewModel) pattern, like Google recommends. There are a few reasons why we did this, including:
It's what most other Android developers are familiar with.
This makes it easier for new hires to understand the code base.
The equivalent iOS team also uses ViewModels.
This makes conversations about similar things easier across platforms.
After using MVP for some time, we realised the View interface that the Presenter was using was becoming larger and larger.
Mocking it for unit tests was becoming a real chore.
Our ViewModels are extending the ViewModel
class from the Android architecture components, and we write them in a way that's heavily influenced by MvRx.
We've also added some code to our BaseViewModel
class (that all our ViewModels extend), to communicate with the Coordinator
.
The way this looks in practice is something like this:
The Fragment
The Fragment (just like the ViewModel
) has no idea what flow it exists in. The only thing Fragments need to do to participate in this pattern is request their ViewModel
in a specific way.
We've build a Kotlin property delegate, which connects all of the above:
It will traverse the parents of the Fragment to find the nearest
CoordinatorHost
(an Activity or Fragment).It will then call the
Coordinator.onCreateViewModel()
method on the relevantCoordinator
.Finally, it makes the
Coordinator
listen to coordinator events from theViewModel
. This is implemented by having aLiveData
in theBaseViewModel
which emits, and theCoordinator
simplyobserves
these.
All this complexity is hidden though. Getting the actual ViewModel is as simple as this:
Some components help with navigation
A couple of components help with navigation within the flow and between flows.
The FlowNavigator
The Coordinator
can't add/replace/hide Fragments by itself. As we've just seen, it's not even aware of the concept of Fragments. It only sends commands to the FlowNavigator
like navigateTo(screen)
or replace(screen)
. The FlowNavigator
takes these commands and 'translates' them into actual Fragment transitions. There are multiple reasons for having this abstraction layer:
The Fragment navigation framework is complicated. Introducing this abstraction makes it a bit more inflexible, but simplifies the API a lot. After using this for a while, we've found we don't need anything more complicated than that.
We use Fragments at the moment, but we might want to get rid of them eventually and just use Views for representing screens in a flow. With this abstraction, it should be fairly straightforward. We can keep our Coordinators exactly as they are, and then make sure we provide a
FlowCoordinator
that uses Views instead of Fragments.It also reduces boilerplate from the
Coordinator
and makes the code easier to read. It's a lot easier to understandflowNavigator.navigateTo(PinEntry)
rather thanfragmentManager.beginTransaction()
.replace(android.R.id.content, PinEntryFragment())
.addToBackstack(null)
.commit()
It makes the
Coordinator
code more testable. Instead of having to deal with Android classes, we can simply test that the right commands when they're given to theFlowNavigator
(we've even build a Fake recording version of theFlowNavigator
that makes this easier).
The interface and the implementation for our FlowNavigator
is influenced heavily by the SupportAppNavigator
from Cicerone.
The FeatureNavigator
This is all fine and well when we move between screens within the same flow. But at some point, the flow is finished, and we need to either return back to where we started, or (more importantly) go to a different flow.
This is where the FeatureNavigator
comes in. It's an interface that can begin different flows. This means launching an Intent
to the relevant Activity (which is also a CoordinatorHost
and a flow in itself).
Feature navigation in a multi-module project
Modularised projects (like ours) have completely different challenges than non-modularised ones when it comes to this type of navigation. That's because more often than not, the different flow will live in a different module. Our current flow doesn't have access to the Activity which lives in the next module, so constructing the correct Intent
isn't a straightforward task.
We've used a simplistic solution. Our FeatureNavigator
interface lives at the bottom of our module hierarchy. It has a big list of methods, each launching a different Activity. The implementation of this interface lives at the top of our module hierarchy (our application
module), so it's aware of all the activities in the app. We use Dagger magic to bind that implementation to the interface. Then we can inject this interface to whichever class needs it.
We know this isn't ideal, and are currently exploring other ways of doing feature navigation. But we see this mostly as a problem with modularisation, not with the Coordinator pattern. We even have this problem in screens that don't use this pattern at all.
We still have some unanswered questions
We've come a long way using this new pattern, and we're happy with the results! But we're still addressing a few unanswered questions.
When is something a separate flow?
We can only answer this case-by-case. We've found it's often easy to define boundaries between flows. But in some cases, it's not obvious if a screen belongs to the beginning of a flow, the end of another, or should be considered a new flow. Discussing this between the Android, iOS and Design teams helps make these decisions.
Do nested Coordinators make sense? How would we implement these?
We've found that there are small collections of screens that are always used together in different flows. This means we've had to copy and paste the code that coordinates them in separate Coordinators.
The question is, should we try and extract these into separate flows that a 'parent' Coordinator coordinates? If we extend this further, can we join more and more of our flows into higher-level flows, until the whole app is coordinated by a single Coordinator?
What would we need to do to turn this into a stand-alone library?
There are also a few parts of the pattern that currently only work within the Monzo app codebase:
We make some assumptions with regards to dependency injection. Some of our implementation depends on our unique Dagger setup. We need to find a way to make this work without leveraging Dagger at all.
Although the concept should work with View backstacks, we haven't tested this. We only have a
FragmentFlowNavigator
implementation, which uses Fragment transactions in order to implement screen transitions.
The future of Coordinators
As we keep using this pattern, we're certain it'll keep evolving. And we'll try to update you if we make any major changes you might find interesting!
In the meantime, let us know if you have suggestions or thoughts about the new pattern or any of our unanswered questions. We'd love to hear your opinion, and keep the discussion going!