뷰모델등 사용하다보면 파라미터로 레포지토리, 유즈케이스등등 로직을 수행할 다양한 클래스들을 받게 되는데 일반적으로 액티비티나 프래그먼트 같은 뷰에서 뷰모델을 선언할때 바로 파라미터에 넣어주면 안된다.
class SearchFragment : Fragment() {
...
private val viewModel by lazy {
SearchViewModel(
repository,
usecase
)
}
이렇게 선언하게 되면 이렇게 하면 뷰와 뷰모델, 그리고 이들 종속성 사이의 결합도가 높아지게 되서 에러가 발생할때 파악하거나 테스트하기가 어려워 지는 등 유지보수 관리 차원에서 문제가 생긴다.
그렇기 때문에 뷰모델 팩토리를 이용해 이를 해결할수 있다.
class SearchViewModelFactory(
private val repository: ApiRepository,
private val bookmarkRepository: BookmarkRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SearchViewModel::class.java)) {
return SearchViewModel(repository, bookmarkRepository) as T
} else throw IllegalArgumentException("Not found ViewModel class.")
}
}
....
private val viewModel: SearchViewModel by viewModels {
SearchViewModelFactory(
ApiRepositoryImpl(),
BookmarkRepositoryImpl(requireContext().applicationContext)
)
}
뷰모델과 의존성을 분리시키고 외부에서 주입하는 방식으로 팩토리를 이용해서 결합도를 낮출 수 가 있다.
하지만 이도 단점이 있는데, 뷰모델을 생성할때마다 팩토리를 만들어 줘야하며 종속성을 주입할때마다 수시로 바꿔줘야하는 번거로움이 있다.
또한 테스트할때도 어려움이 있는데
안드로이드에선 테스트를 할때 Test Double을 이용한다.
Test Double이란?
테스트 더블은 영화를 촬영할 때 배우를 대신하여 위험한 역할을 하는 스턴트 더블(Stunt Double)이라는 용어에서 유래된 단어이다.
자동화된 테스트를 작성할 때, 여러 객체들이 의존성을 갖는 경우 테스트 하기 까다로운 경우가 있다.Test Double은 이러한 경우에 유용한데, 테스트를 위해 실제 객체를 대신하는 객체를 말한다.
Test Double을 통해 실제로 실행하지 않고 무거운 메소드가 호출되도록 하는 등 시스템의 특정 세부 정보를 검증할 수 있다.
이때 주로 Mock 과 Fake 객체를 이용하는데 테스트 할때마다 종속성을 관리해 줘야하니 번거롭고 테스트코드가 복잡해지게 된다.
이를 해결 하기 위해 DI(Dependency Injection)를 사용한다.
DI(Dependency Injection)란 종속성 주입 프레임 워크를 의미한다.
앞서 말한 하나의 객체가 다른 객체에 의존할 때 이를 외부에서 제공하는 방식인데
코드의 결합도를 낮추고, 유지 관리성을 향상시키며, 테스트 용이성을 높이는게 목적이다.
뷰모델 팩토리랑 비교하면 장점이 뚜렷한데, 일단 뷰모델을 선언할때마다 만들어줘야하는 팩토리에 비해 명시적으로 선언할 필요 없이, 단순한 어노테이션으로 의존성을 주입해 코드를 더 간결하게 짤 수 있다.
뿐만아니라 팩토리를 이용해 외부에서 주입한다해도 Factory가 ViewModel의 구체적인 구현과 의존성에 대해 알아야 하므로, 일정 수준의 결합도가 발생하게 되는데
프레임워크를 사용하게 되면 ViewModel은 자신이 필요로 하는 의존성의 구체적인 구현에 대해 알 필요가 없으므로, 결합도가 낮아진다.
이번에 DI를 이용해서 저번에 만들어 둔 이미지 검색 앱을 DI를 써서 리팩토링 해볼 예정이다.
DI하면 크게 3가지 Dagger, Hilt, Koin이 있는데 이번엔 Google에서 공식적으로 지원하는 Android 전용 종속성 주입 라이브러리 Hilt를 사용해 볼 것이다.
plugins {
...
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
id("com.google.dagger.hilt.android") version "2.50" apply false
}
프로젝트 gradle에 hilt를 작성해준다
kapt혹은 ksp를 이용하는데, kapt는 java 바이트코드로 변환하고 ksp는 코틀린코드를 직접 처리하는 차이가 있다.
일단 난 ksp를 사용할 예정이다
hilt의 최신 버전은 https://mvnrepository.com/artifact/com.google.dagger/hilt-android 에서 확인 할 수 있다.
plugins {
...
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
dependencies {
...
//hilt
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-android-compiler:2.50")
}
모듈 레벨의 gradle 에서 종속성을 추가해준다.
@HiltAndroidApp
class MyApplication : Application()
이제 애플리케이션 레벨에서 @HiltAndroidApp를 추가해준다
@HiltAndroidApp 어노테이션은 Hilt가 애플리케이션 수준에서 종속성 주입을 시작할수 있게 해주는데,
이를 붙이면 Hilt는 ApplicationComponent라는 특별한 컴포넌트를 생성하게 된다.
이 컴포넌트는 앱의 시작부터 종료까지 존재하게 되는데, 이를 기반은으로 다른 컴포넌트들을 생성 할 수 있다.
따라서, @HiltAndroidApp 어노테이션은 Hilt가 애플리케이션의 종속성 그래프를 생성하고 관리하는 SingletonComponent를 생성하는 트리거 역할을 하기 때문에 필수적이라고 할 수 있다.
💡 Application을 선언했으면 꼭 Manifest에 이름을 적어주자
<application android:name=".MyApplication" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules"
class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null
private val binding get() = _binding!!
private var _adapter: SearchAdapter? = null
private val viewModel: SearchViewModel by viewModels {
SearchViewModelFactory(
ApiRepositoryImpl(),
BookmarkRepositoryImpl(requireContext().applicationContext)
)
}
기존에 팩토리로 구현한 프래그먼트와 뷰모델이다.
@AndroidEntryPoint
class SearchFragment : Fragment() {
...
private val viewModel: SearchViewModel by viewModels()
뷰모델에 종속성을 주입하기 위해선 종속성을 주입받는 뷰모델을 소유하는 컴포넌트에 @AndroidEntryPoint를 붙여 줘야 하는데, 이를 통해 해당 프래그먼트 내부 뷰모델에도 종속성을 주입할 수 있기때문이다
@AndroidEntryPoint를 사용해 SearchFragment가 종속성 주입을 받을 수 있는 진입점으로 정의 해준다.
이렇게 어노테이션을 붙인 클래스는 자동으로 Hilt의 종속성 그래프의 일부가 되며, @Inject 어노테이션이 붙은 필드, 메소드, 생성자에 종속성을 주입받을 수 있게 된다.
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: ApiRepository,
private val bookmarkRepository: BookmarkRepository
) : ViewModel() {
내부의 뷰모델에서는 @HiltViewModel를 사용해 종속성을 주입받게 한다.
컴포넌트와 달리 @HiltViewModel를 사용하는 이유는 ViewModel은 생명 주기가 Activity나 Fragment와 다르기 때문에, 종속성을 주입받는 방식도 다르다.
그렇기 때문에 @HiltViewModel로 종속성을 주입받게 해준다
그런뒤 @Inject constructor()를 붙여 해당 어노테이션이 붙은 생성자를 주입한다.
이제 레포지토리들을 설정해주자
@Module
@InstallIn(ViewModelComponent::class)
object SearchModule {
@Provides
fun provideApiRepository(): ApiRepository = ApiRepositoryImpl()
@Provides
fun provideBookmarkRepository(@ApplicationContext context: Context): BookmarkRepository = BookmarkRepositoryImpl(context)
}
@Module 어노테이션을 통해 Hilt에 이 클래스가 종속성을 제공하는 모듈임을 알려준다.
모듈을 설정한 이유는 레포지토리에 직접 들어가서 따로따로 설정해주는 번거로움 없이 한곳에 모아서 종속성을 관리 할 수 있어서 사용한다.
이렇게 구현하면 레포지토리에선 직접 종속성을 생성하거나 관리할 필요 없이 모듈에서 제공 받아 사용 할 수 있기때문에 관리에 용이 해진다.
@InstallIn(ViewModelComponent::class)를 선언해서 뷰모델 수명주기에 맞췄는데,
현재 내 앱의 경우 search 프래그먼트와 mybox프래그먼트로 나뉘어 있으며
ApiRepository의 경우 search 프래그먼트 하나에서만 사용하고
BookmarkRepository는 양쪽 전부 사용하고 있다.
BookmarkRepository처럼 양쪽에서 사용하고 있을 경우 ActivityComponent를 사용해 수명주기를 액티비티 레벨에 맞출수도 있는데
BookmarkRepository는 sharedPreference로 구성되어 생성비용이 크지 않다고 판단해 관리하기 용이하게 둘을 묶어서 넣었다.
@Provides는 @Module이 붙은 클래스 내부에 위치하는데 여기서 필요한 종속성 인스턴스를 생성하고 반환하는 역할을 한다.
@Module안에선 @Provides혹은 @Binds를 이용해서 종속성을 제공 할 수 있는데
@Provides같은 경우 직접 객체를 생성하고 초기화 하는 코드를 작성할 수 있어
Context가 필요한 BookmarkRepository때문에 @Provides를 사용했다.
@ApplicationContext를 이용해 애플리케이션 컨텍스트를 주입한다.
이제 실행을 해보면
이럴수가 에러가 떴다.
@HiltViewModel엔 하나의 @Inject 또는 @AssistedInject가 있어야 한다고 한다.
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: ApiRepository,
private val bookmarkRepository: BookmarkRepository
) : ViewModel() {
이게 안보이나 옹이눈인가? 하고 고민 할때쯤
임포트부분에 문제가 있다는걸 알았다.
import com.google.inject.Inject
-> import javax.inject.Inject
위의 google.inject는 Google Guice 의 주입 어노테이션이다 구글 또 너냐
Hilt는 javax.inject.Inject을 사용해야 하므로 바꿔준다.
이제 실행 해주면
...
다행이 별일 아니다
Did you forget to specify your Application's class name in your manifest's 's android:name attribute?
애플리케이션 이름쓰는거 까먹었냐고 물어봤다.
ManiFest에 애플리케이션이름을 적어주자
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
이제 실행해주면
아주 잘 실행되는 모습을 볼 수 있다.