Android: Migrating Hilt to Koin

Pedro Alvarez
Stackademic
Published in
6 min readApr 11, 2024

--

Image from https://www.wallpaperflare.com/search?wallpaper=coins

In this article we will continue our PokeAPI project by upgrading our Dependency Injection(DI) framework into a much simpler approach. We shall replace Hilt DI by Koin. Hilt plays a great role by injecting dependencies into each component via code generation and annotations. However, as the developer loses some of the control due too much boilerplate and code generation, resolving dependencies becomes a very confusing process. Koin is built on top of Kotlin DSL(Domain-Specific-Language) and shows up as a much simpler framework to deal with. Also, different from Dagger/Hilt, Koin resolves dependencies at run time, instead of build time. Now, let's start changing our project by changing the frameworks

Koin setup

Let's start by configuring our Gradle file. First, remove all the Hilt dependencies and replace them by these ones and sync. We are adding the Koin dependencies.

 implementation("io.insert-koin:koin-androidx-compose:3.5.3")
implementation("io.insert-koin:koin-android:3.5.3")
implementation("io.insert-koin:koin-androidx-navigation:3.5.3")

Modules

I will not deeply explain the concept of module since it was already covered in the Hilt article, but for short, it's the container that is responsible for providing an implementation for each required dependency type in a scope. In Hilt we had an object that provided each concrete dependency via annotated functions with Provides . For interface injection, we relied on Binding to map each implementation to its abstraction:

@Module
@InstallIn(SingletonComponent::class)
object PokemonListModule {
@Provides
fun provideRetrofitClient(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://pokeapi.co/api/v2/") // Specify your base URL
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient())// Add converter factory for Gson
.build()
}

@Provides
fun provideAPIService(retrofitClient: Retrofit): APIService {
return retrofitClient.create(APIService::class.java)
}

@Provides
fun providePokemonListRepository(apiService: APIService): PokemonListRepository {
return PokemonListRepository(apiService)
}

@Provides
fun providePokemonListDataSourceFactory(
repository: PokemonListRepositoryInterface
): PokemonListDataSourceFactoryInterface {
return PokemonListDataSourceFactory(repository)
}
}

@Module
@InstallIn(ViewModelComponent::class)
interface PokemonListModuleInterface {
@Binds
abstract fun bindPokemonListRepository(impl: PokemonListRepository): PokemonListRepositoryInterface
@Binds
abstract fun bindPokemonListDataSourceFactory(impl: PokemonListDataSourceFactory): PokemonListDataSourceFactoryInterface
}

For me at first, it was very confusing to understand the annotations in each function were responsible to generate code for resolving each dependency when they were injected in a scope. It's a lot of memorization about what the process does, and not to in fact understand how that works, which is bad in my opinion and is one of my biggest complains of Android. Also, when we rely too much on code generation, our project builds become much longer slower than usual.

Take a look on how Koin handles that to us:

val appModule = module {
single<APIService> { Retrofit.Builder()
.baseUrl("https://pokeapi.co/api/v2/") // Specify your base URL
.addConverterFactory(GsonConverterFactory.create())
// Add HTTP Interceptor
.client(
OkHttpClient()
.newBuilder()
.apply { addInterceptor(get()) }
.build()
)// Add converter factory for Gson
.build()
.create(APIService::class.java)
}

single<Interceptor> { CustomInterceptor() }
single<PokemonListRepositoryInterface> { PokemonListRepository(get()) }
single<PokemonListPagingSource> { PokemonListPagingSource(get()) }
single<PokemonListDataSourceFactoryInterface> { PokemonListDataSourceFactory(get()) }
viewModel { PokemonListViewModel(get()) }

single<PokemonDetailsRepositoryInterface> { PokemonDetailsRepository(get()) }
viewModel { PokemonDetailsViewModel(get()) }
}

This is how it works:

  • The module function is responsible to hold all of the dependencies providing, just like the Module annotated class in Hilt/Dagger.
  • The single function declares a Singleton provided class, which means that instance will exist during the entire application lifecycle and will be recycled whenever it's needed across the scenes. But we should be very careful, because as that is tied to the app's lifecycle, that practice shall lead us to memory leaks and crashes if not will fit.
  • The factory function(although not in the code) establishes that a new instance of that type will be provided any time it's required.
  • When we specify a generic type for the single function, we are doing the same that Binding does in Hilt: it says that the implementation should be providing when injecting an interface type, like PokemonListRepository and PokemonListRepositoryInterface.
  • The viewModel function provides a ViewModel class that will be lifecycle-aware regarding the Activity, Fragment or Composable View it belongs to. Use it for any injected View Model in your scenes.
  • The get function that we put in place of the init dependencies is used to actually resolve that required dependency in the given context. It is the same as annotating the required dependency as Inject in Hilt

