Kotlin Dependency Injection
Introduction
Dependency Injection (DI) is a design pattern that helps you build more maintainable and testable Android applications. In essence, it's a technique where an object receives its dependencies from external sources rather than creating them itself. This pattern is fundamental in modern Android development using Kotlin.
In this tutorial, we'll explore:
- What dependency injection is and why it matters
- Different types of dependency injection in Kotlin
- Popular dependency injection frameworks for Android
- How to implement dependency injection in a real Kotlin Android application
What is Dependency Injection?
Imagine you're building a car. A car needs many components: an engine, wheels, a steering wheel, etc. Rather than having the car build these components itself, you would "inject" them into the car during assembly. This is essentially what dependency injection does for your code.
Without Dependency Injection
class Car {
// Car creates its own engine
private val engine = Engine()
fun start() {
engine.start()
}
}
class Engine {
fun start() {
println("Engine started")
}
}
// Usage
fun main() {
val car = Car()
car.start()
}
With Dependency Injection
class Car(private val engine: Engine) {
// Engine is provided from outside (injected)
fun start() {
engine.start()
}
}
class Engine {
fun start() {
println("Engine started")
}
}
// Usage
fun main() {
val engine = Engine()
val car = Car(engine)
car.start()
}
Output:
Engine started
Benefits of Dependency Injection
- Testability: You can easily mock dependencies for testing.
- Reusability: Components are more reusable as they're decoupled.
- Maintainability: Cleaner code that's easier to understand and maintain.
- Scalability: Makes it easier to manage large applications.
- Parallel Development: Teams can work on different components simultaneously.
Types of Dependency Injection in Kotlin
1. Constructor Injection
Dependencies are provided through a class constructor:
class UserRepository(
private val apiService: ApiService,
private val database: UserDatabase
)
2. Property Injection
Dependencies are injected directly into properties:
class UserViewModel {
lateinit var userRepository: UserRepository
fun loadUser(userId: String) {
userRepository.getUser(userId)
}
}
// Usage
val viewModel = UserViewModel()
viewModel.userRepository = UserRepository(ApiService(), UserDatabase())
3. Method Injection
Dependencies are provided via methods:
class UserAnalytics {
fun trackUserBehavior(user: User, logger: Logger) {
logger.log("User ${user.id} performed an action")
}
}
Popular Dependency Injection Frameworks
Dagger 2
Dagger is a compile-time dependency injection framework that uses annotation processing.
Basic setup:
// Define a module
@Module
class NetworkModule {
@Provides
fun provideApiService(): ApiService {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(ApiService::class.java)
}
}
// Define a component
@Component(modules = [NetworkModule::class])
interface AppComponent {
fun inject(activity: MainActivity)
}
// In your Application class
class MyApplication : Application() {
val appComponent: AppComponent = DaggerAppComponent.create()
}
// In your Activity
class MainActivity : AppCompatActivity() {
@Inject
lateinit var apiService: ApiService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(application as MyApplication).appComponent.inject(this)
}
}
Hilt
Hilt is built on top of Dagger and specifically designed for Android apps:
// Application setup
@HiltAndroidApp
class MyApplication : Application()
// Module definition
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideApiService(): ApiService {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(ApiService::class.java)
}
}
// Inject into an Activity
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var apiService: ApiService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// apiService is automatically injected
}
}
Koin
Koin is a lightweight dependency injection framework for Kotlin:
// Define your module
val appModule = module {
single { Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(ApiService::class.java)
}
factory { UserRepository(get()) }
}
// Start Koin in your Application
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(appModule)
}
}
}
// Inject in your Activity
class MainActivity : AppCompatActivity() {
private val apiService: ApiService by inject()
private val userRepository: UserRepository by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Use injected dependencies
}
}
Practical Example: Building a Weather App
Let's build a simple weather app using dependency injection with Koin, as it's one of the easiest to set up for beginners.
Step 1: Set up Gradle dependencies
// In your app's build.gradle
dependencies {
// Koin for Android
implementation "io.insert-koin:koin-android:3.2.0"
// Retrofit for network requests
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
}
Step 2: Create the API service
interface WeatherApiService {
@GET("weather")
suspend fun getWeather(@Query("city") city: String): WeatherResponse
}
data class WeatherResponse(
val temperature: Double,
val description: String,
val city: String
)
Step 3: Create the Repository
interface WeatherRepository {
suspend fun getWeather(city: String): WeatherResponse
}
class WeatherRepositoryImpl(private val apiService: WeatherApiService) : WeatherRepository {
override suspend fun getWeather(city: String): WeatherResponse {
return apiService.getWeather(city)
}
}
Step 4: Create the ViewModel
class WeatherViewModel(private val weatherRepository: WeatherRepository) : ViewModel() {
private val _weatherState = MutableLiveData<WeatherUiState>()
val weatherState: LiveData<WeatherUiState> = _weatherState
fun fetchWeather(city: String) {
viewModelScope.launch {
_weatherState.value = WeatherUiState.Loading
try {
val response = weatherRepository.getWeather(city)
_weatherState.value = WeatherUiState.Success(response)
} catch (e: Exception) {
_weatherState.value = WeatherUiState.Error("Failed to fetch weather: ${e.message}")
}
}
}
}
sealed class WeatherUiState {
object Loading : WeatherUiState()
data class Success(val data: WeatherResponse) : WeatherUiState()
data class Error(val message: String) : WeatherUiState()
}
Step 5: Set up Koin
// Define your module
val appModule = module {
// Network
single {
Retrofit.Builder()
.baseUrl("https://api.weatherexample.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(WeatherApiService::class.java)
}
// Repository
single<WeatherRepository> { WeatherRepositoryImpl(get()) }
// ViewModel
viewModel { WeatherViewModel(get()) }
}
// Application class
class WeatherApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@WeatherApp)
modules(appModule)
}
}
}
Step 6: Use in Activity/Fragment
@AndroidEntryPoint
class WeatherFragment : Fragment() {
private val viewModel: WeatherViewModel by viewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.searchButton.setOnClickListener {
val city = binding.cityEditText.text.toString()
viewModel.fetchWeather(city)
}
viewModel.weatherState.observe(viewLifecycleOwner) { state ->
when (state) {
is WeatherUiState.Loading -> {
binding.progressBar.isVisible = true
binding.weatherInfo.isVisible = false
binding.errorMessage.isVisible = false
}
is WeatherUiState.Success -> {
binding.progressBar.isVisible = false
binding.weatherInfo.isVisible = true
binding.errorMessage.isVisible = false
binding.temperature.text = "${state.data.temperature}°C"
binding.description.text = state.data.description
binding.city.text = state.data.city
}
is WeatherUiState.Error -> {
binding.progressBar.isVisible = false
binding.weatherInfo.isVisible = false
binding.errorMessage.isVisible = true
binding.errorMessage.text = state.message
}
}
}
}
}
When to Use Which Framework?
- Dagger: Good for large teams and large projects where compile-time safety is critical
- Hilt: Simplifies Dagger specifically for Android - recommended for most Android projects
- Koin: Great for small to medium projects and when learning DI concepts
- Manual DI: For very small projects or when introducing the concept
Summary
Dependency injection is a powerful design pattern that helps you create more maintainable, testable, and scalable Android applications with Kotlin. By providing dependencies from outside rather than creating them within a class, you decouple your components and make them more reusable and easier to test.
We've covered:
- The basic concept of dependency injection
- Different types of DI (constructor, property, method)
- Popular frameworks (Dagger, Hilt, Koin)
- A practical example building a weather app with Koin
As you develop your Android applications, consider implementing dependency injection from the start — it may seem like extra work initially, but will save you significant time in the long run, especially as your application grows in complexity.
Additional Resources
- Official Hilt Documentation
- Koin Official Documentation
- Dagger Documentation
- Android Developers: Dependency Injection
Exercises
- Basic Exercise: Convert an existing class that creates its own dependencies to use constructor injection.
- Intermediate Exercise: Implement a simple note-taking app using Koin for dependency injection.
- Advanced Exercise: Compare implementations of the same app using both Hilt and Koin. Note the differences in code structure and complexity.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)