Navigating your way around customizable delivery

How to modularize your app and take advantage of Android App Bundles conditional delivery features.

Wojtek Kaliciński
Android Developers

--

Co-authored by Dom Elliott and Ben Weiss

Android App Bundle is the new publishing format for Android apps, replacing the monolithic APK. An app bundle is not installed directly. Instead, Google Play generates optimized APKs for each device from the app bundle. Compared to a monolithic APK, the APKs generated from bundles are typically much smaller. The development experience is simpler too, you don’t have to manage and version multiple APKs for different device configurations every release, saving you a lot of time.

The momentum behind Android App Bundle is incredible. Over 450,000 apps and games on Google Play use app bundles in production, representing over 30% of active installs. Apps switching to a[[ bundles have seen, on average, a 16% size saving compared to using a universal APK. This size savings has resulted in partners seeing up to an 11% increase in installs.

When you modularize your app you can take advantage of app bundles conditional delivery features. You can choose to deliver modules at install time based on conditions, such as a user’s country or device features. On-demand delivery means you can install and uninstall modules on the fly when your app needs them. We’ve seen partners build modules for a variety of use cases such as:

  • Large features that are only used by a small percentage of users
  • Specific hardware or software capabilities such as delivering an AR module to compatible devices
  • Specific Android versions
  • Delivering large libraries with limited lifetime that can be installed and removed when they’re no longer needed.

These advanced features are optional, you can just use the app bundle for publishing. You can also modularize your app without using any other customizable delivery options.

This post explores in some detail the features for creating and delivering modular apps, focusing on defining module dependencies and working with app navigation. First, however, I’ll bring you up-to-date with some of the new and planned features for bundles.

Latest and greatest

Our vision for app bundles goes further. We’re working on a dynamic framework with more options.

These options include in-app updates, enabling you to trigger and complete updates entirely from within your app. We’ve also worked on customizable asset delivery, enabling the app bundle to include asset packs. This feature can be especially useful for games developers who want to package large game assets with their game binary as a single artifact. We further introduced several options for delivering those assets to users.

We’re introducing new features that complement this dynamic framework. For example, the app size report in the Google Play Console provides data such as app download size and size on device. It gives you size guidance when you’re using the app bundle too. Recently, we added new metrics relating to your users, and we show you the proportion of your active users who have low device storage. These users can present an uninstall risk, and optimizing your app size is one way to reduce that risk.

We’ve also added a new feature to Google Play’s app signing service that enables you to upgrade your app signing key to a new cryptographically stronger signing key for new installs. This is especially useful if you created your app signing key a long time ago, and it’s not as cryptographically strong as you want it to be.

Finally, there’s internal app sharing, which makes testing app bundles and dynamic delivery easier. Internal app sharing gives you a fast way to share your apps for local testing. Using this mechanism, Google Play installs the test app on a device in the same way it is if the app were released. All you need to do is upload a bundle to Google Play, then share a URL with your testers, they then open this URL on their device to install the app. Developers using internal app sharing say it speeds up their workflows. You can make anyone at your company an uploader to internal app sharing, without giving them any other access to the Play Console.

Internal app sharing workflow

If you have other artifacts you’ve uploaded to the Play Console, you will be able to get install links for them too. For app bundles, go to the bundle explore and switch to an old version, and you can copy an install link. Find out more about developer tools on the Play Store here.

Finally, we’ve added the FakeSplitInstallManager class to Play Core. This enables you to test an app with dynamic features offline. Normally, when you load an on-demand dynamic feature in your app, SplitInstallManager requests the Play Store to install the splits for that dynamic feature and you have to wait for them to load. Using FakeSplitInstallManager, your app installs the splits needed locally, offline. You don’t have to wait for Play to deliver and install the splits. This makes it easy to iterate on dynamic features early on in the development process, without needing to be online and without waiting for the Play Store. You can still switch to SplitInstallManager and do full online testing with internal app sharing when you’re ready. This is available now in the latest Play Core release.

These testing features are all about making it easier for you to test your app bundles and dynamic delivery as part of your workflow.

Now you have an overview of the latest features in Android App Bundle let’s look more closely at features and modularize in your app.

Feature on feature dependencies

Before talking about feature-on-feature dependencies let’s recap one of the key capabilities of app bundles: dynamic modules.

Dynamic modules for app bundles

Say you have an app with three different features. One of them offers camera support (green), one offers video support (orange), and the third payments support (blue). The goal of modularizing your app is to break it into dynamic features so that you have a better separation of code. Modularization into dynamic features also means that different users can get different portions of your app: user 1 may use the camera support, user 2 may be a bit more advanced and use the camera and video support, and user 3 may use the camera and payment features.

The advantage of this approach is that each user can get whatever parts of the app they need, and they’ll have a much smaller installed app size compared to one delivered as a monolithic APK.

Google Play stats show that for every 3 megabytes decreases app size, conversions can increase by up to 1%.

There are other advantages that you do get out of modularizing your app. The first one is faster incremental build times from the ability to build only a subset of modules. This means you spend less time building your app and more time developing it. Another advantage is the logical separation of code. For example, if one team wants to work on the camera feature and another on the video feature, there should be fewer cross-team dependencies.

Here at Google, we’re big believers in modular app development: it speeds up development and improves quality. A modular approach means teams work independently to build, test, and debug features without the complexity of everyone working on one big, complex app code monolith.

For more information check out my article Local development and testing with on-demand modules.

Dynamic feature delivery

Once you have dynamic features in your app you get 3 methods of delivering them:

  • Install time delivery, where the user installs the app and the dynamic features are automatically downloaded onto the device.
  • On-demand delivery, which downloads dynamic features when the app requests them.
  • Conditional delivery, which depends on the configuration of the users’ devices. For example, a device that supports AR would install an AR support dynamic feature while devices without support for AR would not.

You can also use all of these delivery mechanisms with instant app or game delivery — this is where you deliver a small (up to 10 MB) version of your app that the user loads by selecting “Try Now” in the Play Store. However, where you mark your instant enabled dynamic features for at-install delivery these features are automatically installed when the user chooses to install the app. Also, where you mark features for on-demand delivery and you use them in your at-install delivery, you have to prompt the user to download these features from the installed app, because they won’t be automatically downloaded when the user starts using the installed app when coming from the instant app.

Base APK dependency

Originally dynamic features depended on the base APK. So, take our app with dynamic features for camera, video, and payment. If the camera and video features relied on common image processing software, that software had to reside in the APK.

This meant that, if a user only wanted the payment feature, they still had to download the APK containing the image processing software. This placed a limitation on how far you can go with optimizing your download size.

With Android Studio 4.0 you can define dynamic features that depend on other dynamic features. So, in the example app, you could now pull all this image processing code into the camera dynamic feature.

Now, users who want to use the payment feature won’t have to install an APK that includes image processing software. This means you are able to deliver a smaller APK and, hopefully, improve conversions.

To start using this feature install Android Studio 4.x Canary/Beta.

Once you’ve developed your features, you list them in the base app’s build.gradle file. If you’re adjusting the dependencies of existing features, you won’t need to make any changes.

Next, you define the dependencies in each of the features’ build.gradle files. If you’re adjusting the dependencies of existing features, you simply add the dependency on :camera to the video features build.gradle file.

Once this is done, Android Studio parses the dependency tree ready for use in the new Android Studio Canary feature dependencies capabilities. However, before you can use those features you have to add a feature flag. To do this go to Help > Edit Custom VM Options, add rundebug.feature.on.feature to the file, save the change, and restart Android Studio.

Now open Run > Edit configurations and in Run/Debug Configuration you can define various installation configurations for testing.

If you select the video feature, you see that the camera feature is required by the video feature. So, when you test the video feature, Android Studio will automatically select the camera feature and install it when you run the test. Also, if you deselect the camera feature the video feature is also deselected as you don’t have the camera feature you won’t be able to use the video feature.

Now build your app normally and upload it to the Play Console. Play then automatically serves the correct dynamic feature modules to your device.

App navigation

The traditional way to navigate is using the framework APIs, which provides 2 options:

  • Start an activity using an Intent, which is the easiest way to start a new screen for your app.
  • Use the supportFragmentManager to replace fragments as you need them to navigate from one screen to the other.

TheJetpack navigation architecture component makes it easier to navigate your screens. This component includes the Navigation Editor, where you define destinations and then manipulate the navigation paths for these destinations. The navigation defined in Navigation Editor populates the res/navigation/graph.xml file, eliminating the need to handwrite code: although you can still make manual edits if you wish.

You have to write a little bit of code within your layout.xml file to reference the NavHostFragment. You set it as your default fragment and reference the graph ID that you created, either manually or with the Navigation Editor.

After changing this, you don’t have to manually start activities or interact with the fragment manager itself, because the navigation component takes care of all of this for you.

Navigating dynamic features

App bundles and dynamic feature modules changed the way that apps navigate from a base module to a feature. An app can have multiple dynamic feature modules, installed at install time or later using one of the customizable delivery options mentioned above. This means a module might not be installed when your app navigates to it. That causes problems for the navigation component because the library expects a module to be on device when the app navigated to it. The Dynamic Feature Navigator libraries solve this problem.

Dynamic Feature Navigator is a set of AndroidX libraries that build on top of dynamic features, navigation components, and the Play Core library. The library is now available in Alpha, find out more in our guide here.

To illustrate how smooth the transition from navigation to dynamic feature navigation should be let’s look at an example navigation graph and on-demand module. Here are the steps you will follow:

  1. In your layout.xml file replace NavHostFragment with DynamicNavHostFragment, the new class that provides the base implementation and the easiest way to interact with Dynamic Feature Navigator.

Before

After

2. Make changes to your navigation graph. This process parallels the way in. which you declare module names in the build.gradle files to tell your app which modules are installed. In this case, for each feature, you add a module name and the destination of each feature so the dynamic feature navigator knows where to find and install a feature as it’s needed.

With these changes, you can navigate from a base module, download and install a new module, and launch it as required. All this without touching any other code. The navigator takes care of the installation state progress and all the intermediate states for you.

Let’s take a look under the hood to see how this works. When you use the navigation component, navigate() is called, and the navigator knows how to navigate from one destination to another.

When you’re using dynamic feature modules and they’re not installed, the navigation component does not know how to go from A to B. This challenge is solved within the dynamic feature navigator by providing an intermediate progress destination.

This ProgressDestination checks whether a module is installed. If the module is installed, the navigator directly and transparently navigates to the destination. If the module is not installed, the dynamic feature navigator downloads and installs the feature, and then launches to the destination feature.

You can also customize this process using the API provided by extending the AbstractProgressFragment. You pass it a layout ID for what you want to show. Then override the onProgress function, which transparently calls down to the Play Core API so your app can display the progress to your users. There are also functions to override the progress states that you might want to handle.

To set the custom progress fragment, open the graph.xml and add the fragment’s ID as a progressFragment.

You also have to set the progress fragment with that fragment ID, at some point within the navigation graph hierarchy, for the navigation component to catch it.

If customizing the progress destination is not enough, you can go even further and show installation progress using a toast like notification, or any UI of your choice.

To achieve this, pass in a DynamicInstallMonitor in Extras to the .navigate() call, and check DynamicInstallMonitor.installRequired to determine whether the installation is required. If false is returned, your app can jump to the module. Otherwise, you subscribe to the status of the installation, to get live data from SplitInstallSessionStates. You also check whether the installation arrives at an end state, to find out if the installation has failed or been canceled and your app cannot proceed to the feature. To use SplitInstallSessionStates, create InstallMonitor and pass it to the DynamicExtras. These DynamicExtras then get passed into the navigate function, where you can subscribe to the live data and observe the split states.

The dynamic feature navigator libraries are fully customizable through the AndroidX navigation-dynamic-features-core that provides all the navigator APIs and the APIs for activity navigation with dynamic features and fragmented navigation with dynamic features.

Start with the fragment version, because that gives you the easiest way to interact with the dynamic feature navigator. If you need to, you can go all the way down to the core and build a completely custom experience.

To get started, add the dependencies to your build.gradle file:

And, when you find a bug or other issue in the snapshot will have a feature request, file it at goo.gle/navigation-bug.

Kotlin extensions library for Play Core

Not every app uses the navigation component, and also not every dynamic feature has a UI, a fragment or an activity for example. This is where the Kotlin extensions library for Play Core comes in. As it is an extension library, it doesn’t replace the main Play Core artifact but builds on top of it. It uses the same APIs underneath.

Building on top of the existing API, we took the opportunity to simplify it and help guide you through the correct flows and recommended patterns of API use. This is done by leveraging the power of Kotlin coroutines. To illustrate, this is the original Play Core API, and it’s asynchronous.

manager.startInstall(request): Task

Most methods in the SplitInstallManager return immediately, with a task that sets callbacks. For example, addOnSuccessListener lets you listen for the work to be completed, so you can act after it completes. You also need to add the failure listener, where you can get exceptions and handle any failures.

So, how does this work in Play core KTX? It’s the same call, but it has a couple of differences.

First, there are no ugly callbacks. This call is sequential: it returns a result with a session ID instead of passing that through the task and callbacks. That’s because this function is actually a suspending function, so it has to run in a coroutine. It’s suspending but it’s not blocking, so it’s safe to call from the main thread. And, it returns a result that you can assign to a value.

This is implemented as an extension function on the SplitInstallManager, building on top of the existing APIs. And we use all the Kotlin syntactic sugar whenever it makes sense. In this example, we’re using default and named arguments, so it’s easier to call these methods. This works well for functions that return a single result. However, the Play Core API is much more complicated than that. This example shows the install process for a split.

It goes through many steps and emits many status events, and a simple suspending coroutine function is not something that would work. Normally, in Play Core, you would handle it with a listener, using code similar to this:

This code leaves you to handle, for example, adding a subclass listener and providing your own status handling. Importantly, you need to clean up the listener at the right moment, whenever the listener or the object leaves the scope, to prevent memory leaks and lifecycle issues.

Enter an API called Flow, which is part of the Kotlin coroutines library. This example requests a flow that emits status events about the installation for splits from the Play Core API.

The collect function on Flow is also suspending, which means this will run in a coroutine. And there’s another important property of coroutines: they support cancellation. In the KTX libraries for AndroidX, you get extensions on the view models, activities, and fragments. These extensions give you scopes for running your coroutines as long as your view model (or a fragment or an activity) are active, you will keep getting events.

As soon as the user navigates away from that screen and the scope is canceled, we also cancel the Flow, clean up any listeners, and you will not get any more events after your lifecycle is finished. On top of that, Flow and the Kotlin coroutine libraries come with a lot of operators built-in, so you can deal with the streams of events in a nicer way.

Here, for example, I’m filtering the stream of events for only the updates about the module I’m interested in:

This is a much better way of working with this API.

The module installation process is quite complicated — it goes through many different states. How do you know, as a developer, which ones are important, which ones you need a handle for the installation to be able to continue, and which ones simply affect the UI, but are not essential for the installation to complete?

Here’s a convenience function, that takes lambdas, and creates the listener for you.

For the mandatory states that you need to handle, such as user confirmation for downloading a large module, you need to provide these arguments. And of course, all the other states are optional arguments, so you can handle them as well and show it in your UI.

Final words

Modularizing your app simplifies development and can help you raise the quality bar on your code. Coupling modularization with Android App Bundles means that you can take advantage of dynamic features to deliver users the optimal code for the features they want to use.

The new features covered in this post enable you to achieve greater code size optimization with dependent dynamic features. The new navigation and Kotlin features simplify the coding needed to ensure your app navigation works seamlessly with dynamic features.

Taking full advantage of the dynamic features of app bundles to ensure you minimize the download and on-device size of your apps and games can help you achieve better conversion and reduce the likelihood of uninstalls.

To learn more, see Android App Bundle on the Android Developers website.

--

--