Coordinators on Android: how to build flows quickly with reusable screens

Read the article

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

Screens showing the flow to create a Pot in the Monzo Android appScreens showing the flow to create a Pot in the Monzo Android app

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:

Chart pot creation 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

Screens showing the flow to create your Profile when you sign up for Monzo

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)

Screens showing how to update your address

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:

Chart coordinator generic (2)
  • The CoordinatorHost creates and has a reference to the Coordinator

  • The CoordinatorHost creates the FlowNavigator

  • The Coordinator has a reference to the FlowNavigator

  • The FlowNavigator creates the Fragments

  • Each Fragment has a reference to a ViewModel

  • The Coordinator creates the ViewModels

  • The Coordinator has a reference to the FeatureNavigator

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

Signup Profile Creation Flow

Update address 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:

/** A component that owns and manages a [Coordinator]. */
interface CoordinatorHost {
    val coordinator: Coordinator<*>
}

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, the

    Coordinator can be injected to the CoordinatorHost).

  • Creating an implementation of the FlowNavigator interface, and providing it to the constructor of the Coordinator. This is a class that helps the Coordinator 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 the Coordinator 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:

interface Coordinator<S : Parcelable> {
    /**
     * Called by the host when the flow starts.
     * Use this to display the first screen, log analytics, set up state, etc.
     */
    fun onStart() {}

    /**
     * Called when the host of this coordinator is killed by the system. Override to save any internal
     * state that can be restored later using [onRestoreInstanceState].
     */
    fun onSaveInstanceState(): Parcelable? = null

    /**
     * Called after the host of this coordinator has been re-created after being destroyed. Override to restore
     * any state that was previously saved in [onSaveInstanceState].
     */
    fun onRestoreInstanceState(savedState: Parcelable) {}

    /**
     * Create a [BaseViewModel] instance for the given [screen].
     * BaseViewModel is the class that all our ViewModels extend from.
     */
    fun onCreateViewModel(screen: S): BaseViewModel

    /**
     * Handle events sent from [BaseViewModel] instances.
     */
    fun onEvent(event: Any, screen: S)
}

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:

sealed class UpdateAddressScreen {
    object EnterAddress : UpdateAddressScreen()
    data class PinEntry(val address: Address) : UpdateAddressScreen()
    object Confirmation : UpdateAddressScreen()
}

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:

override fun onStart() {
    flowNavigator.newRootScreen(UpdateAddressScreen.EnterAddress)
}
  • 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:

 override fun onCreateViewModel(screen: UpdateAddressScreen): BaseViewModel {
     return when (screen) {
         is UpdateAddressScreen.EnterAddress -> {
				     // These dependencies can be simple data, or even different
						 // implementations of UseCases for loading/sending data over the network
						 EnterAddressViewModel(flowSpecificDependency1, flowSpecificDependency2)
				 }
				 //... view models for other screens
     }
 }
  • These ViewModels can also send events back to the Coordinator that created them. In that case, the we call the onEvent() 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). The Coordinator's main job is to 'coordinate' all the different screens. Here's an example:

override fun onEvent(event: Any, screen: UpdateAddressScreen) {
    when (event) {
         is EnterAddressCoordinatorEvent.AddressConfirmed -> {
             // These events can also change the Coordinator state. 
						 // In theis case, we store the address to be used later.
             state = state.copy(address = event.address)
             // Goes to the next screen
             flowNavigator.navigateTo(UpdateAddressScreen.PinEntry)
         }
         // ... other events, either from this screen, or the other screens in this flow
    }
}

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:

class EnterAddressViewModel constructor(
		// dependencies go here 
) : BaseViewModel() {

		// ...

    // Called by the view when the user confirms the address they entered
    fun onAddressConfirmed() {
         sendCoordinatorEvent(EnterAddressCoordinatorEvent.AddressConfirmed(address))
    }
}

sealed class EnterAddressCoordinatorEvent {
    data class AddressConfirmed(val address: Address) : EnterAddressCoordinatorEvent()
		// ... other events that this viewmodel sends to the coordinator
}

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 relevant

    Coordinator.

  • Finally, it makes the Coordinator listen to coordinator events from the

    ViewModel. This is implemented by having a LiveData in the

    BaseViewModel which emits, and the Coordinator simply observes these.

All this complexity is hidden though. Getting the actual ViewModel is as simple as this:

class AddressFragment : Fragment() {

    private val viewModel: AddressViewModel by hostedViewModel()

		// ... rest of the fragment code

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 understand

    flowNavigator.navigateTo(PinEntry) rather than
    fragmentManager.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 the FlowNavigator (we've even build a Fake recording version of the FlowNavigator 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!