In this post I’m going to talk about navigation between screens in Android applications, architecture and Navigation Architecture Component.

On the first sight, the topic of navigation might look mundane, almost trivial. However, by the end of this post, you’ll see that this first impression couldn’t be farther from truth. Navigation between screens is the core architectural aspect in your app and the way you approach this task makes a huge difference in its long-term maintainability.

Starting Activities, replacing Fragments

There are two main ways to represent “screens” in Android apps: Activity-per-screen and Fragment-per-screen. There are other approaches as well, but they are much less popular. Therefore, I’ll concentrate just on these two in the context of our discussion here.

If you’d like to navigate to a screen represented by Activity, you’d do it like this:

Intent intent = new Intent(context, TargetActivity.class); context.startActivity(intent);

With Fragments, you need to write a bit more code:

SomeFragment fragment = new SomeFragment(); fragmentManager .beginTransaction() .replace(R.id.fragment_container, fragment) .commit();

But that’s not all. When you start a new Activity, the old one is automatically added to the backstack by default. That’s not the case with Fragments. To enable back-navigation with Fragments, you need to explicitly add transactions to the backstack:

TargetFragment fragment = new TargetFragment(); fragmentManager .beginTransaction() .addToBackStack(null) .replace(R.id.fragment_container, fragment) .commit();

All in all, not that difficult, right? Let’s move on.

Activity extras and Fragment arguments

In some cases, you’ll want to pass data to the destination screen. For example, imagine that you have a list of products, and when the user clicks on one of them, you want to show a new screen with that product’s description. To make this work, you’ll need to pass either the entire data structure representing the product, or, at least, product’s ID to the next screen.

To pass data into Activities, you use so-called “Intent extras”:

Intent intent = new Intent(context, TargetActivity.class); intent.putExtra(TargetActivity.INTENT_EXTRA_PRODUCT, product); context.startActivity(intent);

The destination TargetActivity will then extract this data from the intent:

Product product = (Product) getIntent().getExtras().getSerializable(INTENT_EXTRA_PRODUCT);

With Fragments, you’d use so-called “Fragment arguments”:

TargetFragment fragment = new TargetFragment(); Bundle args = new Bundle(); args.putSerializable(TargetFragment.ARG_PRODUCT, product); fragment.setArguments(args); fragmentManager .beginTransaction() .addToBackStack(null) .replace(R.id.fragment_container, fragment) .commit();

The destination TargetFragment will then extract the data from its arguments:

Product product = (Product) getArguments().getSerializable(ARG_PRODUCT);

Please note that I rely on automatic Java serialization (using Serializable marker interface) to put data into intents and arguments. That’s my preferred way to pass non-primitive data structures. It’s simple to use and maintain. However, many Android developers don’t like this approach and prefer to use Parcelables. If that’s your preference too, be my guest.

Coupling between screens

Let’s talk about the coupling between different screens in your application a bit.

Despite the fact that the term “coupling” is usually used in a very negative context, there is really nothing wrong with it. In fact, coupling is what you do when you interconnect multiple components to work together. However, there are different types and different degrees of coupling, and not all of them created equal.

In the very first examples in this article, I coupled the current screen to the next one either through a reference to Activity class, or a call to Fragment constructor. This kind of coupling is alright.

However, in the examples that demonstrated exchange of data, the coupling became much stronger. In addition to dependency on the high-level details of the target screen, I also made the current screen coupled to the constants defined inside that screen. In fact, the current screen is not just coupled to the constants, but it’s also “assuming” that the target screen will use these constants to retrieve the data from either intent or arguments. But even that isn’t the entire story. See, using this seemingly simple and innocent approach, I managed to couple the target screen to the current one as well. If I’ll forget to provide data that the target screen expects, or provide incorrect types of data, then I can break the target screen, even if it worked fine until this exact moment!

In relatively small applications, such a coupling might not become an issue. But in larger codebases which need to be maintained for years, it might lead to serious problems.

For example, in older codebases I saw screens which were navigated to from multiple other screens and used more than ten different constants for data exchange. These constants would be combined in different ways upon navigation from different screens. When you jump into such code and need to add additional route to that screen, you basically face a very complex puzzle because you need to figure out which constants are mandatory to use, which types of data should be used, whether there are mutually exclusive constants that shouldn’t be used together, etc. Since this contract isn’t enforced by the compiler, you discover your mistakes only when you run the application. And if the application has multiple flavors which use the data injected into that screen in different ways, then good luck to you.

