DI

Hilt를 사용한 의존성 주입 시작하기

smomo 2021. 8. 22. 14:41

Hilt 라이브러리는 프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 자동으로 수명주기를 관리하여 애플리케이션에서 DI를 수행하는 표준 방법을 정의한다.

Hilt 목표

1. Android 앱용 Dagger 관련 인프라를 단순화한다.

2. 앱 간의 설정, 가독성/이해 및 코드 공유를 용이하게 하기 위해 표준 구성 요소 및 범위 세트를 생성한다.

3. 다양한 빌드 유형(예: 테스트, 디버그 또는 릴리스)에 서로 다른 바인딩을 공급하는 쉬운 방법을 제공한다.

설정

프로젝트의 루트 build.gradle 파일에 종속 항목을 추가

buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }
}

app/build.gradle 파일에 종속 항목을 추가

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

dependencies {
    implementation "com.google.dagger:hilt-android:2.28-alpha"
    kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}

Hilt는 자바 8 기능을 사용하는데 프로젝트에서 자바 8을 사용 설정하려면 app/build.gradle 파일에 다음을 추가한다.

android {
  ...
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}

Hilt Application

Hilt를 사용하는 모든 앱은 @HiltAndroidApp으로 주석이 지정된 Application 클래스를 포함해야 한다. 생성된 이 Hilt 구성요소는 Application 객체의 수명 주기에 연결되며 이와 관련한 종속 항목을 제공하며 다른 구성요소는 이 상위 구성요소에서 제공하는 종속 항목에 액세스 할 수 있다.

@HiltAndroidApp
class ExampleApplication : Application() { ... }

Component hierachy

 

Hilt는 안드로이드 Application의 다양한 생명주기에 자동으로 통합되는 내장 컴포넌트 세트를 해당 스코프 어노테이션과 함께 제공한다. 각 컴포넌트에 위에 달린 어노테이션은 컴포넌트 바인딩의 생명주기를 지정하는 데 사용된다. 각 컴포넌트 아래에 있는 화살표는 하위 컴포넌트를 가리키고 있다. 보통 하위 컴포넌트의 바인딩은 상위 컴포넌트의 바인딩이 가지고 있는 의존성들을 가질 수 있다.

 

참고 :  @InstallIn이 달린 모듈의 binding에 scope가 지정될 때는 반드시 모듈이 설치되는 component의 scope와 일치해야 한다. 예를 들면, @InstallIn(ActivityComponent.class) 모듈은 @ActivityScoped만 사용할 수 있다.

 

Generated components for Android classes

 안드로이드 클래스에 적합한 Hilt 컴포넌트 

 

Hilt Components Injector for
SingletonComponent Application
ActivityRetainedComponent ViewModel
ActivityComponent Activity
FragmentComponent Fragment
ViewComponent View
ViewWithFragmentComponent @WithFragmentBindings 어노테이션이 지정된 View
ServiceComponent Service

Components 주기, Scope

Hilt는 해당 Android 클래스의 수명 주기에 따라 생성된 구성요소 클래스의 인스턴스를 자동으로 만들고 제거한다.

 

Component Scope Created at Destroyed at 
SingletonComponent @Singleton Application#onCreate() Application#onDestroy()
ActivityRetainedComponent @ActivityRetainedScope Activity#onCreate() Activity#onDestroy()
ActivityComponent @ActivityScoped Activity#onCreate() Activity#onDestroy()
FragmentComponent @FragmentScoped Fragment#onAttach() Fragment#onDestroy()
ViewComponent @ViewScoped View#super() 제거된 뷰
ViewWithFragmentComponent @ViewScoped View#super() 제거된 뷰
ServiceComponent @ServiceScoped Service#onCreate() Service#onDestroy()
참고 : ActivityRetainedComponent는 구성 변경 전체에 걸쳐 유지되므로 첫 번째 Activity#onCreate()에서 생성되고 마지막 Activity#onDestroy()에서 제거된다.

 

@ActivityScoped를 사용하여 AnalyticsAdapter의 범위를 ActivityComponent로 지정하면 Hilt는 해당 Activity의 수명 주기 동안 동일한 AnalyticsAdapter 인스턴스를 제공한다.

@ActivityScoped
class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }

Scoped bindings와 Unscoped bindings 비교

Unscoped bindings 
- 모듈에 설치되지 않은 경우 모든 구성 요소에서 사용 가능

Scoped bindings

- 하나의 구성 요소로 범위가 지정되고 하위 구성 요소에 의해 상속됨

// 스코프가 지정되지 않은 이 바인딩은
// 각 바인딩의 요청에 대해 새로운 인스턴스를 제공하게 된다.
class UnscopedBinding @Inject constructor() {...}

// 스코프가 지정된 이 바인딩은
// 이 바인딩에 대한 동일한 컴포넌트 인스턴스는 각기 다른 요청에 대해 동일한 인스턴스를 제공한다.
// @FragmentScoped로 지정되었기 때문에 동일한 프레그먼트의 요청에 대해 동일한 인스턴스를 제공하게 된다.
@FragmentScoped
class ScopedBinding @Inject constructor() {...}