Said that, we have already described what is each class in our previous article.

Koin initialization

Now that we defined our dependency container(module), we need to attach this module to our application level, which is different from Hilt, when we didn't need to do that:

class PokeAPIApplication: Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@PokeAPIApplication)
modules(appModule)
}
}
}

We rely on the onCreate function to do any required set up before starting our application. In the startKoin function we do any set up to attach Koin to our project. Inside, androidContext is used to define the application type as the context and provide access to any Android specific resources. The modules function specifies which is the container the application will depend to do the DI. Pretty simple, huh?

ViewModel Injection

Now we are gonna resolve our ViewModel dependencies in both Composable screens. As the ViewModel is the only layer we should "manually" resolve in our screens, we should rely on the get function(explained above) to retrieve:

@Composable
fun PokemonListScreen(
navController: NavController,
viewModel: PokemonListViewModel = get()
) {
// Implementation
}

@Composable
fun PokemonDetailsScreen(
navController: NavController,
name: String,
viewModel: PokemonDetailsViewModel = get()
) {
/// Implementation
}

Now that we declared our module, initialized it, and also injected the ViewModel in our Composables, the application automatically has the ability to resolve the dependencies once they are declared in the classes headers. That's the good part: we don't need a Hilt Inject annotation anymore, which could cause so many building problems, any dependency will be automatically resolved by the module. Now you can remove the Inject annotation from each constructor and also remove the AndroidEntryPoint from our main Activity.

Declaration access

The application now works again with Koin, however, the module automatically resolves the dependencies that come in the constructor. We still should provide a resolve delegate for when we instantiate a complex dependency inside a class. Take a look at this example:

class PokemonListViewModel: ViewModel() {
private val dataSourceFactory: PokemonListDataSourceFactoryInterface by inject()

// Implementation
}

We use the inject delegation to rely the type instantiation to Koin, which implements a get and setter. Check here to learn more about Kotlin delegations.

Extra: Scopes and Qualifiers

This is one of my favorite parts of Koin theory. Let's say we have a class MyClass that receives two primitive parameters p1 and p2:

data class MyClass(
val p1: String,
val p2: String
)

Now we want to provide this dependency to multiple scopes, may they be an Activity/Fragment, a ViewModel or even a Service. What should we pass by parameters? The answer is: depends of the scope:

val appModule = module {
scope(named("implementation 1")) { MyClass("Hello", "World")}
scope(named("implementation 2")) { MyClass("Good", "Bye")}
}

We are presenting two different implementations, each one mapped to a qualifier. The qualifier is specified by that named function inside the scope, and each scope will be accessed in different parts of the app, the correct scope that is attached is the one depending on the qualifier name the code line calls:

class MyActivity1: AppCompatActivity() {
val myClass1: MyClass = get(named("implementation 1"))
}

For the first activity, we are injecting the class dependency by attaching to the implementation 1, which means the values of the instance for this scope will be "Hello" and "World".

class MyActivity2: AppCompatActivity() {
val myClass2: MyClass = get(named("implementation 2"))
}

For this other case, we are relying on the second implementation and we will have the values "Good" and "Bye". That is a brilliant solution to customize your dependency instantiation depending on different scopes.

Conclusion

This article provided a migration example with some concepts between Hilt and Koin and explained the biggest differences between both:

  • Hilt resolves dependencies at compile time while Koin does at run time
  • Hilt relies on code generation and reflection, while Koin doesn't
  • Koin is much simpler to understand since it doesn't rely on annotations to each dependency to be resolved
  • Koin is built on top of DSL, which makes the process very easier to understand.

We also described a way to customize different resolving processes for different scopes in your application through the qualifiers.

I hope it clarified to you a very simple process of Dependency Injection and giving support to huge Android applications and classes became even easier ;)

Stackademic 🎓

Thank you for reading until the end. Before you go:

--

--

iOS | Android Developer - WWDC19 scholarship winner- Blockchain enthusiast