Exploring Dynamic Feature Navigation on Android

Since the introduction of the Navigation Component on Android, navigating the different parts of our application has become much more pleasant to implement. We’ve been able to better decouple navigation logic from our activities and fragments, along with being able to test these paths with more ease. However, the Navigation Component has only ever allowed us to achieve these things with components contained within Android application or library modules – with these not being the only kind of modules that our Android projects support, developers have been eager for more module type inclusion for the navigation component. For example, when it comes to the use of Dynamic Feature Modules within our applications, these cannot be navigated to via the use of the Navigation Component. For these cases, the new Dynamic Feature Navigation library extends from the Navigation Component to allow us to perform navigation which involves destinations defined within Dynamic Feature Modules.

Whilst it’s incredibly helpful being able to perform dynamic feature navigation with the navigation library, there is one big concern that might arise – what if the dynamic feature isn’t actually installed on the device at the time of navigation? Luckily for us, the new dynamic navigator library provides some classes which will help us out in this side of things. Because these modules can either come with the initial app download or be installed when requested, we can’t just navigate to these components in the same way that we would handle other parts of our application. Aside from the dynamic feature not being installed at the time of navigation – what if the feature is still downloading, or fails to download before navigating to? When it comes to these cases, there are a lot of different states to think about. Luckily for us, the new Dynamic Feature Navigation Component aims to ease this process, not only handling the navigation to dynamic features, but also handling the different states of install which dynamic features may be in.

In this post we’re going to dive into the Dynamic Feature Navigation library, learning not only about how we can utilise it within our applications, but also how its components work under the hood.

Note: Some of the concepts in this article will reference the Navigation Component. If you are unfamiliar with any concepts mentioned, it would be worth checking out the guides/documentation for the Navigation Component.


Navigating to a Dynamic Feature destination

Before we get started with the library, we need to go ahead and add the dependency to our application. It’s important to note that the library is still in alpha – so if you do get a chance to play with it then it’s a great time to provide feedback to the developers working on it. 

implementation "androidx.navigation:navigation-dynamic-features-
    fragment:2.3.0-alpha03"

If we were previously using the Navigation Component library, we would have had something that looked like this for our host fragment:

<androidx.fragment.app.FragmentContainerView
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:defaultNavHost="true"
    app:navGraph="@navigation/main_nav" />

When it comes to the Dynamic Navigator, we need to switch this out for the new DynamicNavHostFragment. This navigation host class allows us to handle navigation using destinations that are defined within dynamic feature modules.

<androidx.fragment.app.FragmentContainerView
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.dynamicfeatures
        .fragment.DynamicNavHostFragment"
    app:defaultNavHost="true"
    app:navGraph="@navigation/main_nav" />

Now that our navigation host is using the DynamicNavHostFragment class, we can go ahead and add our first navigation destination from our feature module into our graph.

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_nav"
    app:startDestination="@id/mainFragment">

    <fragment
        android:id="@+id/mainFragment"
        android:name="co.joebirch.navigationsample.MainFragment" >

</navigation>

At this point we have our navigation graph, along with our startDestination defined as our mainFragment reference. With this declared we now want to go ahead and add a destination to our graph – our first destination is going to be from a dynamic feature module. Let’s go ahead and add this to our navigation graph.

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_nav"
    app:startDestination="@id/mainFragment">

    <fragment
        android:id="@+id/mainFragment"
        android:name="co.joebirch.navigationsample.MainFragment" />

    <fragment
        app:moduleName="feature_one"
        android:id="@+id/featureOneFragment"
        android:name="co.joebirch.feature_one.FeatureOneFragment" />

</navigation>

We’ve added our destination here with the id of featureOneFragment. You’ll notice here that we have three different properties that we’ve set on our fragment destination:

  • moduleName – this is the name of the module that our navigation destination resides in. The library will look inside of this module when locating the destination, acting as the glue between the navigation graph and the specific destination. This is the key addition when compared to application / library module navigation.
  • id – the id of the navigation destination
  • name – the name of the fragment used for this navigation destination

With these defined our graph now has sufficient information to locate the fragment being used as our destination – now we need to actually configure the navigation to it. When it comes to this, the approach will look very similar to how we may have configured the Navigation Component for application and library module navigation.

To begin with we’ll go ahead and add an action to our mainFragment so that we can navigate to our feature module destination:

<fragment
    android:id="@+id/mainFragment"
    android:name="co.joebirch.navigationsample.MainFragment" >

        <action
            android:id="@+id/action_mainFragment_to_featureOneFragment"
            app:destination="@id/featureOneFragment" />

