DEV Community

Azamat
Azamat

Posted on

Reduce Android App Size Using Google’s Play Feature Delivery

Source:https://www.freepik.com/free-vector/construction-crane-concept-illustration_24487817.htm#page=2&query=constructor&position=0&from_view=keyword&track=sph

The multifunctional app (Super app) has been a key trend in mobile development for a few years now. The benefits of a super app are many, yet the user is often interested in only some of its functions, whereas the rest still take up space on the device. Besides, creating one app to rule them all leads to bloat, which discourages downloads.

Reducing app size and letting the user choose the features they want are essential tasks, and Play Feature Delivery is there to help. The Internet has no shortage of guides on creating a dynamic feature from scratch. But how do we turn some code we wrote into a dynamic feature?

In this article, I will be covering migration of features to dynamic modules, using our flagship Android app as an example. I will tell you about the impact of dynamic delivery on architecture, the options for migrating existing features, the challenges I encountered, and the results of our efforts.

Dynamic feature delivery in a nutshell

Dynamic delivery is a technology created by Google that helps split out specific features from a monolithic app, installing or removing these even as the program runs.

If you are new to Play Feature Delivery, I suggest starting with the links I have collected at the bottom of this article.

Reviewing these materials will help to understand better the Dynamic Delivery modus operandi, become familiar with the API it provides, and find an example that uses the RIBs architecture. Not every project uses that architecture, though, so I am going to supplement the selection of sources by explaining how we migrated existing code to a dynamic module.

The impact of Dynamic Delivery on app architecture

Building a multimodular app

I will briefly describe our architecture, but you can find more details by clicking the links at the bottom of the article. Our project has a module-injector, which contains the base interfaces:

BaseDependencies are used for enumerating objects that the feature requires as inputs (feature dependencies), and BaseApi is used for enumerating objects that the feature exposes (external feature interface). ComponentHolder is needed as a link between BaseDependencies and BaseApi, as it allows to implement the BaseApi of the specific feature by passing all required dependencies.

For better understanding, we will look at the three interfaces while using the Security News feature as an example. The feature provides the latest news about security. Its input dependencies will include the login in string format, and it will expose an interactor with a method that checks for news:

SecurityNewsFeatureComponentHolder acts as the link:

Thus, you need to pass the feature dependencies to the init module of the SecurityNewsFeatureComponentHolder object to get an interactor instance in the main module.

The project contains an alternate ComponentHolder, LazyComponentHolder, which creates a feature API instance only when referenced for the first time. I am going to omit the details of its implementation to keep the text light.

If the project uses Dagger, the body of the createApi method will contain a reference to the feature dagger component. However, this triad of interfaces does not bind you to a specific DI tool.

In the code quoted above, SecurityNewsComponent extends the SecurityNewsFeatureApi interface with the SecNewsPresenter presenter. It is announced in the SecurityNewsComponent interface, not SecurityNewsFeatureApi, because it is used in the code within the feature module, not within the App module. To get SecNewsPresenter inside the feature, we need to change the SecurityNewsFeatureComponentHolder code.

As you may notice, we are now holding a SecurityNewsComponent-type link, not SecurityNewsFeatureApi. The new getSecurityNewsComponent method will help to get the SecNewsPresenter inside the feature code. As a result, the getSecurityNewsComponent method is referenced inside the feature module, and the get method is referenced outside the feature module.

The impact of Dynamic Delivery on app architecture

With regular apps, the main App module knows about every feature module. With Dynamic Delivery, a dynamic feature depends on the App module, which knows nothing about the dynamic feature module, interacting with it via ReflectionAPI. Read the documentation if you want to learn more.

module dependence diagram

The above limitations of Dynamic Delivery prompt us to modify the code inside the feature module. We will start with separating the module into the API and the implementation.

Module dependence diagram including Dynamic feature delivery

Module dependence diagram including Dynamic feature delivery
The same separation should be implemented to create a shared impl module for App and Dynamic feature. Classes and interfaces referenced by both modules can be placed inside the impl module.

We move SecurityNewsFeatureDependencies, SecurityNewsFeatureApi, and every interface and class used in these (SecurityNewsInteractor in our case) to the Dynamic feature API. However, we cannot move SecurityNewsFeatureComponentHolder, as it is associated with the feature entities that should remain inside the same module. If there is more than one reference to SecurityNewsFeatureComponentHolder, the usage of ReflectionAPI in the code will increase: for example, we would need to call the init method via ReflectionAPI to initialize the feature, and then the get method to get the external feature interface.

To keep the code simpler, you can reduce the number of methods available via ReflectionAPI to just one by leaving only the init initialization method. To do this, we create an ApiHolder abstract class in module-injector:

In the FeatureSecurityNewsApi module, we create a SecurityNewsApiHolder object:

In the FeatureSecurityNewsImpl module, we change the code in SecurityNewsFeatureComponentHolder:

Thus, we first need to call the SecurityNewsFeatureComponentHolder initialization method via ReflectionAPI, creating SecurityNewsComponent and saving a link to the component in the FeatureSecurityNewsApi object field. After the initialization, the getSecurityNewsComponent method of the SecurityNewsFeatureComponentHolder object will be referenced inside the feature module, and the getApi method of the SecurityNewsApiHolder.23 module, outside the module.

Migrating existing features

