본문 바로가기
IT/Android

Android, Hilt를 사용해야하는 이유와 사용방법

by Cyber_ 2024. 4. 8.

0. 개요

프로젝트 진행하며 테스트를 코드를 작성하려는데 어려움을 겪었었다.(킅래스를 재선언하고 매개변수로 받고 하는 등의 작업에서 중복됨을 느꼈다.) 후에 Hilt 라이브러를 활용하여 재사용할 클래스에 의존성 주입을 하니 테스트 코드를 작성하는데 훨씬 수월했다. 사실 아직은 잘 모르겠다. '의존성 주입을 사용한다는 것은 무엇인지', '의존성 주입을 통해 테스트 코드 작성외에 어떤 이점이 있을지' 라는 의문이 든다. 하여 DI와 Hilt라이브러리를 이해하고 사용한다면 어떤 이점이 있는지에 대해 알아보자.

Android의 Dependency Injecttion(DI)

1) DI란?

  • 클래스에는 흔히 다른 클래스 참조가 필요
  • 클래스가 필요한 객체를 얻는 세 가지 방법
  • 클래스가 필요한 종속 항목을 구성. 예를 들어 Car라는 클래스에서 Engine 인스턴스를 생성하여 초기화
  • 다른 곳에서 객체를 가져옴.
  • 객체를 매개변수로 제공 받는다. Car 생성자는 Engine을 매개변수로 받는다.
  • 세 번째 옵션이 종속항목 삽입. 클래스 인스턴스가 자체적으로 종속항목을 얻는 대신 클래스의 종속 항목을 받아 제공
  • DI 사용전
class Car {

    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}
  • DI 사용 후
class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val engine = Engine()
    val car = Car(engine)
    car.start()
}
  • 이 처럼 매개변수로 Engine을 받게된다면 편해지는 점
  • Car의 재사용 가능. Engine의 서브클래스 ElectricEngine이라는 새로운 클래스를 정의 한다면 ElectricEngine 서브클래스의 인스턴스롤 전달하기만 하면 된다.
  • 테스트 편의성. 테스트 더블을 전달하여 다양한 시나리오를 테스트할 수 있습니다. FakeEngine의 테스트 더블을 생성하여 다양한 테스트에 맞게 구성할 수 있다.

2) Android 의 DI

  • 생성자 삽입: 위에서 설명한 방법
  • 필드 삽입(또는 setter 삽입): Activity 및 Fragment와 같은 특정 Android Framework Class는 시스템에서 인스턴스화 하므로 생성자 삽입이 불가능. 필드 삽입을 사용하면 종속 항목은 클래스가 생성된 후 인스턴스화 됩니다.
class Car {
    lateinit var engine: Engine

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.engine = Engine()
    car.start()
}
  • Android를 위한 DI library
    : 런타입 시 종속항목을 연결하는 리플렉션 기반솔루션 , 컴파일 시간에 종속 항목을 연결하는 코드를 생성하는 정적 솔루션으로 구분된다.
  • Dagger: Google에서 유지 관리하며 자바, Kotlin 및 Android 용으로 널리 사용되DI 라이브러리, 종속 항목 그래프를 자동으로 생성하고 관리하여 앱에서 DI 사용을 용이하게 합니다. 또한 Guice 같은 리플렉션 기반 솔루션의 여러 개발 및 성능 문제를 해결하는 완전 정적 및 컴파일 시간 종속 항목을 제공
  • Hilt: Android에서 DI를 위한 Jetpack의 권장 라이브러리. Hilt는 프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 수명주기를 자동으로 관리함으로써 애플리케이션에서 DI를 실행하는 표준 방법을 정의. Dagger가 제공하는 컴파일 시간 정확성, 런타임 성능, 확장성 및 Android 스튜디오 지원의 이점을 누리기 위해 인기 있는 DI 라이브러리인 Dagger를 기반으로 빌드

2. Hilt를 사용한 DI

1) 프로젝트의 루트 build.gradle

plugins {
  ...
  id 'com.google.dagger.hilt.android' version '2.44' apply false
}

2) app(module)/build.gradle

...
plugins {
  id 'kotlin-kapt'
  id 'com.google.dagger.hilt.android'
}

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

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

// Allow references to generated code
kapt {
  correctErrorTypes true
}

3) Hilt Application Class

  • Hilt를 사용하는 모든 앱은 @HiltAndroidApp으로 주석이 지정된 Application 클래스를 포함해야 함.
