[Kotlin] HiltViewModel이란? (with Compose)
💢 Hilt는 Android에서 의존성 주입(Dependency Injection)을 쉽게 구현할 수 있게 해주는 라이브러리이다.
프로젝트 내에서 수동 DI를 사용하는 상용구 코드를 줄여주며, 모든 Android 구성 요소에 컨테이너를 제공하고 컨테이너의 수명 주기를 자동으로 관리한다.
Dependency Injection은 구성요소 간의 의존 관계가 내부가 아닌 외부를 통해 정의되게 하는 디자인 패턴 중 하나이다. 목적은 객체를 생성하고 사용하는 관심사를 분리하는 것.
프로젝트 내에서 MVVM 패턴과 Compose Navigation을 사용했다.
ViewModel을 View에 전달하는 과정에서 Hilt를 사용하지 않으니 MainActivity 혹은 NavigationGraph 내에서 ViewModel을 선언하여 View로 전달해야 했다.
ViewModel이 Repository도 참고하고 있었기 때문에 코드의 가독성이 매우 떨어지고 ViewModel과 NavigationGraph와의 결합도가 높아졌다. 코딩 초보인 내가 보아도 좋지 않은 코드였다. 그래서 찾게 된 것이 Hilt 라이브러리이다.
🎨 사용법
🔶 의존성 추가
project/build.gradle
plugins {
id("com.google.dagger.hilt.android") version "2.44" apply false
}
app/build.gradle
plugins {
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
dependencies {
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Compose Navigation과 함께 사용하려면 추가
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
}
🔶 Application에 @HiltAndroidApp 어노테이션 추가
@HiltAndroidApp
class MyApplication : Application() {
...
}
@HiltAndroidApp은 종속 항목 삽입을 사용할 수 있는 애플리케이션의 기본 클래스가 포함된 Hilt 코드 생성을 트리거한다.
이렇게 하면 앱의 생명주기에 연결된 컨테이너를 추가할 수 있다.
애플리케이션 컨테이너는 앱의 상위 컨테이너이므로 다른 컨테이너는 이 상위 컨테이너에서 제공하는 종속 항목에 액세스할 수 있다.
<application
android:name=".MyApplication"
...
/>
프로젝트에 Application 클래스를 추가할 때는 꼭 manifest에 명시해줘야 한다. 기존 <application> 태그에 name 속성만 추가해주면 된다.
🔶 컴포넌트(Activity, Fragment, Service 등)에 @AndroidEntryPoint 추가
// 컴포넌트에서 의존성을 주입하고 싶을 때
@Inject lateinit var chatRepository: ChatRepository
// ViewModel에서 repository 의존성을 주입하고 싶을 때
class ChatViewModel @Inject constructor(private val chatRepository: ChatRepository): ViewModel() {
...
}
위와 같은 방식으로 컴포넌트 내에서 필요한 의존성을 주입받을 수 있다.
Kotlin에서 생성자에 어노테이션을 달려면 constructor 키워드도 같이 써줘야 한다.
여기서 만약 ChatViewModel을 싱글톤으로 사용하고 싶으면 @Singleton 어노테이션을 사용하면 된다. Hilt가 ChatViewModel의 인스턴스를 생성할 때, 첫 번째 요청 시에만 인스턴스가 생성되고, 이후부터는 이미 만들어놓은 인스턴스를 재사용한다.
🔶 Hilt 모듈 사용
// di/AppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideChatInterface(): ChatInterface {
return ChatRetrofitObj.retrofitService ?: throw IllegalStateException("Retrofit not initialized")
}
@Provides
@Singleton
fun provideChatRepository(chatInterface: ChatInterface): ChatRepository {
return ChatRepository(service)
}
@Provides
@Singleton
fun provideDeviceUID(@ApplicationContext context: Context): String {
return Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
}
}
Hilt 모듈은 @Module과 @InstallIn 주석이 달린 클래스이다.
Hilt 모듈은 인터페이스나 프로젝트에 포함되지 않은 클래스(외부 라이브러리 등)와 같이 생성자가 삽입될 수 없는 유형을 주입하고 싶을 때 사용한다.
만약, 이런 유형의 컴포넌트를 사용하지 않는다면 모듈은 생성하지 않아도 된다. Hilt에서 자동으로 생성자를 만들어 의존성을 주입해주기 때문
ChatRepository는 프로젝트의 일부이지만 ChatInterface를 인자로 가지고 있기 때문에 모듈 내에서 제공해야 한다. 만약 Interface을 인자로 가지고 있지 않았다면 따로 모듈 내에서 생성 코드를 작성하지 않아도 된다.
@Module : Hilt에 모듈임을 알려줌
@InstallIn : Hilt에서 모듈이 어떤 컴포넌트에 설치될지를 지정하는 역할 아래 링크를 통해 hilt의 구성요소를 확인할 수 있다.
@Provides : 함수 본문에서 반환되는 인스턴스를 제공해야 할 때마다 실행됨
@Singleton 어노테이션은 필요에 따라 사용하면 된다.
🎨 사용 시 고려했던 점
아래 링크를 통해 ViewModel을 Screen 단에 직접적으로 주입하는 것은 View의 독립성을 떨어트린다는 글을 보게 되었다.
- https://velog.io/@mraz3068/Jetpack-Compose-Top-20-mistakes-6-10
- 위 블로그 내용 요약
- hiltViewModel을 screen단에서 주면 ui테스트를 방해하고, screen 함수의 독립성을 떨어트린다.
- 그렇기 때문에 state와 event를 인자로 넘겨주는 것이 더 효율적인 방법
그래서 state와 event를 적용해본 결과, state를 사용하는 것은 확실히 함수의 독립성을 높이고 UI 테스트에 유용할 것 같단 생각이 들었다. 그러나 event를 인자로 넘겨줄 땐 오히려 ViewModel의 구조를 복잡하게 만들고 코드의 이해도를 낮춘다는 생각이 들어 event 부분은 과감하게 빼버렸다.
공식문서에서도 screen 단에서 직접적으로 HiltViewModel을 주입하며, state의 사용은 장려하고 있었다.