Blog Infos
Author
Published
Topics
,
Published
Designing a scalable architecture for multi-module Jetpack Compose apps

The overall structure of a single feature

Multi-module architecture

Alright. You saw the title. We are talking not about just any Jetpack Compose app but the multi-module ones. But what makes multi-module projects so different? Well, they are definitely more complex architecture-wise. And most importantly, they are very easy to mess up, so you no longer have the benefits they bring, but still, you have to deal with the increased complexity.

  • Each module that our feature depends on should be as small as possible.

Separation of a feature into API and Impl modules

Multi-module project architecture. (Dependencies between features and libraries on the diagram are random)

Concrete example project

To make the demonstration a little bit easier we will use a concrete example project that is built on concepts discussed in the blog post.

Concrete example project with a multi-module architecture

Photo by Radik Sitdikov on Unsplash

Navigation

Now, let’s talk about navigation. First, let’s see what do we have available in the standard Jetpack Compose Navigation API. The code snippet below shows how to set up the navigation for Jetpack Compose in the project.

 

 

val navController = rememberNavController()
NavHost(navController = navController, startDestination = "movie-search") {
composable("movie-search") { MovieSearchScreen(...) }
composable("movie-details") { MovieDetailsScreen(...) }
...
}

Jetpack Compose Navigation API

 

NavHost(startDestination = "movie-details/{movieId}") {
...
composable(
"movie-details/{movieId}",
arguments = listOf(navArgument("movieId") { type = NavType.IntType })
) {
MovieDetailsScreen(backStackEntry.arguments?.getString("movieId"), ...)
}
}

Jetpack Compose Navigation API. Passing arguments

 

navController.navigate("movie-details/41835")

Navigating to the specific destination

 

API and Impl parts of a composable feature entry

Now, let’s see how to implement this in code. All the entry points to features would implement a base FeatureEntry interface.

import androidx.navigation.compose.composable
interface FeatureEntry {
val featureRoute: String
fun NavGraphBuilder.composable(
navController: NavHostController,
destinations: Destinations,
) {
composable(featureRoute) { backStackEntry ->
Composable(navController, destinations, backStackEntry)
}
}
...
}

Feature entry interface code. Part 1

 

val navController = rememberNavController()
NavHost(navController = navController, startDestination = "movie-search") {
composable("movie-search") { MovieSearchScreen(...) }
composable("movie-details") { MovieDetailsScreen(...) }
...
}

Jetpack Compose Navigation API. Calling composable function inside NavHost

interface FeatureEntry {
val featureRoute: String
...
@Composable
fun Composable(
navController: NavHostController,
destinations: Destinations,
backStackEntry: NavBackStackEntry
)
}

Feature entry interface code. Part 2

 

typealias Destinations = Map<Class<out FeatureEntry>, @JvmSuppressWildcards FeatureEntry>
inline fun <reified T : FeatureEntry> Destinations.find(): T =
this[T::class.java] as T

Destinations code

 

val myFeature = destinations.find<MyFeatureEntry>()

Retrieving feature entry point

 

abstract class MovieSearchEntry : FeatureEntry {
final override val featureRoute = "movie-search"
}

In the Impl module, we will create ImagesEntryImpl class that implements ImagesEntry. Its Composable function serves as an implementation of a feature entry point. It would contain calls to composable functions and a dependency injection setup.

class MovieSearchEntryImpl @Inject constructor() : MovieSearchEntry() {
@Composable
override fun Composable(
navController: NavHostController,
destinations: Destinations,
backStackEntry: NavBackStackEntry
) {
// Build your feature UI here.
...
}
}

In order to add our feature entry to the navigation graph, we can use the following code.

Building a navigation graph based on feature entries

 

Nested navigation

Jetpack Compose Navigation API also allows defining nested navigation graphs.

NavHost(navController, startDestination = startRoute) {
...
navigation(startDestination = nestedStartRoute, route = nested) {
composable(nestedStartRoute) { ... }
}
...
}

Nested navigation API in Compose

 

