Example of Android app structure with multiple modules
An example how android application can be structured using multiple modules. With the focus on how to configure Dagger.
Benefits come into play when you have a big application with many developers.
See also How modularisation affects build time of an android application. Note that article was published in January 2017. Since that time Google did a lot of improvements. Especially in plugin version 3.0.
persistence
module.base-app
, login-navigator
and user-details-navigator
.base-app
, user-details-navigator
.MainActivity
since it's default activity.The main question is how to organize dependency injection between different application modules. Dagger provides two mechanisms for relating components (see Component relationships form JavaDoc). Subcomponents and component dependencies.
We cannot use subcomponents because they are tightly coupled with parents (parent component should know the complete list of subcomponents). In other words, this is the circular dependency between application modules. That contradicts to our initial requirements.
Another disadvantage
the subcomponent implementation to inherit the entire binding graph from its parent
This allows to easily "leak" dependency. I prefer to be more explicit about what dependencies should be visible, even if it means writing more code.
But subcomponents are good choice to be used within one application module.
In that case, parent component knows nothing about dependent components. Also, we have to explicitly list all classes that we want to be available for dependent components. Let's see an example. base-app
app-module has LoggedOutComponent
which provides UserRepository
and know nothing about who will use it.
@Singleton
@Component(modules = arrayOf(PersistenceModule::class, SubComponentsBindigsModule::class))
interface LoggedOutComponent {
fun userRepository(): UserRepository
}
LoginComponent
, from login
app-module, depends on LoggedOutComponent
. This allows to inject UserRepository
into LoginPresenter
@Component(
modules = arrayOf(LoginModule::class),
dependencies = arrayOf(LoggedOutComponent::class))
@FeatureScope
interface LoginComponent {
fun inject(activity: LogInActivity)
@Component.Builder
interface Builder {
fun loggedOutComponent(component: LoggedOutComponent): Builder
fun build(): LoginComponent
}
}
LoggedOutComponent
resides in base-app
module which is the bottom level module. login
module depends on it. Actual instance for LoggedOutComponent
is created in App
class inside app
top-level module. We don't want to introduce the dependency from login
module to app
module. Otherwise, it would mean circular dependency.
In order to mitigate that, base-app
module defines interface LoggedOutComponentProvider
interface LoggedOutComponentProvider {
fun provideLoggedOutComponent(): LoggedOutComponent
}
Application has to implement this interface. That allows to get easily access component by casting context.getApplicationContext
to LoggedOutComponentProvider
. That's even easier to do with extension function
@Suppress("UnsafeCast")
fun Context.loggedOutComponent() =
(this.applicationContext as LoggedOutComponentProvider).provideLoggedOutComponent()
and use it inside LoginActivity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_log_in)
DaggerLoginComponent.builder()
.loginView(this)
.loggedOutComponent(this.loggedOutComponent())
.build()
.inject(this)
logInButton.setOnClickListener {
presenter.performLogIn(userNameEditText.text.toString())
}
}
This approach has one disadvantage - absence of compile time verification. Developer can forget to implement LoggedOutComponentProvider
and notice that only as runtime error. Fortunately, we can mitigate this disadvantage by having instrumented tests.
Eliminate circular dependency is much more important.
"Component dependencies" is very powerful technique. It allows composing different components into one. We could write something like that
@Component(
modules = arrayOf(LoginModule::class),
dependencies = arrayOf(LoggedOutComponent::class, LoggedInComponent.class))
Unfortunately, this has limitation. Only one component, from "dependencies array" can have scope. All other should be unscoped. Example above will work, if LoggedOutComponennt
has @Singlethon
scope and LoggedInComponent
is without scope. But won't work, if LoggedInComponent
has scope (@Singlethon
or any other).
That's why we in this repo we introduce LoggedInComponent
which is subcomponent of LoggedOutComponent
. It provides the same UserRepository
and UserName
(which make sense only when user is logged in).
@LoggedInScope
@Subcomponent(modules = arrayOf(LoggedInModule::class))
interface LoggedInComponent {
fun provideUser(): UserName
fun provideUserRepository(): UserRepository
@Subcomponent.Builder
interface Builder {
fun build(): LoggedInComponent
}
}
Another learning, but not related to "multiple modules" theme. Presenter has dependency on View. Instead of creating setView
method we can inject it via constructor. All you need is to tell Dagger, that View dependency will be provided during component creation.
@Component(
modules = arrayOf(LoginModule::class),
dependencies = arrayOf(LoggedOutComponent::class))
@FeatureScope
interface LoginComponent {
fun inject(activity: LogInActivity)
@Component.Builder
interface Builder {
@BindsInstance fun loginView(view: LogInView): Builder
fun loggedOutComponent(component: LoggedOutComponent): Builder
fun build(): LoginComponent
}
}
See binding instances documentation
Let's see how multi-module project affects build time. I will compare two different android Gradle plugin versions 2.3.3
and 3.0.0-beta7
.
Version 2.3.3
./gradlew clean assembleDebug --profile
~28 sec
./gradlew clean :app:assembleDebug --profile
~ 18 sec
My assumption is the following. :app:assembleDebug
compiles app
module and all dependent modules. In case of clean build - all of them.
Plain assembleDebug
do same as :app:assembleDebug
plus :peristence:assembleDebug
, :base-app:assembleDebug
etc. This introduces additional actions which blows up build time.
Version 3.0.0-beta7
./gradlew clean assembleDebug --profile
~13 sec
./gradlew clean :app:assembleDebug --profile
~ 13 sec
It seems that latest version not only faster but also "smarter". The difference is visible when we compare profile. :app:assembleDebug
executes less tasks than assembleDebug
. And version 3.0.0 assembleDebug
executes less tasks than version 2.3.3 assembleDebug
.
Let's take a close look at the generated profile. Version 2.3.3 :app:assembleDebug
contains task :user-details:compileReleaseKotlin
. That's strange since we are compiling debug build. Version 3.0.0 contains :user-details:compileDebugKotlin
, which is more logical. Same happens with flavors. Versions 3.0.0 can properly propagate compiled flavor to all app-modules. Before all flavors were compiled regardless your choice.
Let's see how incremental compilation is influenced by module division. When we change LoginActivity
from login
module, only login
and app
modules are recompiled. All other modules are "UP-TO_DATE". When we change Storage
from persistence
module, all dependent modules (login
, base-app
, user-details
) are recompiled. Navigator modules are not recompiled because they do not depend on persistence
module.
kapt
. See details. But it's possible to use ButterKnife in library module when you are using Java (see details).login
and app
modules. In that case LoginActivity
will use "main_activity" layout from app
module instead of one from login
module.app
, top level module, should include all screen modules via compile project(":user-details"). Otherwise screen wont be included into application at all and produce runtime error when we try to open it.AndroidManifest.xml
is merging result of all AndroidManifest.xml
its possible declare UserDetailsActivity
in user-details
module and specify parentActivityName
later in AndroidManifest.xml
from app module.