[Dagger-Hilt] Hilt 는 어떻게 ViewModel 을 생성하고 주입하는가?
동기
DND 9기에 운영진으로 참여했는데, 코드 리뷰 요청이 들어왔습니다. 제가 리뷰해야하는 화면은 두 개였는데, 각 화면은 Screen-Level-Composable 로 구현되어 있고, 두 화면을 포함하는 Activity 가 존재합니다. 플래그를 통해 전환이 이루어지고요.
ActivityViewModel 을 hiltViewModel() 을 사용하여 각 Screen-Level-Composable 의 파라미터로 넘겨주고 있었습니다.
@Composable
fun RecordPhase1Screen(viewModel: RecordViewModel = hiltViewModel())
@Composable
fun RecordPhase2Screen(viewModel: RecordViewModel = hiltViewModel())
각 Composable 에 전달된 RecordViewModel 은 같은 주소값을 가진 하나의 객체입니다.
DI 를 하였기 때문에 그러하겠지만, 이들이 어떻게 생성되고 주입되는지 문득 궁금해졌습니다.
@HiltViewModel
해당 어노테이션은 ViewModel 의 주입을 위해 필수적으로 작성되어야 합니다. HiltViewModel.java 경로를 찾아 들어가보면, 다음과 같은 주석이 작성되어있습니다.
Identifies a androidx.lifecycle.ViewModel for construction injection.
The ViewModel annotated with HiltViewModel will be available for creation by the dagger.hilt.android.lifecycle.HiltViewModelFactory and can be retrieved by default in an Activity or Fragment annotated with dagger.hilt.android.AndroidEntryPoint. The HiltViewModel containing a constructor annotated with javax.inject.Inject will have its dependencies defined in the constructor parameters injected by Dagger's Hilt.
볼드 처리한 부분이 주제의 의문을 해소할 단서가 될 수 있는데요, HiltViewModelFacotry 에 의해 생성되며, @AndroidEntryPoint 어노테이션이 붙은 클래스에서 참조할 수 있다는 것을 말해주고 있습니다.
Hilt 는 외부에서 의존성을 주입해줄 수 있도록 보일러 플레이트 코드가 Generate 되어, 이를 기반으로 DI 를 수행하는 방식으로 작동합니다. 우리는 @HiltViewModel 어노테이션을 붙여 생성한 ViewModel 클래스에 의해 Generate 되는 MainViewModel_HiltModules.java 클래스를 확인할 수 있습니다.
@OriginatingElement(
topLevelClass = MainViewModel.class
)
public final class MainViewModel_HiltModules {
private MainViewModel_HiltModules() {
}
@Module
@InstallIn(ViewModelComponent.class)
public abstract static class BindsModule {
private BindsModule() {
}
@Binds
@IntoMap
@StringKey("com.team.bpm.presentation.ui.main.MainViewModel")
@HiltViewModelMap
public abstract ViewModel binds(MainViewModel vm);
}
@Module
@InstallIn(ActivityRetainedComponent.class)
public static final class KeyModule {
private KeyModule() {
}
@Provides
@IntoSet
@HiltViewModelMap.KeySet
public static String provide() {
return "com.team.bpm.presentation.ui.main.MainViewModel";
}
}
}
위 코드에서 우리는 두 개의 DI Module 이 생성됨을 파악할 수 있습니다. 각각 @Binds 어노테이션과 @Provides 어노테이션이 붙습니다. 이 둘에 대해서는 다음 포스팅에 설명되어 있으니, 모르신다면 참고하셔도 좋겠습니다.
[Dagger-Hilt] Dagger-Hilt 의 대표적인 Annotations
Dependency Injection 의존성 주입(Dependency Injection, DI)은 안드로이드 앱 아키텍처 및 클린 아키텍처 적용시 필수적으로 사용되어야 하는 기술입니다. 대부분의 아키텍처가 테스트 용이성에 포커스를
blothhundr.tistory.com
ViewModel 이 어떻게 생성되고 주입되는 지 파악하기 위해, 두 Module 을 자세히 살펴 봅니다.
첫 번째 Module
@Module
@InstallIn(ViewModelComponent.class)
public abstract static class BindsModule {
private BindsModule() {
}
@Binds
@IntoMap
@StringKey("com.team.bpm.presentation.ui.main.MainViewModel")
@HiltViewModelMap
public abstract ViewModel binds(MainViewModel vm);
}
@Binds 어노테이션이 붙은 함수는 앞서 멘션해 둔 포스팅의 설명과 같이, 인터페이스 또는 추상 클래스의 상속체를 바인딩합니다. 우리가 구현하는 ViewModel 은 일반적인 클래스이지만, androidx.lifecycle.ViewModel.java 는 다음과 같이, 추상 클래스로 구현 되어 있습니다.
public abstract class ViewModel {
...
}
해당 Module 은 ViewModelComponent.class 로 설정되어 있습니다. 해당 어노테이션은 ViewModel 의 생명주기에 맞도록 객체를 DI 할 때 작성하는 어노테이션이므로, 이는 곧 ViewModel 을 직접적으로 Activity 에 주입하는 Module 은 아님을 알 수 있습니다. 이후 설명할 Map MultiBinding 에 사용되는 모듈입니다.
두 번째 Module
@Module
@InstallIn(ActivityRetainedComponent.class)
public static final class KeyModule {
private KeyModule() {
}
@Provides
@IntoSet
@HiltViewModelMap.KeySet
public static String provide() {
return "com.team.bpm.presentation.ui.main.MainViewModel";
}
}
이전 Module 과는 다르게, ActivityRetainedComponent.class 로 설정되어 있습니다. Activity 생명주기가 유지되는 동안 객체도 유지됩니다. 해당 어노테이션에 대한 설명은 다음 포스팅에 자세히 설명해두었으니, 참고하시면 되겠습니다.
[Dagger-Hilt] ActivityScope 와 ActivityRetainedScope 의 차이
Hilt Hilt 는 현재 가장 주목 받는 DI (Dependency Injection) 라이브러리입니다. 안드로이드 제트팩 라이브러리 이기도 하죠. Hilt 에는 수많은 스코프 가 있습니다. 스코프의 존재 이유는 외부에서 생성되
blothhundr.tistory.com
앞선 Module 에서 설명했던 것과 같이, 우리가 구현하는 ViewModel 은 일반적인 class 이므로, @Provides 키워드가 붙어 직접적으로 ViewModel 을 DI 하는 Module 임을 알 수 있습니다. 이에 대한 설명은 불필요한 것으로 보이기 때문에, 넘어가도록 합니다.
위 두 모듈만으로 ViewModel 의 생성이 끝나느냐? 아닙니다. 우리는 주입에 대한 사실의 검증만을 얻었을 뿐입니다. 어떻게 생성이 되고, 어떻게 주입되는지에 대해서는 아직 알 수 없습니다.
임의의 Activity 를 생성하고 ViewModel 을 구현하여 DI 하게 될 경우, 위와 같이 다섯 개의 보일러 플레이트 코드가 Generate 됩니다. 하나 씩 가볍게 살펴봅니다.
1. Hilt_MainActivity
@AndroidEntryPoint 어노테이션을 붙여 구현한 클래스의 경우 자동으로 생성됩니다. 제 경우 100줄 미만의 코드가 Generate 되었는데, 주입을 담당하는 부분이 해당 클래스에 작성되어 있습니다. Inject() 메서드를 살펴봅니다.
protected void inject() {
if (!injected) {
injected = true;
((MainActivity_GeneratedInjector) this.generatedComponent()).injectMainActivity(UnsafeCasts.<MainActivity>unsafeCast(this));
}
}
위와 같이 정의되어 있는데, generatedComponent() 를 통해 객체가 반환되며, 위 다섯 개의 Generated 된 코드 중 MainActivity_GeneratedInjector 를 활용합니다. 해당 인터페이스에는 위 스니펫에서 실행하고 있는 injectMainActivity() 밖에 없습니다. 해당 객체는 ActivityComponentManager.java 에서 createComponent() 메서드를 통해 생성됩니다. 즉, 이 코드와 MainActivity_generatedInjector 가 실제 주입을 담당하는 코드입니다.
2. MainViewModel_Factory
Activity 에 ViewModel 을 주입하는 부분은 찾았으니, ViewModel 에 필요한 객체들을 주입해주는 부분도 찾아봅시다. 이번 클래스는 비교적 짧은데요. MainViewModel 에 주입된 객체들을 할당해주는 작업을 수행합니다. 눈에 띄는 것은 Factory<T> 와 Provider<T> 입니다. 일단 클래스 선언을 먼저 살펴 보아야겠죠. 다음과 같습니다.
@ScopeMetadata
@DaggerGenerated
public final class MainViewModel_Factory implements Factory<MainViewModel> {
...
}
Factory<MainViewModel> 을 구현한 구현체임을 알 수 있습니다. Factory<T> 는 Provider<T> 를 상속받는 클래스이며, 작성된 주석에 따르면 범위가 지정되어 있지 않은 Provider<T> 라고 합니다. Provider<T>.get() 메서드를 통해 생성자에 삽입 및 그에 대한 메서드를 제공하는 바인딩 과정의 실행이 보장됨을 알 수 있습니다.
private final Provider<GetMainTabIndexUseCase> getMainTabIndexUseCaseProvider;
private final Provider<CoroutineDispatcher> mainDispatcherProvider;
private final Provider<CoroutineDispatcher> ioDispatcherProvider;
public MainViewModel_Factory(Provider<GetMainTabIndexUseCase> getMainTabIndexUseCaseProvider,
Provider<CoroutineDispatcher> mainDispatcherProvider,
Provider<CoroutineDispatcher> ioDispatcherProvider) {
this.getMainTabIndexUseCaseProvider = getMainTabIndexUseCaseProvider;
this.mainDispatcherProvider = mainDispatcherProvider;
this.ioDispatcherProvider = ioDispatcherProvider;
}
선언된 세 개의 Provider<T> 는 제가 MainViewModel 의 생성자 파라미터에 선언해 둔 주입 요구 객체들입니다. 스니펫의 함수는 해당 클래스의 생성자이며, 각 Provider<T> 객체를 생성자 파라미터로 전달받아 각 변수에 할당해주고 있음을 확인할 수 있습니다. 바로 아래 MainViewModel_Factory.get() 메서드가 있으니 확인해보면 어떻게 MainViewModel 의 인스턴스가 생성되는 지 알 수 있겠지요.
@Override
public MainViewModel get() {
return newInstance(getMainTabIndexUseCaseProvider.get(), mainDispatcherProvider.get(), ioDispatcherProvider.get());
}
MainViewModel_Factory.get() 메서드는 MainViewModel 에 생성자 파라미터가 요구하고 있는 객체들을 Provider<T>.get() 메서드를 통해 얻어 와 새로운 인스턴스를 생성하여 반환함을 알 수 있습니다. 즉, MainViewModel 의 인스턴스를 직접 생성하는 부분입니다. 해당 메서드를 통하여 MainViewModel 의 인스턴스를 가져오는 방식입니다.
3. MainViewModel_HiltModules_KeyModule_ProvideFactory.java
클래스 명이 무지막지하게 깁니다. 그에 비해 내부 코드는 굉장히 짧은데, 다음과 같습니다.
@ScopeMetadata
@QualifierMetadata("dagger.hilt.android.internal.lifecycle.HiltViewModelMap.KeySet")
@DaggerGenerated
@SuppressWarnings({
"unchecked",
"rawtypes"
})
public final class MainViewModel_HiltModules_KeyModule_ProvideFactory implements Factory<String> {
@Override
public String get() {
return provide();
}
public static MainViewModel_HiltModules_KeyModule_ProvideFactory create() {
return InstanceHolder.INSTANCE;
}
public static String provide() {
return Preconditions.checkNotNullFromProvides(MainViewModel_HiltModules.KeyModule.provide());
}
private static final class InstanceHolder {
private static final MainViewModel_HiltModules_KeyModule_ProvideFactory INSTANCE = new MainViewModel_HiltModules_KeyModule_ProvideFactory();
}
}
위에서 살펴보았던 Factory<T> 를 구현한 구현체인데, 우리가 봐야 할 부분은 get() 메서드일 것입니다. 여지껏 살펴 본 바로는, Factory<T> 또는 Provider<T> 의 get() 메서드를 통해 대부분의 객체를 획득하고 있기 때문이지요. 해당 함수는 provide() 메서드를 통해 반환받은 String 을 다시금 반환하는 역할을 수행하며, provide() 메서드는 MainViewModel_HiltModules.java 의 @Provides Module 메서드를 실행하여 주입하고자 하는 클래스 (MainViewModel) 의 경로를 String 형태로 받아와 null-check 를 수행하는 부분이라 사료됩니다.
해당 파일은 Map MultiBinding 을 위한 Dagger 팩토리 클래스입니다. 특정 키를 사용하여 여러 구현체를 매핑하여야 하는 경우에 사용됩니다.
Map MultiBinding 이란, 여러 구현체를 하나의 Map 으로 주입받을 수 있도록 합니다. 이렇게 하면 각 키에 해당하는 값 (구현체)을 런타임에 쉽게 찾을 수 있도록 합니다. @Binds 를 활용하여 하나의 인터페이스 또는 추상 클래스에 대해 여러 구현체를 구분하여 주입할 때 사용할 수 있지요.
ViewModel 은 abstract class 로 선언되어 있기 때문에, Map MultiBinding 이 사용되는 것이라 볼 수 있겠습니다. 즉, Map MultiBinding 을 통해 Activity 가 생성될 때 동적으로 ViewModel 이 생성되고 주입받을 수 있는 것입니다.
모든 과정을 짧게 정리하면 다음과 같습니다.
- MainViewModel_Factory 에 의해 ViewModel 에 필요한 객체가 주입되고 ViewModel 의 인스턴스가 생성됩니다.
- MainViewModel_HiltModules_KeyModule_ProvideFactory 에 의해 null-check 가 이루어지고 Map MultiBinding 을 통해 ViewModel Map 에 적절한 참조가 보관됩니다.
- Hilt_MainActivity 는 MainViewModel_HiltModules 에 선언된 Module 들을 활용하여 적절한 위치에 binds() 된 ViewModel 을 주입하게 됩니다.
Activity 가 상속받는 ComponentActivity 가 ViewModelStoreOwner 를 구현하고 있으며, 위와 같이 생성된 ViewModel 은 Activity 가 갖고 있는 ViewModelStore 에 보관되고, 참조가 발생하면 그 때 꺼내어 반환해 주는 방식으로 우리는 ViewModel 을 사용하고 있습니다. 이는 꽤 잘 알려진 사실이고, 저는 그 이전의 단계에 대한 궁금증을 가지게 되었습니다.
이번 과정을 통해 알게 된 것은, Provider<T>, Factory<T> 등에 의해 여러 객체가 생성되고 획득할 수 있다는 사실과, Hilt 가 의존성 주입을 위해 생성하는 코드들의 역할을 얕게나마 파악할 수 있게 되었습니다.
이것이 어떠한 문제 해결에 도움이 될 지는 사실 잘 모르겠습니다. (늘 Deep-Dive 를 하며 드는 의문이긴 합니다...) 하지만 언젠간 이 지식이 필요한 순간이 오리라 고대하며 또 다시 Deep-Dive 하고 있겠지요?