import androidx.navigation.compose.NamedNavArgument
interface FeatureEntry {
val featureRoute: String
val arguments: List<NamedNavArgument>
get() = emptyList()
}
import androidx.navigation.NavHostController
import androidx.navigation.NavBackStackEntry
interface ComposableFeatureEntry : FeatureEntry {
fun NavGraphBuilder.composable(
navController: NavHostController,
destinations: Destinations,
) { backStackEntry ->
composable(featureRoute, args) {
Composable(navController, destinations, backStackEntry)
}
}
@Composable
fun Composable(
navController: NavHostController,
destinations: Destinations,
backStackEntry: NavBackStackEntry
)
}
import androidx.navigation.NavHostController
interface AggregateFeatureEntry : FeatureEntry {
fun NavGraphBuilder.navigation(
navController: NavHostController,
destinations: Destinations,
)
}

FeatureEntry base interfaces after refactoring

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Navigation superpowers at your fingertips

This talk will begin by the demonstration of a beautiful sample app built with Compose Mulitplatform and Appyx, complete with:
Watch Video

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android engineer
Bumble

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android en ...
Bumble

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android enginee ...
Bumble

Jobs

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
abstract class MovieDetailsEntry : AggregateFeatureEntry {
final override val featureRoute = "movie-details?movieId={movieId}"
final override val arguments = listOf(
navArgument("movieId") {
type = NavType.IntType
}
)
fun destination(movieId: Int): String =
"movie-details?movieId=$movieId"
}

API part of profile feature entry

 

import androidx.navigation.compose.composable
import androidx.navigation.navigation
class MovieDetailsEntryImpl @Inject constructor() : MovieDetailsEntry() {
override fun NavGraphBuilder.navigation(
navController: NavHostController,
destinations: Destinations
) {
navigation(startDestination = destination(), route = "@ignored") {
composable(route = featureRoute, arguments) { ... }
composable(route = "credits/{movieId}") { ... }
}
}
}

Implementation of a profile feature entry point

 

val destinations: Destinations = ...
val movieDetailsDestination = destinations
.find<MovieDetailsEntry>()
.destination(movieId)
navController.navigate(movieDetailsDestination)
view raw Navigation.kt hosted with ❤ by GitHub

Navigating to the profile feature

Features without UI

It is also possible to have features without any UI. They are still separated into API and Impl modules. However, they need to use neither Jetpack Compose nor navigation.

Photo by Robin van Holst on Unsplash

 

Dependency injection

We will use Dagger for dependency injection. Why not Hilt? Since we are considering a multi-module architecture, each module most likely would have defined Dagger components. This means that every time we define dependencies between modules, their components would also need to depend on each other somehow. Let’s see two ways how a Dagger component can depend on other components:

  • Component dependencies — where the child component specifies which components it depends on.
@Component
interface ComponentA {
// provided dependencies
}
view raw ComponentA.kt hosted with ❤ by GitHub
@Component(dependencies = [ComponentA::class])
interface ComponentB {
...
}
view raw ComponentB.kt hosted with ❤ by GitHub

Dagger component dependencies mechanism

 

