Hilt

  • 프로젝트 내 모든 Android class에 대해 container를 제공하고 해당 lifecycle을 자동으로 관리함으로써 애플리케이션에서 DI를 사용하는 표준적인 방법을 제공
  • Dagger를 기반으로 구축되어 Dagger가 제공하는 compile-time correctness, runtime performance, scalability, 그리고 Android Studio 지원의 이점을 활용

container

DI 관점에서 객체를 생성하고(construct), 보관하고(cache), 수명(lifecycle)에 맞춰 재사용하거나 폐기하는 주체

container가 하는 일

  • dependency graph를 구성
  • 인스턴스 생성과 주입
  • scope에 따른 캐싱, 재사용
  • lifecycle에 맞춘 관리

모든 Android 클래스에 대해 container를 제공한다는 것의 의미

Application/Activity/Fragment/View/ViewModel/Service 등 각 Android class 타입에 대응하는 component를 자동 생성한다. 그리고 그 component들이 parent-child 계층 구조로 연결되어, 상위 component에 설치된 binding을 하위에서 재사용할 수 있게 한다.

@HiltAndroidApp

Hilt를 사용하는 모든 앱은 @HiltAndroidApp으로 annotation된 Application class를 포함해야 한다.

@HiltAndroidApp은 Hilt의 codegen을 트리거하고 이 과정에는 application-level dependency container 역할을 하는 Application의 base class 생성이 포함된다.

이렇게 생성된 Hilt component는 Application 객체의 lifecycle에 연결되며, 해당 객체에 dependency를 제공한다. 이 component는 앱 전체에서 사용되는 parent component이므로, 다른 component들이 여기서 제공하는 dependency에 접근할 수 있다.

1
2
3
4
5
// android/nowinandroid
@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
  ...
}

@AndroidEntryPoint

Application 클래스에서 Hilt 설정이 완료되고 애플리케이션 수준의 component가 사용 가능해지면, Hilt는 @AndroidEntryPoint annotation이 적용된 다른 Android 클래스에 의존성을 제공할 수 있다.

Hilt가 현재 지원하는 Android 클래스

  • Application (@HiltAndroidApp 사용)
  • ViewModel (HiltViewModel 사용)
  • Activity, Fragment, View, Service, BroadcastReceiver

Android 클래스에 @AndroidEntryPoint를 annotation으로 적용한 경우, 해당 클래스에 의존하는 Android 클래스에도 반드시 @AndroidEntryPoint를 적용해야 한다.

1
2
3
// android/nowinandroid
@AndroidEntryPoint
internal class SyncNotificationsService : FirebaseMessagingService()
1
2
@AndroidEntryPoint
class MainActivity : ComponentActivity()

Android 클래스에 @AndroidEntryPoint를 annotation으로 적용한 경우, 해당 클래스에 의존하는 Android 클래스에도 반드시 @AndroidEntryPoint를 적용해야 한다.

@AndroidEntryPoint는 프로젝트 내 각 Android 클래스마다 개별적인 Hilt Component를 생성한다. 이 component들은 각각의 parent class 로부터 의존성을 받을 수 있다.

component로부터 의존성을 얻기 위해서는 @Inject annotation을 사용하여 field injection을 수행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var lazyStats: dagger.Lazy<JankStats>

    @Inject
    lateinit var networkMonitor: NetworkMonitor

    @Inject
    lateinit var timeZoneMonitor: TimeZoneMonitor

    @Inject
    lateinit var analyticsHelper: AnalyticsHelper

    @Inject
    lateinit var userNewsResourceRepository: UserNewsResourceRepository
    ...
}

[!NOTE] Hilt가 주입하는 field는 private일 수 없다.

Hilt가 injection하는 클래스는, injection을 사용하는 다른 base class를 함께 가질 수도 있다. 다만 그 base class가 abstract라면 @AndroidEntryPoint annotation이 필요하지 않다.

  • 실제로 Android component인 클래스(Activity, Fragment 등) 가 abstract base class 를 상속하고 그 abstract base class에서도 injection(@Inject)을 사용하더라도 @AndroidEntryPoint는 최종 concrete Android 클래스에만 붙이면 된다.

Hilt 바인딩 정의하기

field injection을 수행하려면, Hilt는 해당 component로부터 필요한 dependency의 인스턴스를 어떻게 제공할 지 알아야 한다. binding은 특정 타입의 인스턴스를 dependency로 제공하기 위해 필요한 정보를 담고 있다.

