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.
Context
Our team has 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 ten minutes, we decided to see what we could do about it.
Modularization: A Possible Solution?
We first considered modularizing 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. 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 percent 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 percent 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([email protected])
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 is the dependencies that require external parameters to the tree itself, such as 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 having problems trying to call the real service.
The two rules that head the test class are to avoid problems with the coroutines, 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 rule 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 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 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 few features to finish the migration definitively. Once the migration was finished, we were left with a more idiomatic code and just as robust, as the CI ran the test and warned us of any errors.
The number of lines of code in the whole project decreased by about 3000 lines of code (about five percent 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 five 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.