@HiltAndroidApp
class ExampleApplication : Application() { ... }
  • Application 객체의 생명주기에 연결되며 이와 관련된 종속항목을 제공

4) Android 클래스에 종속항목삽입

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

Hilt가 지원하는 Android 클래스

  • Application(@HiltANdroidApp)
  • ViewModel(@HiltViewModel)
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastRecevier
  • @AndroidEntryPoint로 주석을 지정하면 이 클래스 종속된 Android 클래스에도 주석을 지정해야함. 예를 들어 프래그먼트에 주석을 지정하면 이 프래그먼트를 사용하는 ㅎ활동에도 주석을 지정해야함Android 클래스에 관한 HIlt지원의 예외
  • AppCompatActivity와 같은 ComponentActivity를 확장하는 활동만 지원
  • Hilt는 androidx.Fragment를 확장하는 프래그먼트만 지원
  • Hilt는 보존된 프래그먼트를 지원하지 않는다.

5) Hilt 결합 정의

  • 필드 삽입을 실행하려면 Hilt가 해당 구성요소에서 필요한 종속 항목의 인스턴스를 제공하는 방법을 알아야 합니다. Hilt에 결합 정보를 제공하는 한 가지 방법은 생성자 삽입
class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }
  • @Inject: 클래스의 인스턴스를 제공하는 방법을 Hilt에 알려줌
  • 위의 예에서 AnalyticsAdapter는 AnalyticsService가 종속 항목으로 있습니다.

6) Hilt 모듈

  • 때로 유형을 생성자 삽입할 수 없는 상황도 있습니다.(cannot be constructor-injected), 인터페이스를 생성자 삽입할 수 없습니다. 또한 외부 라이브러리의 클래스와 같이 소유하지 않은 유형도 생성자 삽입할 수 없습니다. 이럴 때는 Hilt 모듈을 사용하여 Hilt에 결합정보를 제공할 수 있습니다.
  • @Module: 특정 유형의 인스턴스를 제공하는 방법을 Hilt에 알려줌
  • @IntallIn: 각 모듈을 사용하거나 설치할 Android 클래스를 Hilt에 알려야함.
  • @AnlaticsService가 인터페이스라면 이 인터페이스를 생성자 삽입할 수 없습니다. 대신 Hilt 모듈 내에 @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
}
  • @Provides: 외부라이브러리(Retrofit, OkHttpClient, Room) 또는 빌더 패턴으로 인스턴스를 생성하여야하는 경우 사용Hilt에 제공하는 정보
  • 함수 반환 유형은 함수가 어떤 유형의 인스턴스를 제공하는지 Hilt에 알려줌
  • 함수 매개변수는 해당 유형의 종속항목을 Hilt에 알려줍니다.
  • 함수 본문은 해당 유형의 인스턴스를 제공하는 방법을 Hilt에 알려줍니다.
@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)
  }
}
  • 동일한 유형에 대해 여러 결합 제공: 한정자
  • 한정자는 특정 유형에 대해 여러 결합이 정의되어 있을 때 그 유형의 특정 결합을 식별하는데 사용하는 주석
    다음 예는 AnalyticsService 호출을 가로채 인터셉터와 함께 OkHttpClient객체를 사용하는 것. 다른 서비스에서는 호출을 다른 방식으로 가로채야할 수도 있기에 이 경우에는 서로 다른 두 가지 OkHttpClient 구현을 제공하는 방법을 Hilt에 알려야 합니다.
  • @Binds 또는 @Provides 메서드에 주석을 지정하는데 필요할 한정자 정의
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptorOkHttpClient

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OtherInterceptorOkHttpClient
// As a dependency of another class.
@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    @AuthInterceptorOkHttpClient okHttpClient: OkHttpClient
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .client(okHttpClient)
               .build()
               .create(AnalyticsService::class.java)
  }
}

// As a dependency of a constructor-injected class.
class ExampleServiceImpl @Inject constructor(
  @AuthInterceptorOkHttpClient private val okHttpClient: OkHttpClient
) : ...

// At field injection.
@AndroidEntryPoint
class ExampleActivity: AppCompatActivity() {

  @AuthInterceptorOkHttpClient
  @Inject lateinit var okHttpClient: OkHttpClient
}
  • 한정자를 유형에 추가한다면 그 종속 항목을 제공하는 가능한 모든 방법에 한정자를 추가하는 것이 좋습니다.
  • Hilt의 사전 정의된 한정자
  • 애플리케이션 또는 활동의 Context 클래스가 필요할 수 있으므로 Hilt는 @ApplicationContext 및 @ActivityContext 한정자를 제공
