[Hilt] Hilt의 API 활용 및 예제 (1)
ViewModel
안드로이드에서 뷰모델을 사용하는 이유는 애플리케이션에서 상태를 유지하기 위함이다.
상태(State)라는 것은 간단하게 말하자면 시스템에 정보가 기억되는 것을 의미한다.
안드로이드는 PC 운영체제와 비교하여 상대적으로 낮은 사양에서도 애플리케이션이 원활히 실행될 수 있도록 설계된다.
그렇기에 필요없다 판단되는 것들은 전부 종료하여 메모리를 확보한다.
이 과정에서 액티비티와 같은 안드로이드 컴포넌트들이 수명주기에 따라 파괴되기도 한다.
대표적으로 구성변경이 발생할때다.
예) 화면 회전, 화면 크기 변경, 라이트/다크 모드 변경 등등
화면 회전의 경우, 액티비티가 파괴되고 다시 onCreate되며 재생성된다.
액티비티 인스턴스 내 레퍼런스되고 있던 모든 객체들이 GC 대상이 된다.
예외적으로 일부 안드로이드 뷰 컴포넌트(EditText, ...)에서는 내부에서 상태를 자체적으로 관리하는 경우도 있다.
이런 일부 경우를 제외하고는 개발자가 UI 상태를 직접 관리하여야 한다.
1. 상태와 연관된 Android의 특징
- Activity/Fragment는 언제든지 파괴될 수 있다
- Activity/Fragment가 파괴되면 UI 상태가 유실된다
- 새로운 데이터를 로드하는 비용이 크다
- Activity/Fragment는 상태를 저장/복원하는 솔루션을 제공한다
- 저장/복원 가능한 데이터의 사이즈(약 1MB)는 한정적이다
2. ViewModel이란?
비즈니스 로직 또는 UI 상태를 갖는 홀더
흔히 MVVM 패턴에서 말하는 ViewModel용어와 같기때문에 이를 구분짓기 위해 안드로이드 개발자 사이에서는 흔히 AAC뷰모델이라고 부른다.
AAC의 역할은 Activity/Fragment 보다 더 오래 메모리에 살아남아서 상태를 보존하는 저장소 역할을 한다.
2-1. 기본적인 ViewModel 인스턴스 획득
class FooViewModel: ViewModel()
val viewModel = ViewModelProvider(viewModelStoreOwner)[FooViewModel::class.java]
코틀린이나 자바문법 수준에서 ViewModel 생성자를 호출하는 인스턴스화 방법으로는 일반 Class 오브젝트를 만들어서 사용하는 것과 별로 다를 바 없다.
그렇기에 Activity나 Fragment 보다 더 오래 살아남기 위해서는 ViewModel 인스턴스화는 ViewModelProvider라는 컴포넌트의 도움을 받아야 한다.
val viewModel: FooViewModel by viewModels()
코틀린에서는 by viewModels()라는 델리게이트 패턴을 활용해서 viewModel 다음과 같이 인스턴스화할 수 있다.
하지만 viewModel에 인수를 전달하는 경우, ViewModelFactory를 제공해야한다.
class FooViewModel(foo: Foo, handle: SavedStateHandle): ViewModel()
val viewModel: FooViewModel by viewModels(
factoryProducer = {
// ViewModelProvider.Factory 반환
}
)
매개변수가 위처럼 존재할 경우, viewModel 생성시 인수 전달을 위한 ViewModelProvider의 Factory를 미리 정의해야 한다.
factoryProducer로 해당 Factory를 반환하는 람다를 만들어서 제공할 수 있다.
기본 제공하는 Factory에는 몇가지가 존재하는데 그 중 하나는 AbstractSavedStateViewModelFactory이다.
2-2. AbstractSavedStateViewModelFactory
class FooViewModel(foo: Foo, handle: SavedStateHandle): ViewModel()
class FooViewModelFactory(val foo: Foo): AbstractSavedStateViewModelFactory() {
override fun <T: ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
): T {
return FooViewModel(foo, handle) as T
}
}
SavedStateHandle를 전달할 수 있는 Factory이다.
2-3. 위 Factory가 create되는 과정
- ViewModel 생성 요청
- ViewModelProvider는 요청한 ViewModel 인스턴스가 있는지 ViewModelStoreOwner에게 확인 요청을 한다.
- 있다면, ViewModelStoreOwner는 ViewModelStore를 통해서 ViewModel을 가져와서 요청한 Client에게 반환한다.
- 없다면, ViewModelProvider.Factory를 통해서 ViewModel을 새로 생성하고, ViewModelStore에 저장한다.
+ ViewModelStoreOwner는 Activity의 경우 Component Activity가 되고, Fragment가 될 수도 있다.
+ ViewModelStore는 단순히 Key-Value 형태의 자료구조인 Map을 래핑하고 있는 클래스일 뿐이다.
Activity나 Fragment에서는 이 ViewModelStore를 구성 변경에 영향을 받지 않도록 따로 관리하고 있다.
그래서 액티비티 생명주기보다도 더 긴 생명주기를 갖게 된다.
3. ViewModel의 생명주기
그렇기에 Activity와 ViewModel의 생명주기를 나타낼 수 있다.
- 회색 부분은 Activity에서 일어나는 이벤트
- 매 이벤트마다 Activity의 생명주기의 변화가 중앙의 표
- 끝의 녹색 부분이 ViewModel의 생명주기
4. ViewModel 사용 시 유의사항
- ViewModel의 주요 목적은 구성이 변경되어도 상태를 유지하는 것.
- 데이터를 영구 유지하려는 목적이라면 로컬 디스크 or 서버에 저장.
- Activity Context와 같은 객체를 참조하면 메모리 누수가 발생할 수 있다.
- ViewModel은 UI에 대한 의존성이 없어야한다.
- ViewModel을 다른 클래스, 함수에 전달하면 안된다.
Hilt와 ViewModel
1. Hilt에서 ViewModel 인스턴스 얻기
@HiltViewModel
class FooViewModel @Inject constructor(
val foo: Foo,
val handle: SavedStateHandle
): ViewModel()
@AndroidEntryPoint
class MyActivity: AppCompatActivity() {
private val viewModel: FooViewModel by viewModels()
}
Hilt 존재 이전에는 생성자 매개변수가 있는 ViewModel 생성을 위해 ViewModel의 보일러 플레이트 코드인 Factory를 생성해야 했다.
하지만 위 예제를 참고하면 별도의 Factory 없이 by viewModels() 하나로 인스턴스화 할 수 있음이 보여진다.
또한, Hilt에서 공통적으로 사용 가능한 HiltViewModelFactory를 제공하기에 저것 하나만으로 호출이 가능하다.
HiltViewModelFactory도 바이트 코드 변조 과정을 거치며 생기는 코드이므로 소스 코드 상에서 확인은 어렵다.
@HiltViewModel 애노테이션은 ViewModel Class에 마킹되어 있어야 한다.
2. @HiltViewModel 쓰는 이유
ViewModelStore에는 동일한 클래스의 오브젝트들이 여러개 저장될 수 있다.
그렇기에 일반적으로는 하나의 인스턴스를 유지하려고 하지만, 실제로는 ViewModelComponent에서는 여러개의 인스턴스를 Key-Value 형태로 제공한다.
이러한 특징 때문에 Hilt에서는 ViewModel을 위한 별도의 컴포넌트를 따로 필요로 하게 된다.
ViewModelComponent로 Scope가 분리되어 의존성 주입이 이뤄진다.
Activity보다 더 오래 살아남기 때문에 Activity의 하위가 아닌, ActivityRetainedComponent의 하위에 들어간다.
ViewModel과 마찬가지로 ActivityRetainedComponent는 구성 변경이 이뤄져도 Component 인스턴스가 유지된다.
ViewModelComponent 내에서 여러 ViewModel 인스턴스를 관리하기 위해서 이전에 배운 Map 멀티바인딩 기법을 사용하게 된다. 문자열을 Key로 갖고, ViewModel 인스턴스를 Value로 받는 Map을 내부적으로 바인딩하여 ViewModel 인스턴스들을 ViewModel Component 내에서 관리하게 된다.
그래서 ViewModelComponent에서 의존성을 공유하고 싶다면 다음 코드처럼 @ViewModelScoped 애노테이션을 사용할 수 있다. 또한 ViewModelComponent 하위에서만 의존성을 제공하고 싶다면, Module을 선언하고, @InstallIn 애노테이션에 ViewModelComponent::class를 넣는다.
@ViewModelScoped
class Bar constructor @Inject(...)
@Module
@InstallIn(ViewModelComponent::class)
object FooModule {
@Provides
fun provideFoo(bar: Bar): Foo {
return Foo(bar)
}
}
3. 요약
- 구성 변경 시에도 상태를 유지하기 위해 ViewModel을 사용한다.
- Hilt에서는 ViewModel의 의존성을 돕기위한 @HiltViewModel과 ViewModelComponent가 있다
- Hilt에서 ViewModel 인스턴스는 HiltViewModelFactory를 통해 만들어진다
- Hilt에서 ViewModel 인스턴스들은 컴포넌트 내부에서 멀티바인딩으로 관리된다.
EntryPoint
1. EntryPoint란?
Hilt 의존성 주입이 어려운 코드에서 바인딩 된 의존성을 참조하는 방법
Hilt Component의 바인딩된 의존성들에 접근할 수 있는 진입점을 제공하는 기법이다.
@AndroidEntryPoint라는 애노테이션도 사용하고 있었다.
이 애노테이션은 Activity, Fragment, Service같은 안드로이드 컴포넌트 클래스에 사용되는 것이다.
@EntryPoint 애노테이션은 안드로이드 뿐만 아니라 다른 클래스에서도 Hilt 컴포넌트에 접근할 수 있는 진입점을 제공한다.
2. Entry Points는 언제 사용하나?
- Hilt를 사용하지 않는 라이브러리에서 의존성 주입이 어려울 때
- Hilt가 지원하지 않는 안드로이드 컴포넌트에 의존성 주입이 어려울 때
- Dynamic Feature Module에 의존성 주입이 필요한 경우
3. EntryPoint 만들기
@EntryPoint
@InstallIn(SingletonComponent::class)
interface FooEntryPoint {
fun getFoo(): Foo
}
먼저 Hilt 컴포넌트 의존성에 접근하기 위해 EntryPoint Interface를 정의해야 한다.
Hilt가 컴포넌트를 생성할 때 Foo EntryPoint를 확장한다.
SingletonComponent로 Scope를 한정했기 때문에 EntryPoint를 통한 의존성 제공 범위 또한 SingletonComponent로 한정짓게 된다. 그렇기 때문에 Activity, Fragment에서 바인딩된 의존성을 이 EntryPoint를 통해서 제공할 수는 없다.
interface에 선언되는 메서드는 반드시 정해진 포멧을 지켜서 선언되어야 한다.
Dagger에서는 이런 메서드는 프로비전 메서드라고 한다. 프로비전 메서드는 매개변수를 가지면 안되며, 반환타입을 가지고 있어야한다.
이렇게 SingletonComponent에 바인딩된 Foo라는 의존성에 접근하기 위한 EntryPoint 준비가 끝난다.
4. EntryPoint에 접근하기
val fooEntryPoint: FooEntryPoint = EntryPoint.get(applicationContext, FooEntryPoint::class.java)
val foo: Foo = fooEntryPoint.getFoo()
Hilt에서는 EntryPoint라는 자바 클래스를 제공한다.
첫번째 인수로 Hilt 컴포넌트 또는 애플리케이션을 포함한 안드로이드 클래스를 전달한다.
두번째로는 EntryPoint Interface를 전달한다.
이렇게하면 적절한 Hilt 컴포넌트를 찾아서 EntryPoint Type으로 반환한다.
이 정적 메서드(EntryPoint.get())는 첫번째 인수가 object이기 때문에 어떤 인수를 전달할지 애매모호할 수 있다.
그래서 EntryPointAccessors라는 유틸 오브젝트 클래스를 사용하면 안전하게 타입을 전달할 수 있다.
val fooEntryPoint: FooEntryPoint = EntryPointAccessors.fromApplication(applicationContext, FooEntryPoint::class.java)
val foo: Foo = fooEntryPoint.getFoo()
어떤 것을 사용하든 큰 차이는 없다.
내부적으로는 EntryPointAccessors도 EntryPoint 클래스에 접근하여 가져오기 때문이다.
5. EntryPointAccessors 메서드
- fromApplication
- fromActivity
- fromFragment
- fromView
6. 요약
- EntryPoint는 Hilt 컴포넌트에 바인딩 된 의존성에 접근할 수 있는 진입점이다.
- EntryPoint는 인터페이스 및 @EntryPoint, @InstallIn으로 구성된다.
- EntryPoint 인터페이스에 접근할 때는 EntryPoints 또는 EntryPointAccessors 클래스를 활용한다.