Note: this was assembled with koin version 2.0.1, more recent versions have changed some things. Refer to the official documentation for more information: https://insert-koin.io/
Context
We have a legacy project, started by a team from another company, with other standards, practices, experiences and so on. This project was initially set up with Dagger as a dependency injection mechanism and is not modularised. As the project grew, so did the compilation times. When it got to the point where compiling the project could take more than 10 minutes we decided to see what we could do about it.
Modularisation, a possible solution?
We first considered modularising the project, so that only the modified modules would have to be recompiled instead of the whole project. This would not solve the initial compilation time but the incremental builds would be much faster.
But given the length of time the project had been under development without following good guidelines to reduce coupling, trying to get modules out was tremendously complicated.
Being able to modularise the project required a refactor at a very deep level, decoupling essential parts of the application from each other. And we had to do all this while still delivering new functionality to the customer.
Dagger and annotation processing
Thanks to Android Studio’s build analysis tool, we were able to see that approximately 40% to 50% of the time in each build was taken up by the annotation processor. And practically all of that time was taken up by Dagger.
We had already worked on other projects using Koin and given that the project code was already more than 90% Kotlin, we thought it was a good idea to migrate from one library to the other to see what would happen. In the worst case scenario we would end up with a dependency injection library that we already knew and were comfortable with.
Initial Configuration
We started the migration bit by bit. The first step was to include the library in the project and configure it.
private fun initKoin() {
startKoin {
androidContext(this@MyApp)
modules(koinModules)
}
}
Initially the list of koinModules
includes the instances of the most basic stateless common elements:
val koinModules = listOf(
commonModule,
networkModule,
databaseModule
)
These modules include things like the ApiClient, the Room database, a label manager or the analytics manager. Things that any project feature might need to a greater or lesser extent.
The next step was to add the test to make sure the module definitions are correct. The main drawback of moving from Dagger to Koin is that Dagger warns on every build if we have done something wrong, on the other hand Koin will fail only at runtime, so it is especially important to have a way to ensure the correctness of our modules. Luckily the way to test this in Koin is quite easy and we have a CI that runs all the tests before letting us release a version (either test or production).
class KoinModulesTest : KoinTest {
@get:Rule
@ExperimentalCoroutinesApi
val coroutineRule = CoroutineMainDispatcherRule()
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
@get:Rule
val mockProvider = MockProviderRule.create { clazz ->
mockkClass(clazz, relaxed = true)
}
@Test
fun testKoinDependencies() {
startKoin {
androidContext(mockk(relaxed = true))
modules(koinModules)
}.checkModules {
//Here we can define the parameters for the ViewModels
create<FeatureViewModel> { parametersOf(mockk<Foo>(), mockk<Bar>()) }
//We can also declare mocks
declareMock<MyService>()
}
}
}
}
With this single test we can test the entire dependency tree. The only thing that requires some attention are the dependencies that require external parameters to the tree itself, for example a parameter that we pass from a fragment to its viewmodel. We may also need to declare other mocks, especially if there is a dependency that executes code at build time, for example:
val data = liveData {
myService.getData(request)?.let { emit(it) }
}
If we don’t mock the dependency the test will end up giving problems trying to call the real service.
The two rules that head the test class are to avoid problems with the corrutines, as in the previous example, if the getData
method is suspendable, the test may end up failing even if the dependencies are well set up.
The third one is to define to koin which mocking framework to use, in our case we use Mockk, but you could use mockito or any other framework you want.
Gradual migration
We take advantage of new developments to use Koin for new features. It is easier to create the modules and dependencies in parallel, this can induce performance problems when we have for example the same ApiService instantiated twice, but except for the ViewModels, the rest of the classes are stateless so it doesn’t affect the performance of the project. And as new features require new ViewModels we don’t have the problem of having the same ViewModel injected in two different ways.
Each new feature will have a new module and this is added to the list of modules defined at the beginning. For example, let’s imagine we have a new feature whose ViewModel receives a Foo and a Bar from the fragment, and needs a FeatureService. The module would look like this:
val featureModule = module {
single {
FeatureService(get(), get())
}
//single<FeatureService>() if we can use reflection
viewModel { (foo: Foo, bar: Bar) ->
FeatureViewModule(foo, bar, get())
}
}
By using an experimental koin feature we can save having to define the service parameters. This feature uses reflection so it may not be usable in all cases, but in our case the impact on performance was not noticeable and we decided to keep it.
For existing features the migration is similar. We define a koin module equivalent to the one already defined in dagger, add it to the module list and change the fragment injection from:
@Inject
lateinit var viewModel: LegacyViewModel
to
private val viewModel: LegacyViewModel by viewModel { parametersOf(Foo()) }
Another advantage that koin offers us in this case is not having to declare the injected elements as lateinit
var making them clearer and safer, using the by viewModel
delegate the viewmodel will be instantiated in a lazy way, that is, only in case it is needed.
Once a module is migrated, we can remove the @Inject constructor from the injected classes so that our production code doesn’t need to know anything about how parameters are passed. Only the koin module definitions and our Android classes (Fragments, Activities) know anything about how dependencies are injected.
We can also stop inheriting from DaggerFragment
and DaggerAppCompatActivity
since koin works by extensions we don’t need to modify the parents of our classes.
Final Result
This migration took us some time, we started incrementally and we took advantage of a moment of few features to finish the migration definitively. Once the migration was finished we were left with a more idiomatic code, without lateinit var and @Inject all over the place and just as robust, as the CI ran the test and warned us of any errors.
The amount of lines of code in the whole project decreased by about 3000 lines of code (about 5% of the total). With 3MB less code generated, now the only code generated is from Room, BuildConfig and Navigation Component.
Most importantly, the build time was more than halved: we went from builds of more than 10 minutes to builds of less than 5 minutes.
We have no regrets at all about the effort involved in this migration, at the time it was a significant time investment but the time we have saved day by day has been more than worth it.