interface ProviderA {
// provided dependencies
}
@Component
interface ComponentA : ProviderA {
...
}
view raw ComponentA.kt hosted with ❤ by GitHub
@Component(dependencies = [ProviderA::class]
interface ComponentB {
...
}
view raw ComponentB.kt hosted with ❤ by GitHub

Dagger component dependencies on plain interfaces

 

Dagger component that extends provider interface from the API module

interface DataProvider {
val moviesRepository: MoviesRepository
}

Example of a dependency provider in API module

 

Impl part, in turn, contains your regular feature scoped Dagger component but also extends DataProvider interface.

@Singleton
@Component(
modules = [DataModule::class]
)
interface DataComponent : DataProvider

Dagger component in Impl module

 

class MovieSearchEntryImpl @Inject constructor() : MovieSearchEntry() {
@Composable
override fun Composable(...) {
val dataProvider = LocalDataProvider.current
val viewModel = injectedViewModel {
DaggerMovieSearchComponent.builder()
.dataProvider(dataProvider)
.build()
.viewModel
}
MovieSearchScreen(viewModel, ...)
}
}
import androidx.lifecycle.ViewModel
class MovieSearchViewModel @Inject constructor(...) : ViewModel() { ... }
@Composable
fun MovieSearchScreen(viewModel: MovieSearchViewModel, ...) { ... }

Injecting dependencies into feature entry

 

In order to do the injection in a composable world, we can create an injectedViewModel function that helps to provide the right instance of a ViewModel in the @Composable function.

@Composable
inline fun <reified VM : ViewModel> injectedViewModel(
key: String? = null,
crossinline viewModelInstanceCreator: () -> VM
): VM {
val factory = remember(key) {
object : ViewModelProvider.Factory {
override fun <VM : ViewModel> create(modelClass: Class<VM>): VM {
@Suppress("UNCHECKED_CAST")
return viewModelInstanceCreator() as VM
}
}
}
return viewModel(key = key, factory = factory)
}

Body of injectedViewModel function

 

interface DataProvider { ... }
val LocalDataProvider = compositionLocalOf<DataProvider> {
error("No data provider found!")
}
val dataProvider: DataProvider = ...
CompositionLocalProvider(LocalDataProvider provides dataProvider) {
NavHost(...) { ... }
}
val dataProvider = LocalDataProvider.current
val viewModel = injectedViewModel {
DaggerMovieSearchComponent.builder()
.dataProvider(dataProvider)
.build()
.viewModel
}

Using composition locals for component dependencies mechanism

 

Dagger module that provides a feature entry

 

@Module
interface MovieSearchEntryModule {
@Binds
@Singleton
@IntoMap
@FeatureEntryKey(MovieSearchEntry::class)
fun movieSearchEntry(impl: MovieSearchEntryImpl): FeatureEntry
}

Dagger module for a feature entry

 

We also use custom Dagger @MapKey in order to restrict map key type to FeatureEntry instances.

import dagger.MapKey
@MapKey
annotation class FeatureEntryKey(val value: KClass<out FeatureEntry>)

Custom Dagger map key for feature entries

 

Finally, in the app (injector) module, we gather all the feature entry Dagger modules in a NavigationModule which then is added to the AppComponent.

@Module(
includes = [
MovieSearchEntryModule::class,
MovieDetailsEntryModule::class
]
)
interface NavigationModule
@Singleton
@Component(
modules = [NavigationModule::class]
)
interface AppComponent {
val destinations: Destinations
}

Gathering all feature entries in the root Dagger component

 

@Singleton
@Component
interface AppComponent {
val appContext: Context
@Component.Factory
interface Factory {
fun create(@BindsInstance appContext: Context): AppComponent
}
}

Root Dagger component that provides application context

 

However, there is a catch here. In our multi-module architecture app (injector) module depends on all other feature modules but not vice versa. This means that feature modules do not have access to AppComponent.

 

Feature component is not able to directly depend on app component

 

This can be solved if we move a Dagger component that provides application Context to a library module. Let’s call it common.

 

Depending on singleton dependencies provided by the app component

 

The instance of an application Context can be provided the same way through CommonComponent in the library module.

interface CommonProvider {
val appContext: Context
}
@Singleton
@Component
interface CommonComponent : CommonProvider {
@Component.Factory
interface Factory {
fun create(@BindsInstance appContext: Context): CommonComponent
}
}

Defining a Dagger component for common dependencies in the project

 

@Singleton
@Component(
dependencies = [CommonProvider::class],
modules = [NavigationModule::class]
)
interface AppComponent : CommonProvider

Finally, if we need access to Context in any feature, we add CommonProvider as a dependency to its Dagger component.

@Component(
dependencies = [CommonProvider::class],
modules = [DataModule::class]
)
interface DataComponent : DataProvider

Adding common Dagger component to the feature

 

Conclusion

We have just seen an approach to designing a scalable multi-module architecture for Android apps that use Jetpack Compose, including navigation. Below is the complete diagram that defines a structure of a single feature in the app using the described approach.

The complete structure of a single feature

Feel free to share your thoughts or concerns in the comments section.

Thanks to Omolara Adejuwon.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu