8.
Multi-Module Apps
Written by Ricardo Costeira
Imagine you have a working app. You release it and it’s a success! Business is blooming, your app keeps growing and new people join the team. However, as time goes by, all the extra code and extra developers start to take a toll on the development process itself. Pull requests become more complex, build times increase, technical debt starts to accumulate… It’s time you sit down with your team and figure out a way to mitigate these problems and make your life easier.
One of the possibilities, in this case, is modularization. In this chapter, you’ll focus on multi-module architecture. You’ll learn:
- The benefits and drawbacks of modularization.
- The different kinds of modules and how they relate to one another.
- How to create a feature module.
- Some of the many things to consider when modularizing your app.
- Ways to navigate between features.
You’ll start with the basics.
What is modularization?
Modularization is the process of refactoring your app into separate modules. For PetSave, this implies transforming each of the packages into its own module.
Open the starter project and look at the project’s structure. It now represents a typical multi-module architecture.
Modules represent either shared behavior or features. Here, common and logging represent shared behavior. The app won’t work without the shared behavior modules, so they’re known as core modules.
logging works as an abstraction module because it abstracts away a specific kind of behavior. This is a clean way of encapsulating third-party libraries. At this point, it only encapsulates the Timber library but you could extend it to handle more complex tools, like Crashlytics or Bugfender.
The animalsnearyou and search modules are inside the features folder. They represent the features you already know. Feature modules should depend only on core modules, and never on other feature modules.
Before you go any further, take a moment to see which types of modules are available.
Types of modules
You define the kind of module you create through its build.gradle. There are a few different types, but you’ll only look at three in this chapter. You’ll explore others in the next chapter.
Application modules
When you create a new Android project, you get a default app module automatically. This module is called an application module. You define it through this line at the top of its build.gradle:
apply plugin: 'com.android.application'
This is the main module of your app. It’s responsible for:
- Defining the app’s base configuration.
- Orchestrating the feature modules.
This module will behave differently depending on the kind of feature modules you’re working with. You’ll learn more about this in the next chapters.
Library modules
Unless you want to create different APKs, you only need one application module in your app. Any other modules you create will be library modules. You define these with the following plugin at the top of their build.gradle files:
apply plugin: 'com.android.library'
These modules implement the logic that adds a behavior to your app, whether user-facing logic or not. This is the case for all modules in PetSave other than app. The naming is a bit confusing, but even the feature modules are, in fact, library modules.
Kotlin/Java modules
You define both application and library modules with plugins in the com.android namespace. This makes them Android modules, meaning you should use Android code with them.
What if you want a module composed only of pure logic, free from the shackles of the Android framework? In that case, you can create a Java or, even better, a Kotlin module. You can do this using the following plugin in the module’s build.gradle:
apply plugin: 'kotlin'
These are the same as library modules, but without all the Android gunk. Don’t get too excited, these are rare. :]
Why modularization is good
Refactoring features into independent modules allows you to focus on each feature individually. This offers a few advantages:
- Changing one feature won’t affect the others, simplifying development.
- It opens the path to things like instant apps and dynamic feature modules, which you’ll learn about in the next chapter.
- When your codebase is large enough for it to make sense, modules allow you to have dedicated teams for each feature.
- You can reuse features in other apps. For instance, you might need to launch an app similar to the one you have, but with slightly different requirements. Just import the matching feature modules to that new app and half your work is done. You don’t want to develop the same feature three times. Take it from someone who’s done that in the past. :]
- You can try out new tech on one module without affecting the others. If you like it, you can then refactor the other modules. If you don’t, you can just refactor the module you changed to use the old tech again, and all is well.
- Refactoring becomes easier because each feature has clear boundaries. Even if you decide that refactoring is not worthwhile and build the whole thing over from scratch, modules make it easier.
- You can use conditions and/or feature toggles to try out new code and make sure it works before deleting old code.
- It offers a great way of doing A/B testing without making a mess of the code.
These are just a few of the positive things about multi-module architecture. The last ones are true not only for feature modules, but for other modules in general.
One specific improvement that’s usually mentioned, but wasn’t here, is build speed — for a good reason.
In large projects, build times can become long, to the point of disrupting your workflow. Many developers modularize their apps in an attempt to reduce those build times.
However, just because you refactor everything into a module, it doesn’t mean that the project’s build time will decrease. Sometimes, it even increases! Like (too) many things on software development, it depends™.
Using Gradle with modules
A modularized project’s build time depends on many things: how your modules depend on each other, how you set up your Gradle dependencies, if you use incremental annotation processing or not…
You can tackle almost everything related to build performance by using Gradle. The deeper you go, though, the more you need to know about Gradle. Gradle is complex to the point where focusing on it would require a chapter on its own. That said, there are a few simple things to consider when working with Gradle in a multi-module app:
Gradle properties
Properties like parallel project execution and configure on demand are very helpful. You’ll learn more about these later.
Incremental annotation processing
Libraries like Hilt and Room use annotation processing. Without incremental processing, any small change that triggers the kapt compiler forces it to process the whole module.
Incremental processing has been active by default since Kotlin 1.3.30 — but it only works if all the annotation processors you’re using are incremental. For PetSave, you can’t activate it with the current configuration because there’s a bug in Java 8 that prevents Room from being incremental.
Leaking dependencies
If you change a module internally, Gradle recompiles only that module. If you change that module’s external interface, you’ll trigger an update to the application binary interface, or ABI. This makes Gradle recompile that module as well as all modules that depend on it, and all modules that depend on those and so on.
You add a Gradle dependency to a module by using either implementation or api. If you include it through api, you’ll leak its interface through the interface of the module itself. In other words, whenever you change a dependency included through api, you’ll cause every module that depends on your module to be recompiled.
Long story short, try to use implementation in modularized projects whenever possible.
Setting Gradle properties
Going back to the Gradle properties, you’ll set a few of them for PetSave.
In the project’s root, or under Gradle Scripts, if you’re using the Android project structure, open gradle.properties. In it, uncomment org.gradle.parallel=true at the end of the file. This allows Gradle to compile independent modules in parallel.
While you’re at it, add these lines below:
org.gradle.caching=true
org.gradle.configureondemand=true
caching tells Gradle to store and reuse any files it can from previous builds.
configureondemand is worthwhile for projects with many modules. Gradle builds have three phases: Initialization, configuration and execution. Setting configureondemand to true tells Gradle to not reconfigure modules that aren’t involved in the tasks it’s running.
Sync your Gradle configuration and run the app. Don’t expect any major difference in build time. This is a small project after all, so the build time was already small. In fact, is it worth it to modularize a project like PetSave?
The short answer is: No. Probably.
Looking back over your decisions so far
Now, for the long answer. Modularization brings a whole new set of complexity to module configuration and dependency management. You should be aware of this before you start modularizing your app. The complexity involved can become difficult to handle.
From a high-level perspective, the process you followed for PetSave so far was to:
-
Create new modules for each feature and for
common. This involved creating the new folder structure, adding a build.gradle for each module and moving code to the correct module. -
Extract the common dependencies between the modules into a common Gradle file named android-library.gradle.
-
Make sure dependency injection still works.
-
Extract the resources — strings, layouts and everything else — to their corresponding modules. This also includes creating two new navigation graphs, one for each feature. These are included in the main graph.
-
Fix the tests.
Now, look at each one in more detail.
Creating the modules
The module creation was straightforward; even the package names are the same. common could be further divided into more modules, but it doesn’t seem worthwhile here.
In Figure 8.2 below, you can see a before (a) and after (b) view of the folder structure. You can also see that the search domain models are now in the common module, since the repository contract (c), which is in the common module as well, depends on them.
Sometimes, for instance, it makes sense to have a module for the domain layer only — or a module for the data layer, so you can use it in another app.
All the domain models now live in the common module. This includes the few models that search had in its package, because the repository contract needs to know about them. Otherwise, common would depend on search, and core modules should not depend on feature modules.
Extracting common dependencies
Things got a little more complicated with dependencies. You had to decide how to deal with them. Should you add the required dependencies to each module, or gather the common ones into a single Gradle file and share it?
The three newly created modules share most of the dependencies. In this case, you aggregated the common ones in android-library.gradle. Open the file — it’s next to all the other build.gradle files.
The com.android.library is at the top, followed by an android block and a dependencies block. There are no api dependencies. You do this to avoid unneeded recompilation of modules that don’t use this configuration but depend on modules that do.
Although there are some slight differences, the android block is similar to the one in app’s Gradle file. You could extract it, but would you really gain something here? Probably just more complexity. If you don’t see a clear advantage, let future you worry about it. :]
Besides, if you need to override some configuration or add something new, you can still do it for the modules that need it. For instance, open common’s build.gradle. It includes the android-library.gradle configuration through the apply from at the top. It also adds some extra Room-related configuration to the android block and its own dependencies.
Checking the dependency injection
After completing the Gradle configuration, it was time to compile the app and make sure everything still worked. The main concern was Hilt, due to past Dagger experiences.
As expected, Hilt didn’t work at first but the reason was simple: Hilt’s entry point is PetSaveApplication, in the app module. That’s where it creates the dependency graph. As such, it needs to know about all the dependencies it has to inject, and it needs to do so at compile time.
Hilt creates the dependency graph before compiling the other modules. Only the common module is aware of dependencies like Retrofit and OKHttp, so Hilt complained about not knowing how to create the bindings. The solution was to add the required dependencies to the app module.
After this, the app wouldn’t run yet. The code was still trying to import resources from the app module. It was just a matter of moving the resources to the correct modules and updating the imports.
An important thing to note regarding the app’s theme: Remember that core modules shouldn’t depend on any other modules except other core modules. By default, the app theme is declared in the app module’s styles.xml. So, since all modules that have anything UI-related in them can depend on common, you moved the app theme to common.
It’s a simple app, with a single simple theme, so this will do. Just know that sometimes, if an app follows a more complex design system, with different themes and/or styles for different cases or a lot of custom UI components, you should consider encapsulating those things into a module of its own.
Extracting the resources
At this point, the app was running but all the navigation logic was still in the app module. It’s a good practice to have nested graphs for bottom navigation destinations, as they tend to include a few different screens. With nested graphs, you can isolate the navigation behavior in the module of the feature it belongs to.
This was a little more complex to deal with. With each feature module having its own graph, you had to make the appropriate changes done. You needed to:
- Include the graphs in the main graph.
- Update the bottom navigation menu to match the IDs of the graphs instead of the
Fragments.
Fixing the tests
After all this, it was time to check the impact on the tests — which was significant. All tests were still in the app module, so the first step was to move them to their corresponding modules. The second step was to fix all the damage that doing so caused.
The main problem was that tests in a module can’t access test files from other modules. So, the UI test in the search module stopped working, mainly due to Hilt test files being declared in common. This is where you start to consider having independent test files for each module, even if you repeat behavior, or a module just for test files.
The solution was twofold. You decided to:
- Manually bind dependencies where they were missing, using
@BindValue. - Grant the module access to the missing files through Gradle.
Open search’s build.gradle and you’ll see some test configuration details in the android block. These give the module access to the specified files.
It’s now time for you to create your own library module. It will be an easier ride than what you’ve done so far. :]
Creating the onboarding feature module
You might have noticed that the app has a new feature now. If not, do a clean install and run the app. You’ll see a new screen: onboarding.
It’s a simple screen that asks the user for a postal code and a distance. It stores that information, then uses it to search for animals. The idea for this screen is for it to evolve into a questionnaire about the user’s choices and preferences for pets. For now, though, making the search work is enough. :]
Currently, the feature is a part of the app module, but you’ll refactor it to be its own module.
Locate the code in the petsave.onboarding package in the app module. The implementation is similar to the other features, but with a few differences:
- The view state doesn’t handle errors anymore. Instead,
ViewModelhas aviewEffectsproperty that handles one-time effects like errors or navigation. - Both the view state and view effects are streams. However, instead of RxJava, this feature uses
StateFlowandSharedFlow.
After the user enters the postal code and distance, the app stores them in the shared preferences. This translates into a dependency on the common module. The tricky part comes from a business rule that states that this screen should only appear the first time the user launches the app. The app has to decide which screen to show at the beginning, or to which screen it should navigate.
Activity is responsible for triggering this decision. MainActivity now also has a ViewModel, along with a use case. The use case tells the ViewModel whether the onboarding process is complete. If so, ViewModel tells Activity to show animals near you. Otherwise, it shows onboarding.
Tapping Submit causes the app to store the data and navigate to animals near you. This gives the onboarding feature a direct dependency on animals near you. You’ll need to change this when onboarding becomes a module because feature modules shouldn’t depend on each other.
Now that you know what you need to do, it’s time to get to work.
Adding a new module
In the project structure, right-click features. Select New ▸ Module from the context menu then, in the window that appears, select Android Library. Click Next.
On the next page, change Module name to :features:onboarding, which places the new module inside the features folder. Then, click Edit to edit the Package name.
Change the package name to com.raywenderlich.android.petsave.onboarding to preserve the package structure. Click Done, then click Finish at the bottom of the window.
Wait while Android Studio does its magic. When it’s done, you’ll have your new module!
Adding code to your module
Now, you have to move the onboarding code from the app module to your new module. Moving packages between modules is tricky in Android Studio. To make things easier, disable Compact Middle Packages in the project structure:
As you can see in the image, this separates the packages instead of showing them in the compact format.
Now, drag the onboarding package in the app module to the petsave package in the onboarding module. This will replace the empty onboarding package inside and make the Select Refactoring dialog appear. Pick the second option: Move everything from <app module onboarding directory> to another directory. Click OK and let Android Studio work on it.
Eventually, a Problems Detected window will appear. It’s complaining about inaccessible dependencies.
You’ll fix that later. For now, just click Continue. When Android Studio finishes, the code will be in the onboarding module.
You can enable Compact Middle Packages again, if you want. Clean the project and build it again. The build will immediately fail due to missing dependencies — as you’d expect.
Organizing your dependencies
When Android Studio creates a module, it also creates a corresponding build.gradle. Go to the Gradle scripts and locate the one that refers to onboarding. Open it and delete everything inside.
This is a library module, so you’ll apply the android-library.gradle configuration. Add this as the first line:
apply from: "$rootProject.projectDir/android-library.gradle"
This already does a lot of the work for you, adding the main plugins and dependencies you’ll need in the project, but you still need to add something more. Create a dependencies block:
dependencies {
implementation project(":common")
// Navigation
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
}
Like the other features, this one depends on the common module. You’ll need the screen to navigate to animals near you, so you add in the navigation dependencies as well. You don’t put this in the android-library.gradle configuration because common doesn’t handle navigation.
Sync Gradle, clean the project, rebuild it and run it. The app will run… but it’ll crash into flames. If you look at the error in Logcat, it says that it can’t instantiate OnboardingFragment. This instantiation occurs in the app module. As it turns out, you created a new module, but didn’t tell the app module to depend on it.
Fixing the app module’s dependency
Open the app module’s build.gradle. Add this project import line in the dependencies block, along with the ones already there:
implementation project(":features:onboarding")
Sync Gradle and build the app. You’d think that it would work, but there’s one final change you need to make.
You also got an error in OnboardingFragment. It’s complaining that it can’t find R. That’s because it’s still using the old import from when it was in the app module.
Update the import by adding the onboarding package. While you’re at it, do the same to the view binding dependency. So, remove this:
import com.raywenderlich.android.petsave.R
import com.raywenderlich.android.petsave.databinding.FragmentOnboardingBinding
And add this instead:
import com.raywenderlich.android.petsave.onboarding.R
import com.raywenderlich.android.petsave.onboarding.databinding.FragmentOnboardingBinding
The view binding dependency still has a squiggly red line under it. That’s because the resources are still in the app module!
Handling module resources
Android Studio doesn’t create a res directory when you create a module, so you have to do it yourself. Right-click the onboarding module and select New ▸ Android Resource Directory. In the next window, choose layout from the drop-down menu in Resource type, then click OK at the bottom. This will create the res/layout package structure.
Next, go to the app module’s res directory. Expand layout and find fragment_onboarding.xml. All you have to do now is drag it down to the layout package in onboarding.
In the Move window that appears, click Refactor, then open fragment_onboarding.xml, if it doesn’t open automatically. It’s in the onboarding module now, but it can’t find the string resources.
Fortunately, there’s a simple fix. First, right-click res in onboarding, and select New ▸ Android Resource File. In the window, enter strings as the File name. Make sure Resource type is Values, then click OK. This creates the res/values/strings.xml file.
Next, inside the app module, open res/values/strings.xml. With a simple cut and paste, move every string resource — except for app_name — over to onboarding’s strings.xml. Be sure to paste them inside the resources tag in the onboarding module’s strings.xml.
Build and run. You’ll get a new error, but this one’s related to the navigation action to animals near you. You’ll fix that in a second.
For now, comment out the line that causes the error and build again. You’ll get the familiar R error, but in OnboardingFragmentViewModel this time. Fix it like you did before, and build again. And now, you get the same error in OnboardingViewState. Fix it and build again! Everything will work now.
One thing to keep in mind: Although you’re using resources in different modules, resource merging rules still apply. So, for instance, say you have two string resources in different modules. If they have the same name, one will override the other.
With that, you’re done with the resources. Now, to fix the navigation issue.
Navigating between feature modules
Navigation between modules is a complex problem in modularized architectures. If you need to navigate between different screens of the same feature, it’s business as usual. But what about navigation between different features? Features can’t depend on each other, so how do you navigate between them?
First, you’ll refactor the navigation logic, moving it to onboarding and including it in app. The onboarding feature is just a screen for now, and probably will be in the future. You’ll refactor it for consistency and decoupling, but it’s a judgment call, in this case.
Right-click onboarding’s res directory and select New ▸ Android Resource File. Enter nav_onboarding in File name and choose Navigation in Resource type. Click OK.
Now, go to the app module’s nav_graph.xml, under res/navigation. Cut the whole <fragment> tag and paste it in nav_onboarding.xml, inside the <navigation> tag.
Still in nav_onboarding.xml, add the import for tools. Set the startDestination of the graph by adding this property to the <navigation> tag:
app:startDestination="@id/onboardingFragment"
Go back to the app module’s nav_graph.xml. Add the include for nav_onboarding. Change the start destination accordingly, because you have to depend on the whole nested graph now.
When you’re done, it should look like this:
<?xml version="1.0" encoding="utf-8"?>
<navigation android:id="@+id/nav_graph"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:startDestination="@id/nav_onboarding">
<include app:graph="@navigation/nav_onboarding" />
<include app:graph="@navigation/nav_animalsnearyou" />
<include app:graph="@navigation/nav_search" />
</navigation>
Finally, you have to update MainActivityViewModel, located in main/presentation of the app module. Go to defineStartDestination() and replace R.id.onboardingFragment with R.id.nav_onboarding. If you don’t depend on the whole graph, the app will crash with an error stating that the destination is not a part of the navigation graph.
Adding the navigation ability
Next, you’ll deal with navigating between features. Up until now, the app module used a normal Navigation component action to navigate from onboarding to animals near you. But now, you’ve defined that action in onboarding’s nav_onboarding.xml:
<action
android:id="@+id/action_onboardingFragment_to_animalsNearYou"
app:destination="@id/nav_animalsnearyou"
app:popUpTo="@id/onboardingFragment"
app:popUpToInclusive="true"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim" />
app:destination="@id/nav_animalsnearyou" has a red squiggly below it because onboarding doesn’t depend on animalsnearyou.
You have a few options to solve this, and it’s not rare to find solutions with a mix of different options. The most common options are:
- Having a
navigationmodule that’s aware of every module and can navigate everywhere. - Using deep links.
You’ll go with the second option here. Navigation component has native support for deep links. This makes your life because you don’t need to manually create any intent filters.
Using deep links
First, go to animalsnearyou. Open res/navigation/nav_animalsnearyou.xml. Delete the comment inside the <fragment> tag and add this line in its place:
<deepLink app:uri="petsave://animalsnearyou" />
This allows the app to deep-link into this Fragment through that Uri.
Next, go to the app module and open its AndroidManifest.xml. With deep links, you need to add an intent filter to the Activity you want to deep link into. Since you’re using a “single Activity, multiple Fragments” architecture, you’ll add it to MainActivity.
The Navigation component makes your life easier here. It builds the intent filter for you through a tag called nav-graph.
In the <activity> tag, replace the comment with the line:
<nav-graph android:value="@navigation/nav_graph" />
<nav-graph> requires you to pass in the navigation graph where you defined the deep link. You pass in nav_graph because it includes all the other graphs
When you build the project, Navigation component will replace this tag with intent filters for every deep link inside the graph. Pretty neat!
Note: If you’re still stuck in Android Studio 3.1, you’ll have to add the intent filters yourself because it doesn’t support
nav-graph.
Setting up the navigation action
You can now set up the actual navigation action. Go to OnboardingFragment in the onboarding module. Locate navigateToAnimalsNearYou() and delete any code inside, replacing it with:
// 1
val deepLink = NavDeepLinkRequest.Builder
.fromUri("petsave://animalsnearyou".toUri())
.build()
// 2
val navOptions = NavOptions.Builder()
.setPopUpTo(R.id.nav_onboarding, true)
.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.build()
// 3
findNavController().navigate(deepLink, navOptions)
Here’s what’s going on in this code:
- It creates the deep link through
NavDeepLinkRequest. You pass in the sameUrias the one that the deep link innav_animalsnearyoudefines. - The navigation action in
nav_onboardinghas some logic to it. It pops up the back stack until it reachesOnboardingFragment, popping it along as well. This prevents pressing the back button while in animals near you from showing onboarding again. It also adds enter and exit animations. This piece of code does that as well, with the difference that it pops up the wholenav_onboardinggraph. That way, even if you add new screens to onboarding, they all get popped out of the back stack. - As before, the code calls
navigate()on thenavController. But now, instead of passing the ID of the navigation action, it passes the deep link request and the navigation options.
Build the app and do a clean install. You’ll see onboarding. Type some data and tap Submit and the app will navigate to animals near you. Just be sure to enter a valid postal code; otherwise, you won’t see any animals. The app isn’t ready to handle the invalid postal code case yet. :]
Well done! You can delete the old navigation action from nav_onboarding, as you won’t need it anymore.
Additional improvements
While the current code works, you could improve it further. The first thing to do would be to extract the deep link Uri to ensure you use the same one everywhere.
Another possibility, as you add more navigation to the app, is to create a specific module just for navigation actions. Otherwise, you’d start to have deep link resources repeated throughout the modules. This navigation module would also encapsulate all other navigation details, such as different navOptions configurations.
You’ve finished your module, congratulations! You won’t create any tests in this chapter because the module is simple enough that testing it wouldn’t give you any additional information.
In the next chapters, you’ll venture further down the modularization rabbit hole.
Key points
- There are three types of modules: application modules, library modules and Kotlin modules. Library modules can be core modules or feature modules. Kotlin modules are like library modules, but without Android framework code.
- Every app needs an application module, which bosses the feature modules around. The application module can also depend on core modules. Each one generates an APK.
- Feature modules can depend on core modules, but never on each other. Core modules can depend on each other.
- Modularization brings a lot to the table. Its applicability depends on the app you’re working on, so you should carefully evaluate the pros and cons. Instead of diving in blindly and modularizing everything, try to understand if it makes sense for your app.
- Navigation is hard. It gets harder in multi-module apps, but Android provides a possible solution.