</fragment>

From our code we can now use a Nav Controller reference to trigger this action and perform the navigation that is defined by it:

findNavController().navigate(
    R.id.action_mainFragment_to_featureOneFragment)

Including graphs from Dynamic Feature modules

At this point we’ve added navigation for a dynamic feature module using our navigation graph, but in some cases we may have slightly more complex navigation to configure. For example, we may have a dynamic feature module which defines its own navigation graph – this would need to be referenced from our navigation graph that we defined above. Let’s say we have the following navigation graph defined within a second dynamic feature module:

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:startDestination="@id/secondFeatureFragmentOne">

    <fragment
        android:id="@+id/secondFeatureFragmentOne"
     android:name="co.joebirch.navigationsample.feature_two.FeatureTwoFragmentOne">

        <action
            android:id="@+id/action_secondFeatureFragmentOne_to_secondFeatureFragmentTwo"
            app:destination="@id/secondFeatureFragmentTwo" />
    </fragment>

    <fragment
        android:id="@+id/secondFeatureFragmentTwo"
        android:name="co.joebirch.navigationsample.feature_two.FeatureTwoFragmentTwo" />

</navigation>

Within this dynamic feature module, let’s say we have it defining its own navigation graph – regardless of whether or not the graph is more complex than this one, this helps to keep the responsibility of this contained. Now, we’re going to want to include this as part of our global navigation graph that we initially defined so that we can navigate to the fragments defined in this new graph. If we head back over to our main_nav graph then we can add the following information:

<include-dynamic
    android:id="@+id/featureNav"
    app:moduleName="secondFeature"
    app:graphResName="second_feature_nav"
    app:graphPackage="co.joebirch.navigationsample.feature_two" />

The include tag is already available in the navigation library, allowing us to add navigation graphs from standard library modules in our project. The Dynamic Feature Navigator library adds this new include-dynamic tag which can be used to add references to navigation graphs from within dynamic feature modules. This tag has four attributes which can be defined:

  • id – the id of the navigation graph
  • moduleName – the name of the module that the graph is located in
  • graphResName – the resource identifier used for the graph
  • graphPackage – the package where the graph file is located

With this include-dynamic tag added to our original main_nav graph we can now perform navigation actions on it. Let’s replace the action_mainFragment_to_featureOneFragment action within our graph so that we can navigate to the graph contained within our include-dynamic tag.

<action
    android:id="@+id/action_mainFragment_to_featureNav"
    app:destination="@id/featureNav" />

With this in place, when we trigger the action_mainFragment_to_featureNav action from our nav controller, the startDestination from our featureNav graph will be displayed and any following navigational behaviour will be taken from that graph.


Handling Dynamic Feature install during navigation

Whilst it’s incredibly helpful being able to perform dynamic feature navigation with the navigation library, there is one big concern that might arise – what if the dynamic feature isn’t actually installed on the device at the time of navigation? Luckily for us, the Dynamic Feature Navigation library provides some classes which will help us out in this side of things.

When navigation is performed to a dynamic feature module, as defined by the module attribute for the destination in our graph, the library will first check if the feature is installed on the device. If installed, navigation will be performed to the destination. Otherwise, a progress fragment will be displayed whilst the dynamic feature will be installed and the user will be navigated to the destination once installation has completed.

When it comes to this progress fragment that is displayed during installation, it will handle the whole installation process for us – this includes any loading, success or error states that occur. This progress fragment is in the form of the DefaultProgressFragment class – which extends from the AbstractProgressFragment class. Whilst this default fragment handles everything for us, we might want to provide our own customised implementation to be used during the install process. However, it is strongly recommended to use the DefaultProgressFragment unless you need to add extended functionality for this piece of the flow or customize the progress UI beyond the defaults.

When it comes to providing our own progress fragment, it needs to extend the AbstractProgressFragment class which means we will be required to implement the following methods:

  • onCancelled() – Called when the user cancels the installation process
  • onFailed(errorCode: Int) – Called when the installation of the dynamic feature has failed, with the error indicated by the provided errorCode
  • onProgress(status: Int, bytesDownloaded: Long, bytesTotal: Long) – Called whenever there is a progress update regarding the installation of the dynamic feature. Here, the bytesDownloaded represents the number of bytes that have been downloaded so far, along with bytesTotal that is the total number of bytes that need to be downloaded. Finally, the status will be one of the SplitInstallSessionStatus values which can be used to determine the current status of the dynamic feature install.

