Part 2: Dynamic Delivery in multi-module projects at Bumble
Tags: Kotlin, Dynamic Delivery, Android App Development, Programming, Android

In part 1 of my series of articles, I have already explained to you what Dynamic Delivery is and what API it has. In this article, I describe in detail exactly how I used Dynamic Delivery in our application Bumble and why, in particular, integration was so easy. As a result, I was able to reduce the size of the application by half a megabyte for 99% of our users, turning a function which was available only in a given geolocation into a downloadable module.

I hope this will be as useful as it was for me :)

Bumble Brew

Recently we have been experimenting with offline interaction with users and have added a new screen featuring a QR code which when scanned at the door would allow you entry into a restaurant, cafe or shop, for example. This is what it looks like:

There is no particular logic to it, but it has a very attractive background. In fact, given its maximum size it is 547-KB-attractive. The specific nature of this screen means that it’s only going to be used by a very small number of users, for example by city dwellers heading for an offline meeting venue. But all users still have half a megabyte of space taken up on their devices. Basically, this makes it an ideal candidate for Dynamic Delivery.

Preparing the Brew module

We use RIBs architecture when creating screens. Articles on this architecture will appear in the future, but for now, to make it easier to understand, we are going to consider RIB as a Fragment . This is because the underlying idea is the same: there is a self-contained screen element with or without UI, that can either be integrated into other elements or can integrate them itself.

In a multi-module app for whatever screen module it is, whether Fragment or RIB , the following is true: it has a public API which describes the interaction with this screen from outside; and an internal API which is needed for the functioning of this screen. Usually we segregate public and private API using access modifiers. However, you can go further and separate public and private API into two different modules: :components:BrewScreen:Interface and :components:BrewScreen:Implementation .

In the first module we describe how to work with this screen, and the dependencies it has.

The whole public API fits into 4 interfaces, which the application module will work with. Dependency declares dependencies needed by this screen. brewOutput acts as a callback for obtaining results from this screen; uiScreen provides information about what is needed and how to display it on-screen (the screen does not perform a network query itself, but receives the results of an external network query); hotpanelTracker is for analytics tracking. Customisation allows you to change the appearance of the screen, for example, replacing the logo and background with branded alternatives. This approach makes it much easier to adapt existing screens for other applications. Output is the result of the screen. In our case, you can only close the screen. However, in future there may be a new event, for example, for opening another screen in the application.

In the second module we implement all the logic and UI for this screen.

Practically all the classes of this module are internal , apart from BrewBuilder . BrewBuilder is a factory that will create an instance of RIB , which we can use in future.

Creating this sort of factory is extremely important for Dynamic Delivery, as it is very easy to use via reflection. All dependencies are expressed by one interface, which will be available without reflection, which means that it can be implemented inside the application module. The creation process itself also requires calling with only one previously known parameter.

DynamicDeliveryContainer

Since all RibBuilders implement a single Builder<Dependency> interface, so this means that a generalised RIB container can be created for all dynamically downloadable screens.

The implementation of DynamicDeliveryContainer does the following:

it asks DynamicDeliveryFeatureDataSource , the implementation covered in part 1, whether the childRibConfiguration.moduleName module is installed or not. if the module is installed, then it calls childRibConfiguration.build(bundle) and attaches the created RIB . if a module is not installed, then it requests installation of the childRibConfiguration.moduleName module and displays some attractive UI. once the module is installed it replaces the attractive UI with a downloaded RIB .

Identical logic can be implemented easily with the help of fragments since fragments also support child fragments being deployed inside them.

Configuration of Dynamic Feature Module

Dynamic Feature Module is an ordinary Gradle module. Based on our example let’s look at the configuration:

Android Gradle Plugin contains a new plugin for these kinds of modules: com.android.dynamic-feature . At the same time, using this plugin imposes no limitations in practical terms; it’s fine to reuse configuration scripts for your modules ( ProjectHelper.configureAndroidLibraryProject ).

Documentation states that versionCode and versionName should not be set since these values are automatically received from the application module. Yes, that’s right; only these values can be cached. I have encountered a situation where the project stopped building after these values changed in the application module. I therefore decided to manually install a version, reusing the application’s properties file.

The dependencies which you specify will be correctly handled by the plugin. Only those dependencies which are not in the main module will be included in this module. In this case, it is just :components:BrewScreen:Implementation .

At the CI testing stage, I discovered that androidTest not only doesn’t run but cannot even build. For some reason, when attempting to build a module for testing instead of behaving like a Dynamic Feature Module, it behaves like an ordinary one. For this reason, lots of errors occur at the stage when manifests and resources merge. All the tests are either in the application module or in the screen module so this means that there’s no point even attempting to run them. For this reason, I switched off completely all the tasks associated with androidTest .

This is what the manifest for this type of module looks like:

This is where we configure module behaviour. In this case, the module is not Instant App; it will be installed on-demand and has an entirely localised name: @string/dynamic.delivery.feature.brew . It is important to have an entirely localised name since this name will be shown in the Google Play dialogue.