However, since that’s the approach Android framework forces us to use if we want to pass data between screens, there is seemingly no alternative. We should just accept the inevitability of bad design, right? Not on my watch!

Static factory methods to the rescue

To address the issues described in the previous section, I usually use static factory methods.

If the navigation target is Activity, then I’ll add this method to its public API:

public static void start(Context context, Product product) { Intent intent = new Intent(context, TargetActivity.class); intent.putExtra(INTENT_EXTRA_PRODUCT, product); context.startActivity(intent); }

Then the clients that want to navigate to that Activity will simply do the following:

TargetActivity.start(context, product);

Note: static methods for starting new Activities don’t really qualify as factories because they don’t instantiate new objects. However, I still call them static factories for convenience.

If the target is Fragment, then, similarly, I’d do the following:

public static TargetFragment newInstance(Product product) { TargetFragment fragment = new TargetFragment(); Bundle args = new Bundle(); args.putSerializable(ARG_PRODUCT, product); fragment.setArguments(args); return fragment; }

And then call this method from clients:

TargetFragment fragment = TargetFragment.newInstance(product); fragmentManager .beginTransaction() .addToBackStack(null) .replace(R.id.fragment_container, fragment) .commit();

Nothing complex here, right? Just moving a bit of code from one place to another. However, this simple design trick brings enormous benefits:

The clients no longer depend on the constants from target screens. The number and the type of parameters is enforced by the compiler through method signature. All the details about mapping of constants to parameters are encapsulated within the target class. The constants are private. If you need to navigate to the same screen from multiple places, then, instead of code duplication, you just call the same method.

In more complex scenarios, when different clients need to pass different sets of data to target screens, I simply add multiple factory methods. I give these methods descriptive names to convey information about their intent to future readers of my code. This way, I explicitly define all valid combinations of data, document their usage and let the compiler enforce that.

It’s impossible to overstate the impact of this technique on your codebase. I find it especially beneficial in big and complex applications, but it’ll make your life easier in any project beyond the most trivial “hello world”. Since it’s that simple, you should probably always use it. As far as I can tell, there are no downsides at all.

Screen navigator abstraction

If you think about it, handling navigation within your app is a standalone responsibility. For example, in case of Fragments navigation, the clients shouldn’t care about the low level mechanics of FragmentTransaction and whatnot. They just need to state which screen should be shown next and provide the respective parameters (if required).

Therefore, according to Single Responsibility Principle, you should extract this functionality into a standalone component.

So, let’s create ScreenNavigator abstraction:

public class ScreenNavigator { private final Activity mActivity; private final FragmentManager mFragmentManager; public ScreenNavigator(Activity activity, FragmentManager fragmentManager) { mActivity = activity; mFragmentManager = fragmentManager; } public void toProductDetailsScreen(Product product) { TargetFragment fragment = TargetFragment.newInstance(product); replaceFragment(fragment); } private void replaceFragment(Fragment fragment) { mFragmentManager .beginTransaction() .addToBackStack(null) .replace(R.id.fragment_container, fragment) .commit(); } public void toProductDetailsScreen2(Product product) { TargetActivity.start(mActivity, product); } }

Now, whenever you need to navigate to another screen, you just call ScreenNavigator’s methods:

mScreenNavigator.toProductDetailsScreen(product);

In essence, all I did was just extracting the code from individual clients into this new class. I’m pretty sure you aren’t mind blown by that. But you should be. See, with this humble change I introduce crucially important architectural boundary into my application.

Note that the clients no longer depend on individual Activity and Fragment classes at all. In fact, the clients don’t even know whether a call to ScreenNavigator will result in a new Activity being shown, or a new Fragment (or any other component, really). These implementation details are now abstracted out and I can even change my approach in the future without affecting the existing code. In addition, elimination of dependencies on Android classes in my clients will allow me to unit test them, including their interaction with ScreenNavigator.

Also note that once you have ScreenNavigator in your codebase, it becomes much less important how you actually handle the navigation. Since all these details are encapsulated in a single class now, you can easily switch between manual approach and different external libraries. In essense, the choice of navigation mechanism ceases to be part of your architecture and becomes implementation detail of ScreenNavigator.

I have ScreenNavigator abstractions in all my apps and it’s among the first classes I add in clients’ codebases. So far, I implemented Activity-per-screen, Activity-per-flow, Fragment-per-screen and Fragment-per-screen-with-bottom-tabs approaches using ScreenNavigator and all of them worked like charm.