Once we have our customised progress fragment, we can set it using the app:progressDestination attribute to the destination ID which is handling our installation progress.


Monitoring dynamic feature install

In some cases we may wish to implement a non-blocking installation flow for our dynamic feature – for example, rather than showing some form of the AbstractProgressFragment we may wish to keep the user in the current context that they are in. This approach can help to achieve a smoother experience for the user and remove any blocking experience that may come from a progress screen.

Within the AbstractProgressFragment class there are some internals which are monitoring the install status of the dynamic feature, relaying this information back to our fragment implementation via the onProgress() override. This is handled using the DynamicInstallMonitor class, which is actually available for us to use outside of this progress fragment – meaning that we can allow the user to trigger an install from navigation, monitor the progress state whilst they continue their current tasks and navigate them once the install has completed (handling any other states along the way, such as errors).

We can begin here by creating a new reference to this monitor:

val installMonitor = DynamicInstallMonitor()

Before we begin any monitoring, we’re first going to perform our navigation. When doing so, we need to pass in an instance of the DynamicExtras class. This class essentially acts as a container for us to pass attributes when handling navigation for dynamic features. To build an instance of this class we can use the corresponding Builder to do so:

val dynamicExtras = DynamicExtras.Builder()
    .setInstallMonitor(installMonitor)
    .build()

The builder currently allows us to set two different references:

  • installMonitor – The reference used to monitor the current install state of the dynamic feature
  • destinationExtras – Any Navigator.Extras that we wish to pass for navigation

Now that we have a reference to our DynamicExtras we can go ahead and perform the navigation on our navigation controller:

findNavController().navigate(
    destinationId,
    null,
    null,
    dynamicExtras
)

Once we’ve triggered this navigate operation we need to immediately check the installed status for our dynamic feature. The install monitor instance that we previously instantiated allows us to check this using its isInstallRequired field, this will return either:

  • false – meaning that install is not required and we can continue to perform the navigation
  • true – install of the dynamic feature is required, meaning that we’ll need to observe the install state and perform the navigation once the install has completed.

At this point, if installation is required then we need to observe the install state. Whilst the installation process happens automatically, we check for this install state so that we know whether or not to observe the install status of the dynamic feature. Our installMonitor reference exposes a LiveData<SplitInstallSessionState> instance that allows us to observe when the state of the install changes. We can then use the value of this state to depict what should be shown within our UI. Finally, once the state represents a terminal state we need to remove our observer as observation is no longer required.

installMonitor.status.observe(viewLifecycleOwner, Observer { state ->
   when (state.status()) {
       SplitInstallSessionStatus.INSTALLED -> { }
       SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
           // Larger feature downloads require user confirmation
           splitInstallManager.startConfirmationDialogForResult(
                state,
                this,
                REQUEST_CODE_INSTALL_CONFIRMATION
           )
       }
       SplitInstallSessionStatus.FAILED -> {}
       SplitInstallSessionStatus.CANCELED -> {}
       ...
   }

   if (state.hasTerminalStatus()) {
       installMonitor.status.removeObservers(viewLifecycleOwner)
   }
})

We can see above that there are a collection of states which the flow could be in at any time, whilst it depends on the UI that you are planning to show here, the UX guide for dynamic delivery will help to handle each of the above states.


Under the hood

Now that we know how we can handle dynamic feature navigation in our apps, I want to take a little look at how things are working under the hood.

In the sections above we touched on this DynamicNavHostFragment class – this new class actually extends from the NavHostFragment that is used for non-dynamic feature navigation. The DynamicNavHostFragment does this in order to override the onCreateNavController() function of the NavHostFragment, using a collection of new classes within this to provide the functionality of dynamic navigation.

If we start at the top we have the NavHostFragment class – this is already available in the current navigation library, so I don’t want to go too much into this. If you’re not familiar with it already though, this class is used to act as a container for the content displayed within our navigation graph. If you noticed in the diagram above, the DynamicNavHostFragment is a new class within the Dynamic Navigation library that acts as the Nav Host for dynamic features – this class extends the original NavHostFragment, so a lot of the functionalities are inherited from that base class. The new Host Fragment class actually only overrides a single method from the base class, the onCreateNavController method. This override is used to configure some extra parts of the navigation logic during initialisation.

Within this onCreateNavController method we can see that there are a collection of new navigation handling classes being configured. During this onCreateNavController the NavigatorProvider for the provided navigation controller is populated with the additional providers which are required for dynamic navigation.