일반적으로 오해하는 부분이 모듈내에 선언된 모든 바인딩이 모듈이 설치되는 컴포넌트와 수명을 함께 한다고 생각하는 것이다. 하지만 그렇지 않고, 단지 스코프 어노테이션이 지정된 바인딩 선언만 해당 컴포넌트와 수명을 함께하여 각 바인딩 요청들에 대해 동일한 인스턴스를 제공한다.

 

@AndroidEntryPoint

Hilt에서는 객체를 주입할 대상에게 @AndroidEntryPoint 어노테이션을 사용하여 member injection을 수행할 수 있다.

@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() { ... }

Android 클래스에 @AndroidEntryPoint을 사용하면 이 클래스에 종속된 Android 클래스에도 @AndroidEntryPoint을 사용해야 한다. 

@AndroidEntryPoint를 사용할 수 있는 타입은 다음과 같다.

  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver
참고 : 
Hilt는 AppCompatActivity와 같은 ComponentActivity를 확장하는 Activity만 지원한다.
Hilt는 androidx.Fragment를 확장하는 Fragment만 지원한다.
Hilt는 retained fragment를 지원하지 않는다.

Retained Fragment

Fragment의 onCreate() 메서드에서 setRetainInstance(true)를 호출하면 구성 변경이 발생해도 Fragment 인스턴스가 유지되는데 Hilt와 함께 사용하는 Fragment는 (의존성 주입의 책임이 있는) 컴포넌트에 대한 참조를 가지고 있고 해당 컴포넌트는 이전 Activity의 인스턴스에 대한 참조를 가지고 있기 때문에 절대로 Fragment 인스턴스가 유지되서는 안된다. 또한 Fragment에 주입된 스코프 된 바인딩 및 프로바이더는 Hilt와 함께 사용하는 Fragment의 인스턴스가 유지될 경우 메모리 누수가 발생할 수 있다. 

@EntryPoint

Hilt가 지원하지 않는 클래스에 필드 삽입을 실행해야 할 경우 @EntryPoint 어노테이션을 사용하여 진입점을 만들 수 있다. Entry point를 생성하기 위해서는 각 바인딩 타입에 대한 접근 가능한 메서드를 사용하여 인터페이스를 정의하고 @EntryPoint 어노테이션을 추가해야 한다. 그런 다음 @InstallIn을 추가하여 Entry point가 설치될 컴포넌트를 지정한다.

class ExampleContentProvider : ContentProvider() {

  @EntryPoint
  @InstallIn(ApplicationComponent::class)
  interface ExampleContentProviderEntryPoint {
    fun analyticsService(): AnalyticsService
  }

  ...
}

Entrypoint에 접근하기 위해서는 EntryPointAccessors의 적절한 정적 메서드를 사용한다. 매개변수는 구성요소 인스턴스이거나 @AndroidEntryPoint 객체여야 한다. 매개변수로 전달하는 구성요소와 EntryPointAccessors 정적 메서드가 모두 @EntryPoint 인터페이스의 @InstallIn 주석에 있는 Android 클래스와 일치하는지 확인한다.

class ExampleContentProvider: ContentProvider() {
    ...

  override fun query(...): Cursor {
    val appContext = context?.applicationContext ?: throw IllegalStateException()
    val hiltEntryPoint =
      EntryPointAccessors.fromApplication(appContext, ExampleContentProviderEntryPoint::class.java)

    val analyticsService = hiltEntryPoint.analyticsService()
    ...
  }
}

이 예에서는 Entrypoint가 ApplicationComponent에 설치되어 있으므로 ApplicationContext를 사용하여 EntryPoint을 검색해야 한다. 만일 EntryPoint가 ActivityComponent에 있다면 ActivityContext를 대신 사용한다.

Hilt 모듈

생성자 삽입할 수 없는 상황일 경우(예: 인터페이스, 외부 라이브러리의 클래스 등) 이럴 때는 Hilt 모듈을 사용하여 Hilt에 binding 정보를 제공할 수 있다. Hilt 모듈은 @Module 어노테이션을 사용하고 @InstallIn 어노테이션을 사용하여 어떤 component에 install 할지 반드시 정해주어야 한다.

@Binds를 사용하여 인터페이스 인스턴스 삽입

@Binds 어노테이션은 인터페이스의 인스턴스를 제공해야 할 때 사용할 구현을 Hilt에 알려준다. 

interface AnalyticsService {
  fun analyticsMethods()
}

// Constructor-injected, because Hilt needs to know how to
// provide instances of AnalyticsServiceImpl, too.
class AnalyticsServiceImpl @Inject constructor(
  ...
) : AnalyticsService { ... }

@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {

  @Binds
  abstract fun bindAnalyticsService(
    analyticsServiceImpl: AnalyticsServiceImpl
  ): AnalyticsService
}

Hilt 모듈 AnalyticsModule의 @InstallIn(ActivityComponent::class) 어노테이션은 AnalyticsModule의 모든 종속 항목을 앱의 모든 Activity에서 사용할 수 있음을 의미한다.

 

@Provides를 사용하여 인스턴스 삽입

 

클래스가 외부 라이브러리에서 제공되므로 클래스를 소유하지 않은 경우(Retrofit, OkHttpClient 또는 Room 데이터베이스와 같은 클래스) 또는 빌더 패턴으로 인스턴스를 생성해야 하는 경우 @Provides 어노테이션 사용한다. 

@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    // Potential dependencies of this type
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}

 

References