Back navigation

When you add ScreenNavigator to your application, also add navigateBack() method to its API and delegate onBackPressed() from all Activities to this method. There is a bit of nuance here, so let me just copy-paste code example from one of my projects:

public class ScreenNavigator { ... public boolean navigateBack() { if(mFragNavController.isRootFragment()) { return false; } else { mFragNavController.popFragment(); return true; } } }

This specific project uses Single-Activity approach and FragNav library to handle Fragments, so I can use FragNavController class instead of hacking with FragmentManager. Modify the implementation of navigateBack() according to your implementation details.

Then, inside your Activities, do the following (if you have many Activities, consider extracting this logic into a base class):

@Override public void onBackPressed() { if (!mScreensNavigator.navigateBack()) { super.onBackPressed(); } }

Just in case you’ve got some other action to do on back press, like closing the nav drawer if it’s open, or dismissing a dialog, you can modify this code like this:

@Override public void onBackPressed() { if (mViewMvc.isDrawerVisible()) { mViewMvc.closeDrawer(); } else if (!mScreensNavigator.navigateBack()) { super.onBackPressed(); } }

In addition to handling back button clicks by the user, you can now call navigateBack() in your code to get back to the previous screen. Magic!

Navigation Architecture Component

Now it’s time to address the elephant in the room: which parts of what I wrote in this post are still relevant given there is an official Navigation Architecture Component from Google?

If you think about it, conceptually, Navigation Component attempts to address the same issue that ScreenNavigator addressed: we’d like to have centralized management of navigation inside Android application and decouple the clients from the details of navigation. Sure, Navigation Component also has “navigation editor” (copy of “storyboards” from Xcode), but it has nothing to do with software design and architecture, so it isn’t that important in the context of our discussion.

Now, unlike ScreenNavigator, Navigation Component is horribly complex:

You need to use XMLs to define your navigation graphs.

If you want to pass any parameters between the origin and the destination in a type-safe and decoupled way, it looks like your only option is to use some Gradle plugin that generates code.

If you don’t like code generation, then back to coupling through Bundles and code duplication.

You can’t have navigation graph that spans multiple activities.

There are probably more complexities and limitations, but the above list is already pretty bad.

I guess many developers will think at this point: “This guy is complaining about XMLs. It’s not really that big of a deal!”. Well, not exactly. I don’t mind XMLs where they make sense, but in this case, it was a poor choice.

Just think about this trivial use case: you want to find all places in code that navigate to a specific screen. With ScreenNavigator, this amounts to just finding usages of one or more simple methods defined in ScreenNavigator. With Navigation Component, it’s more nuanced and requires to look “under the hood” of the abstraction, making it so called “leaky abstraction”. Furthermore, if you used the aforementioned plugin to pass parameters to the next screen in a type-safe manner, this task will require completely different approach.

In my opinion, Google took a very wrong turn with Navigation component. When I look at this library, I see Loaders all over again: extremely complex library that addressed already solvable problem in a very complex and inconvenient way. My prediction is that it’ll also share the fate of Loaders: waste millions of man-hours of developers’ time and then fade into irrelevance (unless Google somehow forces us to use this monster, of course). I just hope that all this waste will be accompanied by some second-order positive impact. For example, maybe Google will finally make Fragment transitions simple and reliable.

Therefore, all in all, my recommendation is to stay away from Navigation Component. If you absolutely want to use it, then, at the very least, wrap this nonsense in ScreenNavigator abstraction. This way you’ll be able to refactor it out of your codebase relatively easily in the future.

Conclusion

As I told you at the beginning of this post, the way you structure navigation between screens in your app is a core part of its architecture. Well, to be precise, it becomes core part of app’s architecture if you don’t give this topic enough attention. However, if you extract SreenNavigator abstraction (you can give it different name, btw), then navigation concern will remain simple and elegant. You won’t even notice its importance in your codebase and that’s exactly what good design feels like.

Unfortunately, I don’t have anything positive to say about Navigation Architecture Component. To me, it looks like yet another over-engineered library that will become legacy in couple of years. Before this happens, however, it’ll take an enormous toll in the form of developers’ effort, attention and, at a later stage, refactoring time.

As always, leave your comments and questions below and don’t forget to subscribe to my mailing list if you liked this article.