override fun onCreateNavController(navController: NavController) {
    super.onCreateNavController(navController)

    ...
    val navigatorProvider = navController.navigatorProvider
    navigatorProvider += DynamicActivityNavigator(
        requireActivity(), installManager)

    val fragmentNavigator = DynamicFragmentNavigator(requireContext(),
        childFragmentManager, id, installManager)
    navigatorProvider += fragmentNavigator

    val graphNavigator = DynamicGraphNavigator(
        navigatorProvider,
        installManager
    )
    ...
    navigatorProvider += graphNavigator
    navigatorProvider += DynamicIncludeGraphNavigator(requireContext(),
        navigatorProvider, navController.navInflater, installManager)
}

If we look at the default navigation component, we can see that there are a collection of classes which extend from a Navigator abstract class – this class is used to define a mechanism for navigating within an app. The classes which extend from this are required to implement the defined methods in order to build the navigation graph for the defined components. When it comes to dynamic feature navigation, these original classes have been reused to aid the implementation of dynamic navigation. In fact, each component navigator extends from its corresponding navigator class, overriding the navigate() function to handle the new requirements for dynamic feature navigation.

For example, we can jump into the DynamicActivityNavigator class and see the difference in the way it is handling navigation via the use of the DynamicInstallManager:

override fun navigate(
   destination: ActivityNavigator.Destination,
   args: Bundle?,
   navOptions: NavOptions?,
   navigatorExtras: Navigator.Extras?
): NavDestination? {
   val extras = navigatorExtras as? DynamicExtras
   if (destination is Destination) {
       val moduleName = destination.moduleName
       if (moduleName != null &&
           installManager.needsInstall(moduleName)) {
           return installManager.performInstall(
               destination, args, extras, moduleName)
       }
   }
   return super.navigate(
       destination,
       args,
       navOptions,
       if (extras != null) extras.destinationExtras else navigatorExtras
   )
}

Pretty similar to how we previously saw things in this article, right? The same flow of checking if the feature is available and then handling the navigation based off of that state. Without this new navigator class, the navigation component would be unable to handle these different states for dynamic features.

From the Navigation Component you may recall the concept of a NavDestination, this class is used to represent a single node within a navigation graph – the nodes (destinations) are then pieced together to create the graph which represents an applications navigational flow. Each of the Navigator classes in the navigation component housed their own implementation of the NavDestination. When it comes to dynamic features and their navigators, exactly the same applies.

If we again use the DynamicActivityNavigator as an example we can see that the Destination for this class extends from the initial ActivityNavigator.Destination class. This definition of the destination is instead used to house the module name that holds the dynamic feature, something that is not supported by the initial implementation. This can then be used during the navigate() method in our DynamicActivityNavigator class when it comes to navigating to this activity.

class Destination : ActivityNavigator.Destination {
    var moduleName: String? = null

    constructor(navigatorProvider: NavigatorProvider) :   
        super(navigatorProvider)constructor(
        activityNavigator: Navigator<out ActivityNavigator.Destination>
    ) : super(activityNavigator)

    override fun onInflate(context: Context, attrs: AttributeSet) {
        super.onInflate(context, attrs)
        context.withStyledAttributes(attrs, 
            R.styleable.DynamicActivityNavigator) {
            moduleName =
                getString(R.styleable.DynamicActivityNavigator_moduleName)
        }
    }
}

These Destination classes are then used in the same ways as previously done so by the navigation component. Here, dynamic feature navigation is building off of what already exists to extend the functionality for dynamic navigation.

As we can see from the small dive into the source of dynamic feature navigation, a lot of what already existed for the Navigation Component has been built off of to add support for navigation via dynamic features. This allows developers to hook into navigational approaches that we are already familiar with and promoting reuse not only within the source, but within our projects also.

—-

Throughout this post we’ve learnt how we can use the Dynamic Feature Navigation library to perform navigation for our feature models – regardless of their install state on our users device. Using this library allows us to implement frictionless navigation flows, offsetting most of the hard work onto this library, allowing us to focus on building great apps. Whilst we didn’t dive too deep into how this works under the hood, we’ve seen a high level view of how things are operating and the reuse of code from the original navigation component.

With all this together I look forward to seeing how you’ll be using this library for dynamic feature navigation within your applications! If you have any questions on how it can be used, or thoughts that you’d like to share, please do reach out in the comments 🙌

Thanks to Ben Weiss, Ash Davies & Wajahat Karim for reviewing and providing feedback on this post 🙌