Clean code: Android modules and Dependency Injection (feat: Koin)
A story of clean separations and cordial agreements
A modular app design is vital for developer sanity, app scalability, and code quality.
Design is the art of separation, grouping, abstraction, and hiding. — Uncle Bob Martin
How can I enjoy an Android app made of proper separations and abstractions (using modules) when each module may need to know when the Application
class is created?
(A few use cases for setting up modules are: initializing an analytics library, observing a LocaleLiveData
to sync recommendations when the app language changes, or unpacking bundled data and inserting into a local database.)
Great question! To explain, let’s hear from our good friend, Application
:
Quick note: though we’ll be using Koin as our DI library here, the following design patterns and process also applies to Dagger 2 (and most, if not all, other DI libraries).
Hi, I’m Application!
I live in a module named
app
. I‘m composed of lots of different modules because I like to be clean! I let each module do its own thing and I don’t worry about their implementation details — honestly, I’m more into the big-picture…But modules need me... especially when I’m first created. Luckily, we’ve come to an agreement on how to communicate with each other! The
base
module calls it: theModuleSetup
interface
. All I need to do is ask our friendKoin
for all instances ofModuleSetup
, and call thesetup
method on each one! I let the individual modules do the gritty work of tellingKoin
how to provide these implementations. Piece of cake 🍰.We stay out of each other’s hair, but still have a very cordial relationship 🙂.
Fin
For those of you who prefer lists rather than stories from inanimate objects; follow these three easy steps to set up your Android app’s modules:
- Create a shared
ModuleSetup
interface
with asetup(app: Application)
method - Provide
ModuleSetup
implementation(s) from each module andbind
them to theinterface
- In
Application
onCreate
, useKoin
’sgetAll<ModuleSetup>
to get all implementations and callsetup
on each with theApplication
context
For the following explanations, it will be helpful to know the implied app structure:
-- app
-- modules
-- base
-- ...
-- ...
-- super-cool
The modules
know nothing about app
. The base
module will contain code available to all other modules, including the app
module. This is where we will place our ModuleSetup
interface
.
Create a ModuleSetup
interface
Oftentimes we have code in our app that needs to be initialized. This means that we need the ability to provide callbacks for when the Application
class is created. Additionally, initialization code usually requires a Context
to run.
To meet these two requirements, whilst writing clean code, we need to create a contract for a class that contains a method which takes the Application
context as a parameter. This will allow Koin
’s getAll<T>
method to return all implementations of our contract so Application
can interact with them accordingly. This contract needs to be available to both the app
module and all other modules, so we’ll put it in our base
module.
Our contract will look like so:
package appname.setupinterface ModuleSetup {
fun setup(app: Application)
}
Now, any module that needs to be set up on Application
creation can implement this interface
and provide it with Koin
.
Implement, provide, and bind your ModuleSetup
implementation(s)
So, base
has created an interface
for us. Let’s implement it in our super-cool
module and run some amazing initialization code.
package appname.modules.super.cool.setupimport appname.setup.ModuleSetup
import ...
class ModuleSetupImpl constructor(private val repo: SuperCoolRepository, private val locale: LocaleLiveData) : ModuleSetup { @MainThread
override fun setup(app: Application) {
// warning: do not run blocking code on the main thread
locale.observeForever {
// runs task in background
repo.syncRecommendations(it.languageTag)
} }
}
We can use Koin
to inject dependencies into our implementation, if needed; notice the SuperCoolRepository
and LocaleLiveData
parameters.
In this setup example, we inject a LiveData which contains our app’s locale and a Repository which will build us some content recommendations based on the locale. This setup allows us to keep our content in sync with the user’s language no matter when or where it changes.
Next, we provide the implementation in our Koin
module.
package appname.modules.super.cool.di.modulesimport ...val superCoolModule = module { ...
single { SuperCoolRepository() }
single { ModuleSetupImpl(get(), get()) } bind ModuleSetup::class
...}
The key here is Koin
’s bind
operator. This allows us to bind our implementation to the ModuleSetup
interface
. Now, we can move on to our final step.
Ask Koin
for ModuleSetup
classes and profit!
We’ve already wired everything together! The last thing to do is to ask Koin
for all the ModuleSetup
classes and call thesetup
method on each one of them.
class ModularApplication : Application(), KoinComponent {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@ModularApplication)
modules(
appModule,
baseModule,
…,
superCoolModule
)
}
// Set up our modules
getKoin().getAll<ModuleSetup>().forEach {
it.setup(this)
}
}
}
A few things to point out here:
- We start
Koin
as usual and load our modules, includingsuperCoolModule
- We implement the
KoinComponent
interface
in order to accessKoin
- We
getKoin()
as provided by theKoinComponent
and call thegetAll<ModuleSetup>
method (on the main thread) in order to loop through and set up each module
That’s it!
One important note before we depart: all the code within the setup
method will get run on the main thread when the Application
is created. This means we must avoid blocking code in our setup methods at all costs, unless we first offload the work to a background thread. The more blocking code we have to run on the main thread, the longer it will take for our Application to start up. In more extreme cases, running blocking code (disk I/O, networking, etc.), will cause the app to crash.
We’ve now implemented a way to separate our code into modules while maintaining the ability to run vital code on Application
creation, all in three easy steps:
- Create our setup contract (
interface
) - Implement and provide all instances of the contract
- Retrieve contract implementations and interact with them in
Application
onCreate
Now, enjoy your clean and modular Android app!