Hilt에 바인딩 정보를 제공하는 한 가지 방법은 constructor injection이다. 클래스의 constructor에 @Inject annotation을 사용하면, 해당 클래스의 인스턴스를 Hilt가 어떻게 제공해야 하는지를 알려줄 수 있다.

1
2
3
class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }

위 문단에서 말하는 해당 클래스의 의미

  • @Inject constructor(...)가 직접 선언된 클래스
  • 즉, Hilt가 생성 책임을 가지게 되는 클래스
  • 위 예시에서는 AnalyticsAdapter를 의미한다.

AnalyticsAdatper 타입이 필요하면 이 constructor를 호출해서 만들면 된다. 단, constructor parameter인 AnalyticsService도 필요하다 그러면 AnalyticsService에 대한 binding도 존재해야 한다.

annotation이 적용된 constructor의 parameter들은 해당 클래스의 dependency이다. 예를 들어, 위 예시에서 AnalyticsAdapterAnalyticsService를 dependency로 가지고 있다. 따라서 Hilt는 AnalyticsService의 인스턴스를 어떻게 제공할 수 있는지도 반드시 알고 있어야 한다.

[!NOTE] 빌드 시점에 Hilt는 Android 클래스를 대상으로 Dagger component를 생성한다. 그 다음 Dagger는 코드를 분석하며 다음 작업을 수행한다.

  • dependency graph를 생성하고 검증하여, 해결되지 않은 dependency나 dependency cycle이 없는지 확인한다.
  • 런타임에 실제 객체와 그 dependency를 생성하는 데 사용되는 클래스들을 자동으로 생성한다(generate).

Hilt modules

Hilt module은 @Module annotation이 적용된 클래스이다. 특정 타입의 인스턴스를 어떻게 제공할지 Hilt에 알려주는 역할을 한다. Hilt module은 반드시 @InstallIn annotation을 사용하여 해당 module이 어떤 Android 클래스에 설치(사용)될 것인지를 명시해야 한다.

Hilt modules에서 제공한 dependency는, 해당 module을 설치한 Android 클래스와 연관된 모든 generated component에서 사용할 수 있다.

  • 어떤 Hilt module을 특정 scope에 설치하면, 그 scope 아래에 있는 모든 Android 클래스에서 그 dependency를 공통으로 사용할 수 있다.
1
2
3
4
5
6
7
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    fun provideApiService(): ApiService = ApiServiceImpl()
}
  • NetworkModuleSingletonComponent에 설치됨
  • 즉, Application scope
  • 결과적으로 Activity, Fragment, ViewModel, Service 등 아래에 존재하는 모든 generated component에서 ApiService를 injection 받을 수 있다.

[!NOTE] Hilt의 codegen은 Hilt를 사용하는 모든 Gradle module에 접근할 수 있어야 하므로, Application 클래스를 컴파일 하는 Gradle moudle에는 모든 Hilt module과 constructor injection을 사용하는 클래스들이 transitive dependency로 포함되어 있어야 한다.

@Binds

1
2
3
class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }

AnalyticsService가 interface라면, constructor injection을 사용할 수 없다. 이 경우에는 Hilt module 안에서 @Binds annotation이 적용된 abstract 함수를 만들어 Hilt에 바인딩 정보를 제공해야 한다.

@Binds annotation은 interface 타입의 인스턴스가 필요할 때 어떤 implementation을 사용할 지 Hilt에 알려주는 역할을 한다.

annotation이 적용된 함수는 Hilt에 다음 정보를 제공한다.

  • 함수의 return type: Hilt가 어떤 interface 타입의 인스턴스를 제공해야 하는지 알려준다.
  • 함수의 parameter: 해당 interface에 대해 어떤 implementation을 제공할지 알려준다.
1
2
3
4
5
6
7
8
9
10
11
12
// android/nowinandroid
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {

    @Binds
    internal abstract fun bindsTopicRepository(
        topicsRepository: OfflineFirstTopicsRepository,
    ): TopicsRepository

    ...
}
  • @Binds를 사용할 경우 module은 반드시 abstract class를 사용하고 함수도 반드시 abstract를 사용해야 한다. 실제 구현 코드는 Hilt가 codegen으로 자동 생성하기 때문이다.
  • 위 코드 해석하기 : Application scope(singleton)에서 TopicRepository 타입이 필요하면 OfflineFirstTopicsRepository를 사용해라.

constructor injection vs binds

  • constructor inejction : 클래스의 constructor에 @Inject를 붙여 “이 클래스의 인스턴스는 이렇게 만들어라”라고 Hilt에 알려주는 방식
  • @Binds : interface <-> implementation을 연결해주는 바인딩 방식

@Provides

다음과 같은 경우에도 constructor injection을 사용할 수 없다.

  • 클래스가 외부 라이브러리에서 제공되어, 직접 소유하지 않은 경우
    • ex. Retrofit, OkHttpClient, Room
  • 인스턴스 생성 시 builder pattern을 사용해야 하는 경우

@Provides가 적용된 함수는 Hilt에 다음 정보를 제공한다.

  • 함수의 return type : Hilt가 어떤 타입의 인스턴스를 제공하는 지 알려준다.
  • 함수의 parameter들 : 해당 타입을 생성하는 데 필요한 dependency들을 알려준다.
  • 함수의 body : 해당 타입의 인스턴스를 어떻게 생성할 지를 정의한다. Hilt는 이 타입의 인스턴스가 필요할 때마다 함수 body를 실행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// android/nowinnandroid
@Module
@InstallIn(SingletonComponent::class)
internal object NetworkModule {
    ...
    @Provides
    @Singleton
    fun okHttpCallFactory(): Call.Factory = 
        OkHttpClient.Builder()
            .addInterceptor(
                HttpLoggingInterceptor()
                    .apply {
                        if (BuildConfig.DEBUG) {
                            setLevel(HttpLoggingInterceptor.Level.BODY)
                        }
                    },
            )
            .build()
}

외부 라이브러리 타입 OkHttpClient은 constructor injection이 불가능하거나, builder pattern으로 생성해야 하므로 @Provides로 Hilt에 생성 방법을 알려준다.

  • return type : Call.Factory 타입을 제공한다.
  • parameters : 이 타입을 만들 때 필요한 dependency가 없다.
  • body : Call.Factory를 어떻게 생성하는지
  • Hilt는 누군가 Call.Factory를 injection 받으려고 하면, 이 함수를 호출해 인스턴스를 얻는다.

외부 라이브러리 타입에 construction injection이 안되는 이유

외부 라이브러리 타입은 우리가 constructor에 @Inject를 붙일 수 없기 때문이다.

constructor injection이 가능하려면 다음 조건을 만족해야 한다.

  • constructor에 @Inject를 붙일 수 있어야 함
  • 그 클래스의 소스 코드를 직접 수정할 수 있어야 함.
  • Hilt/Dagger가 그 constructor를 호출해 인스턴스를 생성함

외부라이브러리 타입에서 OkHttpClient를 예로 들어보면

  • 외부 라이브러리에서 제공됨
  • 소스 코드를 수정할 수 없음
  • constructor가 @Inject로 annotation되어 있지 않음
  • 대부분 builder pattern으로만 생성 가능

결국 다음과 같은 코드는 만들 수가 없다.

1
class OkHttpClient @Inject constructor(...)

builder 패턴이면 binds사용이 안되는 이유

@Binds는 구현체를 “어떻게 만들지를 정의하는 기능”이 없고 오직 “어떤 구현체를 쓸지”만 연결한다. @Binds는 이런 선언만 한다.

인터페이스(또는 상위 타입) T가 필요하면, 구현체 Impl을 써라

1
2
@Binds
abstract fun bindRepo(impl: RepoImpl): Repo
  • 함수 body가 없음(abstract)
  • 생성 로직을 쓸 수 없음
  • Hilt는 RepoImpl을 알아서 만들어야 한다. 즉, 보통 @Inject constructor로 생성 가능해야 한다.

하지만 builder/factory 타입은 알아서 만들 수가 없다.

1
2
3
OkHttpClient.Builder()
    .addInterceptor(...)
    .build()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// android/nowinandroid
@Module
@InstallIn(SingletonComponent::class)
internal object DatabaseModule {
    @Provides
    @Singleton
    fun providesNiaDatabase(
        @ApplicationContext context: Context,
    ): NiaDatabase = Room.databaseBuilder(
        context,
        NiaDatabase::class.java,
        "nia-database",
    ).build()
}

이런 타입은

  • @Inject constructor가 없거나
  • constructor가 숨겨져 있거나 (private)
  • builder/factory 호출이 필수이고
  • 필요한 설정값/환경(context, baseUrl 등)이 들어간다.

즉, Hilt가 “constructor를 호출해서 만들기” 방식으로는 생성할 수 없다.

출처

  • https://developer.android.com/training/dependency-injection/hilt-android
  • https://github.com/android/nowinandroid