앞으로 진행하는 프로젝트에 Hilt를 적용해보고 싶은 개발자가 옥수환님께서 발표하신 드로이드나이츠 2020 - Hilt와 함께 하는 안드로이드 의존성 주입 영상을 보고 정리한 글입니다. 혹시 잘못 이해하고 작성한 부분이나 오타에 대한 피드백을 주신다면 감사히 받겠습니다 🙇🏻♀️
생성자 또는 메서드 등을 통해 외부로부터 생성된 객체를 전달받는 것
자바와 안드로이드를 위한 강력하고 빠른 의존성 주입 프레임워크
개발자 중 49%가 DI 솔루션 개선을 요청함 😵
DI를 사용하는 표준적인 방법을 제공한다.
➡️ 중구난방이었던 Dagger의 사용법을 획일적이게 만든다는 것 ❗️
// @Inject : 의존성 주입을 받겠다.
class MemoRepository @Inject constructor(
private val db: MemoDatabase
) {
fun load(id: String) {...}
}
// @HiltAndroidApp : Hilt 사용시 반드시 선행 되어야 하는 부분, 모든 의존성 주입의 시작점
@HiltAndroidApp
class MemoApp : Application()
// @AndroidEntryPoint : 안드로이드 클래스에 추가,
// Activity 안에 선언 된 @Inject 어노테이션에 대해 의존성 주입 수행
@AndroidEntryPoint
class MemeActivity : AppCompatActivity() {
@Inject lateinit var repository: MemoRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
repository.load("YHLQMDLG")
}
}
// @InstallIn : 해당 컴포넌트의 모듈이 설치 되게 한다.
@InstallIn(ApplicationComponent::class)
@Module
object DataModule {
// @Provides : 모듈 클래스 내에 데이터베이스 객체 생성
@Provides
fun provideMemoDB(@ApplicationContext context: Context) =
Room.databaseBuilder(context, MemoDatabase::class.java, "Memo.db")
.build()
}
@HiltAndroidApp
를 통해 ApplicationComponent가 생성된다.@InstallIn
를 모듈 클래스에 추가하여 해당 컴포넌트의 모듈이 설치 되게 한다.@AndroidEntryPoint
를 Activity에 추가함으로써 ApplicationCompoenet의 하위 컴포넌트인 ActivityComponent가 생성되고 MemoRepository 객체를 주입 받을 수 있게 된다.class MemoApplication : Application() {
override fun onCreate() {
super.onCreate()
val component = DaggerMemoComponent.builder()
...
.build()
component.inject(this)
}
}
정의된 Component는 컴파일 타임에 Dagger라는 접두어가 붙은 Component 클래스를 생성하게 되고,
Application의 onCreate()
메소드에서 컴포넌트를 인스턴스화하는 것이 일반적인 형태였다.
@HiltAndroidApp // 위의 과정을 생략하고, @HiltAndroidApp만 추가하면 된다.
class MemoApplication : Application() {
override fun onCreate() {
super.onCreate() // 의존성 주입은 super.onCreate()에서 이루어짐 (bytecode 변환)
}
}
@HiltAndroidApp : Hilt 코드 생성을 시작, 반드시 Application 클래스에 추가
해당 어노테이션 추가만으로 ApplicationComponent 코드를 생성 및 인스턴스화 하는 코드가 만들어진다.
컴포넌트를 인스턴스화 하는 부분은 상위 클래스의 onCreate()
에서 이루어진다.
➡️ 바이크 코드 변환 때문에 이러한 과정이 가능하다.
@HiltAndroidApp
이 붙은 MemoApplication 클래스는 컴파일 타임에 Hilt 접두어가 붙은 Hilt_MemoApplication 클래스를 생성한다. 생성된 Hilt 클래스는 Base 클래스가 상속한 클래스를 똑같이 상속하게 된다. 예제에서는 Base 클래스가 Application을 상속했기 때문에 생성된 Hilt_MemoApplication 클래스도 Application 클래스를 상속하고 있는 것을 확인할 수 있다. 생성된 Hilt 클래스는 컴포넌트의 인스턴스 및 의존성 주입 관련 코드들을 포함하고 있다.
Hilt_MemoApplication을 상속해야할 것 같지만 실제로는 그러지 않아도 된다❗️
MemoApplication 소스코드는 컴파일을 거쳐 바이트 코드를 산출하고, 그 이후에 Gradle 플러그인이 개입하여 바이트 코드를 조작하기 때문이다.
즉, MemoApplication 바이트 코드는 Hilt_MemoApplication를 상속하는 코드로 변환이 되는데 이는 개발자의 편의성을 위해 도모되었으며, 만약 바이트 코드 변환을 원하지 않는다면 Gradle 플러그인을 비활성화 시키면 된다.
@HiltAndroidApp(Application::class)
class MyApplication : Hilt_MyApplication()
만약 Gradle 플러그인을 사용하지 않는다면 @HiltAndroidApp
에 Application 클래스가 상속할 클래스를 명시하고, 실제로 상속하는 클래스는 생성된 Hilt_MyApplication 클래스가 되어야한다.
어노테이션이 추가된 안드로이드 클래스에 DI 컨테이너를 추가
@HiltAndroidApp
의 설정 후 사용 가능
@HiltAndroidApp
→ Component 생성@AndroidEntryPoint
→ Subcomponent 생성(ContentProvider는 까다로운 생명주기 때문에 지원하지 않기 때문에 다른 방법으로 의존성 주입이 가능하다.)
@AndroidEntryPoint
를 사용하여 해당 타입에 맞는 컴포넌트를 추가하게 된다.// No scope annotation
class MemoRepository @Inject constructor(
private val db: MemoDatabase
) {
fun load(id: String) { ... }
}
위 코드처럼 Scope를 지정해주지 않으면 MemoRepository를 다른 Activity에서 inject 하게 되면 매번 새로 생성하기 때문에 서로 다른 인스턴스가 되어버린다.
@Singleton
class MemoRepository @Inject constructor(
private val db: MemoDatabase)
{
fun load(id: String) { ... }
}
모듈에서 사용되는 Scope 어노테이션은 반드시 InstallIn에 명시된 컴포넌트와 쌍을 이루는 Scope를 사용해야 한다.
두 Activity가 ApplicationComponent에 설치된 모듈로부터 동일한 MemoRepository 인스턴스를 주입받은 것을 확인할 수 있다.
이처럼 Hilt는 자원 공유를 쉽게 할 수 있도록 도와준다.
컴포넌트는 기본적으로 아래와 같은 객체를 그래프에 바인딩한다.
Dagger의 @BindsInstance
을 사용하지 않아도 컴포넌트에 따라 Application, Activity, Context 등과 같은 인스턴스를 제공받을 수 있다.
대신 Context를 요청할 때는 @ApplicationContext
또는 @ActivityContext
를 사용하여 요청하는 Context의 종류를 명확히 해주어야 한다.
@InstallIn(ActivityComponent::class)
@Module
object MyModule {
...
}
아래의 경우, ActivityComponent를 명시했기 때문에 당연히 ActivityComponent에 설치된다.
🤔 하지만 만약 AcitivtyComponent와 FragmentComponent 모두 MyModule이 필요하다면?
하위 컴포넌트는 상위 컴포넌트의 의존성에 Access 할 수 있기 때문에 상위 컴포넌트의 모듈을 설치하는 것을 고려할 수 있다.
ApplicationComponent의 모듈을 설치하면 모든 컴포넌트들이 의존성에 접근할 수 있다.
따라서, ApplicationComponent 혹은 AcitivityComponent에 설치하면 된다.
@Module
클래스에 @InstallIn
이 없으면 컴파일 에러를 발생시킨다. (모듈이 어느 컴포넌트에 설치되는지 명확하게 확인하기 위해 )
// @InstallIn 검사 비활성화
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments += ["dagger.hilt.disableModulesHaveInstallInCheck":"true"]
}
}
}
}
만약 Dagger를 사용하는 프로젝트를 Hilt로 마이그레이션 해야 하는 경우, build.gradle(Module) 파일에 해당 내용을 추가해야 한다.
Hilt가 지원하지 않는 클래스에서 의존성이 필요한 경우 사용
(ex. ContentProvier, DFM, Dagger를 사용하지 않는 3rd-party 라이브러리 등)
@EntryPoint
는 인터페이스에서만 사용할 수 있다.@InstallIn
을 사용해서 어떤 컴포넌트에 접근할 것인지 명시해야 한다.// EntryPoint 생성하기
@EntryPoint
@InstallIn(ApplicationComponent::class)
intreface FooBarInterface {
fun getBar: Bar
}
// EntryPoint로 접근하기
val bar = EntryPoints.get(
application,
FooBarInterface::class.java
).getBar()
@AndroidEntryPoint
를 지원하지 않기 때문에 @EntryPoint
를 사용해야 한다.
@EntryPoint
@InstallIn(ApplicationComponent::class)
interface *MemoEntryPoint* {
fun getRepository(): MemoRepository
}
MemoRepository를 제공받기 위해 MemoEntryPoint라는 인터페이스를 작성한다.
class MemoProvider: ContentProvider() {
override fun query() {
val entryPoint = EntryPointAccessors.fromApplication(context, MemoEntryPoint::class.java)
val repository = entryPoint.getRepository()
}
}
ContentProvider에서 ApplicationComponent에 있는 MemoRepository에 접근하기 위해 작성된 코드이다.
EntryPointAccessors
- EntryPoints를 감싼 Util성 클래스
- EntryPoint 를 쉽게 가져올 수 있도록 도와준다.
프로젝트를 DFM(Dynamic Featur Module) 구성하는 경우 기본 App 모듈에 의존하게 된다.
기본 App 모듈은 DFM을 참조할 수 없기 때문에 컴파일 타임에서 SubComponent 구조로 그래프를 생성하는 것이 불가능하다. 하지만 컴포넌트를 상속하는 구조인 Dagger의 Component dependency 기능을 사용하면 DFM에서 ApplicationComponent를 상속하는 컴포넌트를 생성할 수 있게 된다.
@Component(dependencies = [MemoEntryPoint::class])
interface MemoEditComponent {
fun inject(activity: MemoEditActivity)
@Component.Builder
interface Builder {
fun context(@BindInstance context: Context): Builder
fun dependencies(entryPoint: MemoEntryPoint): Builder
fun build(): MemoEditComponent
}
}
먼저 DFM 쪽에 Dagger 컴포넌트 인터페이스를 정의한다. EntryPoint를 의존하는 MemoEditComponent를 만들면 EntryPoint가 제공하는 MemoRepository를 MemoEditComponent가 접근할 수 있게 된다.
class MemoEditActivity: AppCompatActivity() {
@Inject
lateinit var repository: MemoRepository
ovrride fun onCreate(savedInstanceState: Bundle?) {
DaggerMemoEditComponent.builder().context(this)
.dependencies(EntryPointsAccessors.fromApplication(applicationContext, MemoEntryPoint::class.java))
.build()
.inject(this)
super.onCreate(savedInstanceState)
}
}
EntryPointsAccessors를 통해 ApplicationContext의 일부인 MemoEntryPoint를 받아 DaggerMemoEditComponent를 인스턴스화하고, 마침내 MemoEditActivity에 MemoRepository를 멤버 주입하는 것을 확인할 수 있다.
Hilt는 Jetpack 라이브러리와 함께 사용할 수 있도록 확장(extension) 라이브러리를 제공한다.
현재 지원하는 Jetpack 컴포넌트로는 ViewModel과 WorkManager가 있다.
기존의 Dagger로 ViewModel을 주입하는 것은 상당히 까다로운 작업이었다. 컴포넌트 인스턴스화 끝난 후에 변경될 수 있는 동적인 매개변수 SavedStateHandle을 Dagger의 그래프에 포함시키는 것은 거의 불가능하다.
하지만 이 부분은 Square 사에서 만든 AssistedInject 라이브러리를 통해 해결이 가능하며 Hilt에도 이 기능이 기본적으로 포함되어 있다.
ViewModel을 주입하는 것을 Dagger와 AssistedInject로 직접 구현하려면 매우 복잡하지만 Hilt는 이를 단순화 시켜준다.
class MemoViewModel @ViewModelInject constructor(
private val repository: MemoRepository,
@Assisted private val savedStateHandle: SavedStateHandle
): ViewModel() { ... }
@AndroidEntryPoint
class MemoActivity: AppCompatActivity() {
pirvate val viewModel: MemoViewModel by viewModels()
}
@ViewModelInject
을 붙인다.@Assisted
를 추가적으로 덧붙인다.WorkManager의 Worker도 ViewModel과 동일한 방식으로 동작한다.
다만 @WorkerInject
을 사용하는 것만 다르다.
class ExampleWorker @WorkerInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
workDependency: WorkerDependency
): Worker(appContext, workerParams) { ... }
@HiltAndroidApp
class ExampleApplication: Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override fun getWorkManagerConfiguration() =
Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
Hilt는 표준화된 컴포넌트를 가지고 있다. 하지만 간혹 컴포넌트 객체의 수명이 맞지 않거나 특정 기능을 필요로 하는 상황들이 생기기 마련이다. 이런 경우에는 커스텀 컴포넌트를 정의할 필요가 있다. 커스텀 컴포넌트의 생성은 그래프를 복잡하게 만들기 때문에 생성하기 전에 논리적으로 반드시 필요한 것인지 생각해야 한다.
커스텀 컴포넌트를 정의하는 것은 Dagger의 컴포넌트를 정의하는 것과 크게 다를 것이 없다. 다른 점은 DefineComponent를 사용한다는 것이다.
ex) ActivityComponent와 FragmentComponent 사이에 추가될 수 없다.
단일체로 된 컴포넌트
예를 들어, 각 Activity는 분리된 Component 인스턴스를 가지지만 정의된 컴포넌트 클래스는 하나로 공용된다.