class AnalyticsAdapter @Inject constructor(
    @ActivityContext private val context: Context,
    private val service: AnalyticsService
) { ... }

7) Android 클래스용으로 생성된 구성요소

8) Hilt가 지원하지 않는 클래스에 DI

  • Hilt가 지원하지 않는 클래스에 필드 삽입을 실행해야할 경우
  • @EntryPoint 사용하여 진입점을 만들 수 있다.
  • 예를 들어 Hilt 는 ContentProvicer를 제공하지 않습니다.
class ExampleContentProvider : ContentProvider() {

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

  ...
}
  • 매개변수 요소로 전달하는 구성요소와 EntryPoitAccessors 정먹 메서드가 모드 @EntryPoit 인터페이스의 @IntallIn 주석에 있는 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()
    ...
  }
}

3. 다중 모듈 앱의 Hilt

  • 다중 모듈 프로젝트가 일반 Gradle 모듈로 구성됬다면 Hilt를 사용한 종속 삽입은 위의 내용처럼 진행해도 되지만 기능모듈이 포함된 앱에는 적용되지 않는다.deep multi module project의 경우 build.gradle 파일에 enableExperimentalCalsspathAggregation 플래그를 사용 설정하는 것이 좋습니다.

1) 기능 모듈의 Hilt

  • 기능 모듈에서는 일반적으로 모듈이 서로 종속되는 방식이 반전 됩니다.
  • 따라서 Hilt는 기능 모듈에서 주석 처리할 수 없습니다. 기능 모듈에서 종속 항목 삽입을 실행하려면 Dagger를 사용해야 합니다.
  1. 기능 모듈에 필요한 종속항목이 있는 app 모듈(또는 Hilt)가 처리할 수 있는 다른 모듈)에서 @EntryPoint 인터페이스를 선언
  2. @EntryPoint 인터페이스에 종속된 Dagger 구성요소를 만듭니다.
  3. 기능 모듈에서 평소와 같이 Dagger를 사용

2) Login 기능 모듈을 프로젝트에 추가한다고 가정

  • @EntryPoint 생성
// LoginModuleDependencies.kt - File in the app module.

@EntryPoint
@InstallIn(SingletonComponent::class)
interface LoginModuleDependencies {

  @AuthInterceptorOkHttpClient
  fun okHttpClient(): OkHttpClient
}
  • Dagger 구성요소 생성
// LoginComponent.kt - File in the login module.

@Component(dependencies = [LoginModuleDependencies::class])
interface LoginComponent {

  fun inject(activity: LoginActivity)

  @Component.Builder
  interface Builder {
    fun context(@BindsInstance context: Context): Builder
    fun appDependencies(loginModuleDependencies: LoginModuleDependencies): Builder
    fun build(): LoginComponent
  }
}
  • 기능 모듈에서 Dagger 사용
// LoginAnalyticsAdapter.kt - File in the login module.

class LoginAnalyticsAdapter @Inject constructor(
  @AuthInterceptorOkHttpClient okHttpClient: OkHttpClient
) { ... }
  • 필드 삽입을 한다면
// LoginActivity.kt - File in the login module.

class LoginActivity : AppCompatActivity() {

  @Inject
  lateinit var loginAnalyticsAdapter: LoginAnalyticsAdapter

  override fun onCreate(savedInstanceState: Bundle?) {
    DaggerLoginComponent.builder()
        .context(this)
        .appDependencies(
          EntryPointAccessors.fromApplication(
            applicationContext,
            LoginModuleDependencies::class.java
          )
        )
        .build()
        .inject(this)

    super.onCreate(savedInstanceState)
    ...
  }
}

4. 다른 Jetpack 라이브러리와 함께 Hilt 사용

5. Hilt 테스트 가이드

6. 요약

DI 의존성주입, 종속항목 삽입은 코드의 재사용성, 리팩터링 편의성, 테스트 편의성을 위해 사용되어야 하는 주요 개념이며, 안드로이드에서는 Hilt, Dagger, Koin 등의 DI 라이브러리를 지원하며 공식문서에선 Dagger를 기반으로 제작된 Hilt 사용을 권장한다. 다만 다중 모듈에서 기능모듈을 사용할 경우 Dagger를 사용하여야한다.

Reference

https://developer.android.com/training/dependency-injection?hl=ko