We have successfully adapted the architecture of the multimodular app to Dynamic Delivery. Next, we will apply one of the several scenarios of including the feature in the app. According to the official description, there are three scenarios:

  • Install-time delivery
  • Conditional delivery
  • On-demand delivery

The second scenario allows excluding features that meet certain criteria, such as country or device properties. The third scenario allows excluding a module for all users, leaving the possibility to download it later.

Let us consider the scenario where the Security News feature was part of the application, but now we want to make it dynamically downloadable on demand. If we make a regular gradle module into a dynamically downloadable one from the start, the feature will disappear next time the user updates the app. This can lead to adverse consequences, both behavioral and legal. Unless completely undesirable, you could explicitly ask the user to download the feature again. If it is essential to keep the feature on the device for those who already have it, for example, if the user has paid for it, then you need to think through a migration process. Let us consider possible migration scenarios, from easy to difficult.

Option 1

Above, we looked at three scenarios for including a feature in an app. To keep the feature on the user’s device, you can first convert the gradle module into a dynamic feature available at install time (install time scenario), then make it downloadable on demand (on-demand delivery scenario). With this option, we get three app releases:

  • Release A contains the feature as a regular gradle module
  • Release B contains the feature as a dynamic module with an install-time dist parameter
  • Release C contains the feature as a dynamic module with an on-demand dist parameter
  • In that case, the feature will not disappear from the user’s device when migrating from Release B to Release C. However, this migration option still does not eliminate the risk of losing the feature if the user gets Release A, misses Release B and then gets release C.

Option 2

The problem of missing an intermediate release can obviously be solved with a greater number of these releases. In other words, we can push several intermediate releases between A and C: B1, B2, B3… So, if the user misses B1, they still have a chance to catch B2 or B3.

This option is no guarantee that every user will keep the feature, either, but it can reduce the percentage of those who are affected.

Option 3

With this migration option, a feature download can be started in the background if the feature is found to be missing after an update. A module download that was initiated by the user may conclude with the handled error:

exception com.google.android.play.core.splitinstall.SplitInstallException: Split Install Error(-7): Download not permitted under current device circumstances (e.g. in background). (https://developer.android.com/reference/com/google/android/play/core/splitinstall/model/SplitInstallErrorCode.html#ACCESS_DENIED)

I have not been able to find any official documentation that contains clear conditions for a background download completing with an error and prompting the user for an explicit confirmation.

In my own experience, I have been able to install a feature more than ten times in the background before getting the error, that is, run the cycle of installing the APK, downloading the feature in the background, and removing the APK more than ten times. When I got the error, I had to launch the app and give my explicit consent to downloading. Besides, using traffic for the download without the user’s explicit agreement may be a bad idea.

This is another option that provides no guarantees. You will need to get the user’s explicit agreement by asking them to confirm the download. This is the fourth option.

Option 4

If a background download of the missing feature fails, you could show the user a notification explaining that the feature is missing from the device. The user will have to open the app and confirm the download. However, the user could miss the notification or have notifications from the app turned off.

Option 5

Google gives us the possibility of a deferred install. This option provides no control over the installation process. The official documentation mentions “best-effort when the app is in the background”. If past experience is anything to go by, the download accompanies the retrieval and installation of the next update. In other words, the user could spend some time without the feature until they get an update.

Option 6

If it is critical to keep certain functionality on all user devices, you can leave the feature core inside the app, moving only the optional portion and the space-hungry resources to the on-demand dynamic feature. In the case of Security News, the interactor could be included in the core, so that it stays behind to monitor and retrieve news updates. All the UI-related logic could be downloaded on demand.

Each feature should be analyzed for risks relating to unexpected removal from user devices, and the choice of migration scenario should be made with the results of the analysis in mind.

Results

Splitting out existing feature code into dynamic modules can be effort intensive and require one of the migration scenarios described above. Developing new features as dynamic modules is clearly often easier. However, splitting out existing features can reduce the app size significantly.

It is worth noting that Dynamic Delivery can only be used with AppBundle, itself a good optimizer. In our case, transitioning to AppBundle helped reduce the app size on various devices by an average of 16 percent.

Further splitting out dynamic features from the app will reduce the size even more. You can unpack the app and evaluate the expected result. The size of the following components can be optimized:

  • Dex files, by moving part of the code, including large libraries that other features do not need, to dynamic modules
  • Res and assets folders, by moving images and other files to dynamic modules
  • Lib folders, such as large native .so files
  • Binaries, by moving a large number of strings and identifiers to dynamic modules
  • The choice of functionality that should be converted to dynamic modules cannot be based on potential size reductions alone: popularity or accessibility should be considered as well. For instance, if a certain feature set is only available after the user purchases a license, it can be removed from the core app and offered as a download only after the user activates premium mode.

Remember that removal of certain features could adversely affect payment conversion, conversion to subscription, and so on.

Conclusion

Dynamic delivery provides better control over app size through the ability to download certain features on demand after the user has installed the app. Before moving code to downloadable modules, you need to analyze the viability and required effort for each feature.

The multimodular architecture described in this article can make your work on dynamic modules less challenging, and the suggested migration scenarios can minimize the potential risk of adverse consequences.

I hope that you found this article helpful. Go ahead and share your experience and opinions on this Google-provided capability in the comments.

Supplementary materials

Top comments (0)