Those issues were causing bugs, sometimes crashes, and were slowing down a lot of the delivery.

Heard in sprint preparation: “From which screen do we have to open this new one? If it’s from screenA it’s gonna be easy, if it’s from screenB it’s gonna take 1 day.”

The navigation in an Android app is not supposed to be hard. We wanted something where the only question we have to ask ourselves is “Where do I need to go?” and not “Where am I?”.

This is why we decided to build a home-made navigation system.

For the sake of simplicity, the term “screen” will refer to “an activity, a fragment or a BottomSheet” because the goal is to navigate within the app, regardless of the implementation details.

The architecture of our new system

In the paragraph above, we saw that the first two problems were linked to Navigation Component specificities: the way we declare destinations in XML, and some features missing.

The third problem is only a dependency issue between modules, so we took this opportunity to fix it as well.

That’s why we basically kept the same concept as the old system, but without Navigation Component and relying only on the low-level methods.

1. A Navigator which contains all the platform-specific methods like startActivity() and fragment transactions. This class is in charge of the actual navigation.

2. A Router that contains all the logic.

Ex:

- Should I start the activity from the current activity context or the fragment context

- keep a track of all screens* in the back stack to dispatch event if needed

- prepare the data if I need to open an external app like camera or emails, etc…

3. A RouteParser in charge of converting a route (as a String) and an optional map of <String,Any> into the Activity, Fragment or BottomSheet that we should start, checking that all required extras are present, and defining transitions for each route.

Pre-requisites

Before changing everything in our app, we did a PoC in a small sample app.

The list of mandatory features was:

Start an activity from another activity, a fragment or a push notification (with or without extras), and possibly with a specific fragment already added in it

Open a new activity from a fragment, with a specific transition

startActivityForResult() from an activity or a fragment, and get the result where it has been called

from an activity or a fragment, and get the result where it has been called Replace the fragment in the current activity

Switch between fragments within the same activity

Handle transition animation between activities and fragments

Handle launch mode (FLAG_ACTIVITY_SINGLE_TOP, FLAG_ACTIVITY_CLEAR_TOP, FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK) seamlessly

Handle data persistency between fragments

Finish activity (with or without result)

Be compatible with UI elements like ViewPager , TabBar , BottomBar

, , Open a BottomSheet, and navigate between fragments inside a BottomSheet

Open a bottomsheet

Navigate between fragments inside a bottomsheet

Deeplinking is a bonus feature, not implemented yet

We wanted our new navigation system to be as testable as possible. Everything is fully tested except for the implementation of the Navigator because it is where we call Android methods like startActivity() .

It should be totally independent of our app. The goal was to be able to export it as an external library. No other third-party is required. In the Aircall app, we inject the router with Dagger, but it’s not mandatory.

Only the implementation of the RouteParser is defined within the app because it defines which screen* is associated with each route.

How does it work?

Step 1

Start navigation from your class by calling

It can be any type of class, you just need to have IRouter as a dependency.

Parameters are just a simple string and a map of <String, Any>. There is also a non-mandatory parameter for the LaunchScreenFlags.

LaunchScreenFlags : At this point, we shouldn’t have any reference to an Android constant.

LaunchScreenFlags are not the usual FLAG_ACTIVITY_SINGLE_TOP, FLAG_ACTIVITY_CLEAR_TOP, FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK, but : NO_DUPLICATE_ON_TOP:

A-B (launch B) -> A-B

BACK_ON_LAST_INSTANCE_AND_CLEAR_BACKSTACK:

A-B-C (launch B) -> A-B

CLEAR_WHOLE_BACKSTACK:

A-B (launch C) -> C The goal was to abstract the complexity of the framework.

Step 2

The navigator will call the routeParser.parse(my_route, extras) to determine what this route is.

Step 3

The routeParser can return a ScreenRoute for a Fragment or an Activity, or a ModalRoute for a BottomSheet or a Fragment in a BottomSheet.

Both contain extras as a bundle and transitions. The routeParser is also in charge of the validation of the extras, otherwise, it throws an UnknownDestinationException.

Step 4

The router always has an instance of the current activity, which is automatically binded when it’s created and unbinded when it’s destroyed.

It also has the ID of the fragmentHost if there is one.

What we call fragmentHost is the fragment in the XML layout.

Please read How to use it section to know how it’s done.

A ScreenRoute contains an activityType : Class<out Activity> and a FragmentType: Class<out Fragment>?

If the activity in the ScreenRoute is the same as the current one, we don’t open it again.

If fragmentType is not null, we call the Navigator to create the fragment and replace it in fragmentHost.

If fragmentType is not null, we call the to create the fragment and replace it in fragmentHost. If it’s not the same, we call the Navigator to prepare the intent and start the activity.

If fragmentType is not null, we store the resulting fragment in destinationFragment to add it once the new activity is ready.

We also store this route in the local destinationStack and call all navigationChangedListener if there is any.