Navigating the Complexities: Building a Scalable Multi-Module Navigation Architecture in Android

Rahmi Cemre Ünal
5 min readJul 26, 2023

--

Designing a multi-module Android project can be like solving a complex puzzle, where each module is a piece that needs to fit together seamlessly. And within this puzzle, one of the fundamental challenges you'll face is creating a navigation system that works flawlessly.

Navigating between modules isn’t a walk in the park if we want to reap the real benefits of modularization and eliminate inter-modular dependencies. It requires careful planning, considering how different modules connect, while ensuring individual parts of the app can still communicate with each other.

In this post, we're going to tackle the tricky task of building a scalable navigation architecture, but first, it’s important to understand why it is not so simple to navigate screens in a multi-module project. So let’s get started!

The Problem

One of the critical advantages of breaking down an app into multiple modules is improved build times. With a well-structured multi-module project, developers can compile and build specific modules independently. This means that when making changes to a single module, you don’t need to rebuild the entire app, resulting in faster build times and increased productivity.

The primary hurdle is facilitating communication between modules without creating tight dependencies. Each module should be able to function independently, with minimal knowledge of other modules. This decoupling ensures that changes in one module don’t cause a ripple effect throughout the entire project and result in enhanced incremental build times. Every layer of the architecture needs to be designed carefully to achieve this gain but for this post, our primary focus is the navigation part.

So as an example, imagine we have 2 independent modules, containing 2 different features like this:

They are well isolated within their dedicated modules and also unaware of the internals of the other module so the question is, how can we navigate from the first feature to the second feature without making them dependent on each other?

The Solution

Before starting to implement the solution, let’s think about what we call a “feature” and how we can communicate with it.

It should represent a distinct part of our app and should have a predefined boundary to contain its logic, preferably over an interface to not leak its implementation details.

With this mindset, we can create a single entry point for a feature and use it when we need something about our fully modularized feature.

The feature:shared can contain FeatureCommunicator which is just a contract for the necessary methods to communicate with the feature. The real implementation of the communicator should stay in a separate module, like the feature:implementation module in the illustration above. Then whenever we need to do anything with this feature, we can just talk with it over this contract in the shared module. It’s called shared since we will share this module with other feature modules and because of that, it is expected to be very lightweight (prefer Java modules if possible) to not impact the build time significantly.

This probably still sounds too abstract so let's write the code.

So let’s assume we have a single activity with multiple fragments architecture and we have one module per fragment which are individual features.

Firstly, we can create a new shared module for our feature to define the communicator:

interface FirstFeatureCommunicator {
fun startFeature()
}

We need to implement this interface in the module where the feature exists:

class FirstFeatureCommunicatorImpl @Inject constructor(
@MainFragmentContainerId private val mainFragmentContainerId: Int,
@ActivityContext private val context: Context,
) : FirstFeatureCommunicator {

private val fragmentManager = (context as AppCompatActivity).supportFragmentManager

override fun startFeature() {
val fragment = FirstFeatureFragment()
fragmentManager.addFragment(mainFragmentContainerId, fragment, HomeCommunicator.TAG)
}
}

This approach heavily relies on the use of dependency injection. To access the necessary parts to start a fragment, we are using Hilt to inject the container id and the activity in this example.

Setting this up is quite simple. We just need to provide the container id in the Activity module, which will be used by the fragment manager later on.

@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {

@Provides
@MainFragmentContainerId
fun provideMainFragmentContainerId() = R.id.main_fragment_container
}

@Qualifier
annotation class MainFragmentContainerId

Then we can bind the communicator in the feature’s implementation module:

@Module
@InstallIn(ActivityComponent::class)
interface FirstFeatureActivityModule {
@Binds
fun bindFeatureCommunicator(
firstFeatureCommunicatorImpl: FirstFeatureCommunicatorImpl
): FirstFeatureCommunicator
}

So let’s launch the first feature which is the start destination of our app:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

@Inject
lateinit var firstFeatureCommunicator: FirstFeatureCommunicator

override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.Theme_DemoApp)
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
firstFeatureCommunicator.startFeature()
}
}

Okay but how can we navigate from the first feature to another feature?

It’s basically the same process with small modifications at this point. We can always change how to launch the feature based on our needs.

interface SecondFeatureCommunicator {
fun startFeature(secondFeatureArguments: SecondFeatureArguments)

companion object {
const val SECOND_FEATURE_NAV_ARGS_KEY = "secondFeatureNavArgsKey"
}

data class SecondFeatureArguments(
val id: Int
)
}

This time, let’s not just add, but replace the fragment like in the navigation component’s navigate method:

class SecondFeatureCommunicatorImpl @Inject constructor(
@MainFragmentContainerId private val mainFragmentContainerId: Int,
@ActivityContext private val context: Context,
) : SecondFeatureCommunicator {

private val fragmentManager = (context as AppCompatActivity).supportFragmentManager

override fun startFeature(secondFeatureArguments: SecondFeatureArguments) {
val fragment = SecondFeatureFragment().apply {
arguments =
bundleOf(SECOND_FEATURE_NAV_ARGS_KEY to secondFeatureArguments.toParcelable())
}
fragmentManager.replaceFragmentAddToBackStackAnimation(
mainFragmentContainerId,
fragment,
TAG
)
}

private fun SecondFeatureArguments.toParcelable(): SecondFeatureParcelableArguments {
return SecondFeatureParcelableArguments(id = id)
}
}

If you prefer to use a shared module as a Java module, parcelable is not allowed since it is part of the Android framework, so to solve this, we take the arguments as a raw data class and then convert it to a parcelable in the implementation module.

Also, we can write some useful extension functions to simplify the fragment transactions:

fun FragmentManager.addFragment(frameId: Int, fragment: Fragment, tag: String) {
inTransaction { add(frameId, fragment, tag) }
}

fun FragmentManager.replaceFragmentAddToBackStackAnimation(
frameId: Int,
fragment: Fragment,
tag: String,
): Int {
return inTransactionWithAnimation { replace(frameId, fragment, tag).addToBackStack(tag) }
}

inline fun FragmentManager.inTransaction(operation: FragmentTransaction.() -> FragmentTransaction): Int {
return beginTransaction().operation()
}

inline fun FragmentManager.inTransactionWithAnimation(operation: FragmentTransaction.() -> FragmentTransaction): Int {
val transaction = beginTransaction()
.setCustomAnimations(
R.anim.slide_in_right,
R.anim.slide_out_left,
R.anim.slide_in_left,
R.anim.slide_out_right
)

return transaction.operation()
}

Although I think this is a fairly simple solution to the navigation without doing too much magic and making it unnecessarily complex, I almost feel like you saying, okay but seriously, what year are we in? Is it still 2018, and who still uses the fragment manager anyway?

No need to worry. Just take a deep breath and in the next part, we'll explore how to implement a similar navigation system with the Jetpack’s Navigation component 🚀

--

--