In this case, the localised name of the module is Brew, and it will be used in its uninflected form in all languages; the size of the module was increased for the purposes of the test.

You can also configure whether this module needs to be installed, when the application itself is being installed. This may be useful when you are making a Dynamic Feature Module from the registration screens, for deleting them after completing registration. dist:fusing is responsible for whether or not you need to include this module in APK for devices on Android 4.4 and lower, but we no longer support these.

Android Gradle Plugin will set the split field in the manifest itself, to which it writes the name of the Gradle module from the project.name . It is precisely this name which needs to be used for downloading the module. You cannot change this name without changing the name of the module.

You can read up in more detail about all these possible options in the documentation.

Here is how the final structure of Dynamic Feature Module turned out:

BrewCustomisationProvider is responsible for providing the image identifier to bg_brew.webp . We will create this BrewCustomisationProvider via reflection and use it to create an instance of Dependency . Since we will be deferring the creation of the Dependency until after the module installs, we won’t have any problems.

In Dynamic Feature Module be careful with resources! Those inside the module need to be accessed via this module’s R class. Those in the application’s module, need to be accessed via the application’s R class (as I do for ic_bumble_logo ). This is very important; otherwise, you will get ResourceNotFoundException . You need to be extremely careful, since, due to the dependency on the application’s module, aapt will generate an R class along with identifiers for all the resources from the application’s module.

Configuration of application

First of all, we configure the application’s module.

A new property has appeared in BaseAppModuleExtension : dynamicFeatures , in which it is essential to list all the paths to the Dynamic Feature Modules.

We also depend on DynamicDeliveryContainer , which performs the module download and displays it on the screen, and BrewScreen:Interface , where only interfaces for working with the screen are declared.

We will create a class where we will store all the constants for working with the Dynamic Feature Module. These constants include: the class name for the factory creating RIB , the class name for the customisation factory and the name of the module as in Gradle.

Since these classes are used via reflection, we add them to proguard.

All that’s left is to do is to create an instance of DynamicDeliveryContainer , send the necessary parameters to it and connect it to Activity .

The code given above can be made lighter by moving the creation of dependencies to Dagger. However, describing Dagger components and modules here would make the code more complicated, so, in the interests of simplicity, I create all the dependencies in place.

Bumble Brew is not available to all users, only to those who live in the city where a given event is being held. Also, users are included in a special user group. The application receives the list of groups which a user is part of when it first connects to the server. And that means that at this moment we can say whether or not the button for opening the Brew screen will be displayed. And at this time we can request deferred installation of the module using SplitInstallManager.deferredInstall .

How I broke tests

In our team we use fully-fledged Е2Е tests. The testing toolbox (in my case Appium) installs the application and simulates the user touching the screen. At the same time, the application itself uses an up-to-date version of the back-end. To install the application, we use the following function:

For tests we use AppBundle , from which we create APKS and install it on the device. Installation of APKS takes place using the install-apks command. And if you also use bundletool for your Е2Е tests, don’t forget to add the —-modules _ALL_ parameter, in order to add all the Dynamic Features straightaway. We didn’t have this parameter so all the tests for Brew started to fail.

As I mentioned earlier, unless a given application is installed from Google Play then it is unable to install modules. Either they will all be installed straightaway — or none will be at all. For this reason, the approach with —-modules _ALL_ does not cover scenarios for installing this module. At the present time we use Internal App Sharing for testing Dynamic Feature.

However, Google recently released a new version of com.google.android.play:core , in which FakeSplitInstallManagerFactory appeared. It is still not entirely equivalent to the ordinary SplitInstallManager , but it can nevertheless increase the quality of automated testing. It has 2 parameters for setting its behaviour:

setShouldNetworkError — after installing this, any installation will terminate with an error. modulesDirectory for setting the folder on the device from which you need to install requested modules. The modules themselves are copied into this folder in the form of APK.

You can obtain the necessary APK for installation using the following command:

As a result, the APK files obtained need to be sent to the device via adb and the folder needs to be identified in FakeSplitInstallManagerFactory .

Even though this approach is closer to what really happens, to use it you need a separate application build, using FakeSplitInstallManagerFactory . You also need to introduce changes to the testing toolbox. So, it is probably worth doing it when there are more dynamic models.

To verify that reflection is correct, use ordinary unit and integration tests. In my case these are Е2Е and the integration test, which simply navigates from the main screen to the Brew screen.

Conclusions

The Google technology, Dynamic Delivery, allows you to download and delete modules while the application is actually working.

If you have a multi-module project, then it’s possible that all the modules are clearly segregated into public and private API. Creating separate modules for each of them makes it easier to use Dynamic Delivery in the project. Given this structure, you only need to use reflection for creating instances of public API classes.

When all the application screens have a similar architecture and have clearly designated dependencies, you can create a universal container for downloading and displaying Dynamic Feature Modules. Using this container, you can very quickly convert those application screens which users rarely use to Dynamic Feature. And, thus, you can reduce the size of the application even further.