ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Dependency Injection과 Hilt 맛보기
    Android 2024. 5. 3. 20:38

    Dependency

    • 의존성 : A가 B를 의존한다. = A가 B를 참조한다.
    • 의존대상 B가 변하면, 그것이 A에 영향을 미친다.
      • B의 기능이 추가 또는 변경되거나 형식이 바뀌면 그 영향이 A에 미친다.

    • Car는 Engine이 없으면 동작하지 않는다.
      • Engine은 Car의 의존성
      • Car가 Engine에 의존한다. = Car가 Engine을 참조한다.
    // Without DI
    
    class Car {
        private val engine: Engine = Engine()
        ...
        ...
    }
    • Car class는 engine instance 생성과 어떻게 구성하는지까지 책임을 지고 있다.
      • SRP(단일 책임 원칙) 위반
      • 하나의 Class에 하기에는 책임이 많다.
    • Car와 Engine은 타이트한 의존성을 갖는다.
      • Engine에 대한 하위 클래스등을 사용하기가 어렵다.
      • Gas나 Electric 타입의 차를 생성하려면 Car를 재사용하지 못한다.
      • 테스트가 어렵다.
        • Car는 실제 Engine인스턴스를 사용하므로 Test Double을 사용하여 Engine을 수정할 수 없다.

     

    Dependency Injection

    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클래스가 지닌 책임을 나눈다.
    • 그럼으로써 Car class의 실제 역할에만 집중하게 한다.

    그래서 DI란?

    • 클래스가 내부에서 스스로 의존성을 생성하는 대신 외부에서 의존성을 부여하는 것이 의존성 주입(Dependency Injection)이다.

    Dependency Injection의 장점

    • Good balance of loosely-coupled dependencies
    • Reusability of code
      • 클래스에서 의존성 생성을 제어하지 않기 때문에 어떤 구성과도 동작할 수 있다.
    • Ease of refactoring
      • Smaller-focused classes
      • Dependency가 API에 노출되어 검증가능한 요소가 되므로 객체 생성 타임 또는 컴파일 타임에 확인할 수 있습니다. 
    • Ease of testing
      • 클래스가 Dependency를 관리하지 않으므로 테스트 시 다양한 구현을 전달하여 다양한 TestCase를 만들어 테스트하는 것이 가능하다.

     

    Reusability of code

    fun main(args: Array<String>) {
        ...
        val electricCar = Car(ElectricEngine())
        val combustionCar = Car(CombustionEngine())
        ...
    }
    • 어떤 엔진의 구현을 넘기더라도 Car를 재사용할 수 있다.

     

    Ease of refactoring

    class Car {
        // Very, very long source code
    }
    • Car class 에서 engine관련 로직을 추출해서 다른 클래스로 만들고 의존성으로 바꿔도 된다.
    class Car(
        private val engine: Engine,
        private val battery: Battery,
        private val airFilter: AirFilter,
        private val radiator: Radiator,
        private val wipers: List<Wiper>   
    ) {
        // Small source code
    }
    • 코드 범위가 줄면서 더 단순해진다.
    • 클래스 작업의 인지 부하도 낮아졌다.
    • SRP(단일 책임 원칙)을 지키도록 만들기 쉬워진다.
    • 해당 작업을 계속 반복하면 코드도 줄어들고, Car가 해야할 일이 줄어든다.

    Ease of testing

    class CarTest {
        @Test
        fun `Car happy path`() {
            val car = Car(FakeEngine())
            ...
        }
     
        @Test
        fun `Car with failing Engine`() {
            val car = Car(FakeFailingEngine())
            ...
        }
    }
    • 고장난 엔진을 단 자동차가 어떻게 작동하는 지 본다든지, 다양한 TestCase를 쉽게 만들 수 있다.

    Manual dependency injection

    Constructor Injection

    class Car(private val engine: Engine) {
     
    }
    • class의 dependency를 생성자로 전달한다. 

    Field Injection (or Setter Injection)

    class Car {
        lateinit var engine: Engine
     
        fun start() {
            engine.start()
        }
    }
     
    fun main(args: Array) {
        val car = Car()
        car.engine = Engine()
        car.start()
    }
    • Activity나 Fragment와 같은 특정 Android component등은 시스템에서 인스턴스화하므로 생성자 삽입이 불가능하다.

    Manual dependency injection's problem

    Large amount of boilerplate code

    class Car(
        private val engine: Engine,
        private val battery: Battery,
        private val airFilter: AirFilter,
        private val radiator: Radiator
    ) {
        ...
    }
     
    fun main(args: Array) {
        val cylinder = Cylinder()
        val engine = Engine(cylinder)
     
        val battery = Battery()
     
        val filterGrade = FilterGrade()
        val airFilter = AirFilter(filterGrade)
     
        val radiator = Radiator()
     
        val car = Car(engine, battery, airFilter, radiator)
    }

     

    • 대규모 앱일수록 모든 종속 항목을 가져와 올바르게 연결하려면 대량의 보일러플레이트 코드가 필요할 수 있다.
    • 다중 레이어 아키텍처에서는 최상위 레이어의 객체를 생성하기 위해 아래의 모든 레이어의 모든 dependency가 필요하다.
    • 애플리케이션이 커지면 상용구 코드(예: 팩토리)를 많이 작성하게 되고 상용구 코드는 오류가 발생하기 쉽다.

    Android에서 의존성 주입이 어려운 이유?

    • Android class(Activity, Fragment, Service)들이 Framework에 의해 인스턴스화 됨
      • 생성자에 접근할 수 없음
      • Factory를 API 28부터 제공하지만 현실적이지는 않음
      • Instance의 생명주기 관리도 직접해주어야 함

    Hilt

    Dagger와 Hilt

    • Dagger : 단검, 단도, 비수.
    • Hilt : (칼·단도의) 자루; (무기·도구의) 손잡이.
    • Hilt는 Dagger가 제공하는 컴파일 타임 정확성, 런타임 성능, 확장성 및 Android 스튜디오 지원의 이점을 누리기 위해 인기 있는 DI 라이브러리 Dagger를 기반으로 빌드되었습니다.

    Hilt의 특징

    • Android에서 Dependency을 위한 Jetpack의 권장 라이브러리
    • 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리
    • Annotation을 통한 Boilerplate code 삭제
    • 컴파일 타임에 dependency를 연결하는 코드를 생성하는 정적 솔루션
      • 컴파일 타임의 의존성 체크
      • 생성된 코드는 명확하고 디버깅이 가능함
      • 성능상의 이점 - 리플렉션 사용X
    • Android Studio 지원

     

    Hilt의 단점?

    • LearningCurve가 있음
    • 간단한 프로그램을 만들 때는 번거로움
    • 코드의 가독성을 떨어뜨릴 수 있음

    Manual DI와 Hilt

    Basics of manual dependency injection

    class MemoRepository(
         private val database: MemoDatabase,
         private val remoteDataSource: RemoteDataSource
    ) {
        fun load(id: String){..}
     }
     
     
     class MemoActivity: Activity() {
     
          private lateinit var repository: MemoRepository
     
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
     
              val db = MemoDatabase.getInstance(context)
              val remoteDataSource = RemoteDataSource()
     
              val memoRepository = MemoRepository(db, remoteDataSource)
          }
      }
    • Boilerplate code가 많음
      • 다른 곳에서 MemoRepository을 만드려면 동일하게 코드가 중복될 수 있다.
    • Dependency 선언에 순서가 있다.
    • 객체를 재사용하기 어렵다. 

     

    Managing dependencies with a container

    // Container of objects shared across the whole app
    class AppContainer {
     
       val db = MemoDatabase.getInstance(context)
       val remoteDataSource = RemoteDataSource()
     
       val memoRepository = MemoRepository(db, remoteDataSource)
    }
    
    // Custom Application class that needs to be specified
    // in the AndroidManifest.xml file
    class MemoApplication : Application() {
     
        // Instance of AppContainer that will be used by all the Activities of the app
        val appContainer = AppContainer()
    }
    • 이러한 종속 항목은 전체 애플리케이션에 걸쳐 사용되므로 모든 활동에서 사용할 수 있는 일반적인 위치, 즉 애플리케이션 클래스에 배치해야 합니다.
    class MemoActivity: Activity() {
     
        private lateinit var memoRepository: MemoRepository
     
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
     
            val appContainer = (application as MemoApplication).appContainer
            memoRepository = appContainer.memoRepository
        }
    • AppContainer를 직접 관리해야하고, 모든 종속 항목의 인스턴스를 수동으로 만들어야 합니다.
    • 여전히 Boilerplate code가 많다. 

     

    Dependency 추가

    // Root/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 Application class

    @HiltAndroidApp
    class MemoApplication : Application() { ... }
    • @HiltAndroidApp은 애플리케이션 수준 종속 항목 컨테이너 역할을 하는 애플리케이션의 기본 클래스를 비롯하여 Hilt의 코드 생성을 트리거합니다.

     

    Android 클래스에 종속 항목 삽입

    • @AndroidEntryPoint: @Inject annotation이 달린 변수에 의존성 주입을 수행한다.
    @AndroidEntryPoint
    class MemoActivity : AppCompatActivity() {
     
      @Inject lateinit var memoRepository: MemoRepository
      ...
    }
    • @Inject 주석을 사용하여 필드 삽입
      • Hilt가 삽입한 필드는 private일 수 없다. 

    Hilt bindings 정의

    • Binding: Hilt에게 dependency의 instance를 제공하는 방법을 알려주는 것

    생성자 삽입

    class MemoRepository @Inject constructor(
      private val database: MemoDatabase,
      private val remoteDataSource: RemoteDataSource
    ) { ... }
    • 클래스의 생성자에서 @Inject 주석을 사용하여 클래스의 인스턴스를 제공하는 방법을 Hilt에 알려준다.
    • Annotation이 지정된 클래스 생성자의 매개변수는 그 클래스의 dependency 항목이다.
      • 따라서 Hilt는 database의 instance를 제공하는 방법도 알아야한다. 

     

    Hilt Module

    • @Module로 주석이 지정된 클래스
    • 이 모듈은 특정 유형의 instance를 생성하는 방법을 Hilt에 알려준다.
      • 생성자 주입을 사용할 수 없는 경우에서 사용한다.
        • 예를 들어 interface는 생성자가 없으므로 생성자 주입을 사용할 수 없다. 내가 작성하지 않은 클래스(Room 등)를 사용할 때에도 생성자 주입을 사용할 수 없다. 이런 경우에는 Hilt Module을 이용하여 Binding 정보를 제공해야 한다.
    • @InstallIn 주석을 지정하여 각 모듈을 사용하거나 설치할 Android 클래스를 Hilt에 알려야 한다.
      • 예를 들어 앱 전체에서 사용할 모듈이라면 application 범위에, 특정 fragment에서만 사용한다면 fragment 범위에 모듈을 설치해야 한다.
    @Module
    @InstallIn(ApplicationComponent::class)
    object DataModule {
     
      @Provides
      fun provideMemoDB(
         @ApplicationContext context: Context)
      ) = Room.databaseBuilder(context, MemoDatabase::class.java, "Memo.db")
    }
    • 클래스가 외부 라이브러리에서 제공되므로 클래스를 소유하지 않은 경우 또는 빌더 패턴으로 instance를 생성해야 하는 경우에도 생성자 삽입이 불가능하다.

    주요 Annotation들

    @HiltAndroidApp

    • Hilt 코드 생성을 시작하는 시작점, 반드시 Application 클래스에 추가해야 한다.
    @HiltAndroidApp
    class MemoApplication : Application() {
     
       override fun onCreate() {
          super.onCreate() // 의존성 주입은 super.onCreate()에서 이뤄짐 (bytecode 변환)
       }
    }

     

    • 생성된 Hilt_MemoApplication 클래스를 개발자가 직접 상속할 필요없다.

     

    • Bytecode를 수정하지 않고 직접 상속하도록 수정할 수 도 있다.

     

    @AndroidEntryPoint

    • @Inject가 붙은 변수에 의존성을 주입하는 역할을 한다.
    • 각 Android 클래스에 관한 개별 Hilt 구성요소를 생성
      • @HiltAndroidApp → Component 생성
      • @AndroidEntryPoint → Subcomponent 생성
    • @AndroidEntryPoint를 지원하는 타입
      • Activity
      • Fragment
      • View
      • Service
      • BroadcastReceiver
    • Component
      • Component는 각 객체의 생명주기에 연결 되고 이와 관련된 의존성을 제공합니다.
      • Hilt 2.28.2 버전부터 @ApplicationComponent 어노테이션이 @SingletonComponent 을 상속받는 형식으로 변경되었고, @ApplicationComponent 가 추후 릴리즈에서 제거된다고 알렸다.

     

    •  
    •  Component Hierarchy
      • Hilt는 이미 정의된 표준화된 component set와 scope를 제공한다.
      • 하위 component는 상위 component가 가지고 있는 의존성에 접근할 수 있다.

    • Component Lifetime
      • 해당 Android 클래스의 수명 주기에 따라 생성된 Component의 인스턴스를 자동으로 만들고 제거합니다. 

    Scope와 @InstallIn

    • 범위가 지정되지 않음 (unscoped) - 어노테이션이 지정되지 않았을 때, 인스턴스 주입이 필요할 때마다 항상 새로운 인스턴스를 생성한다.
    • 커스텀 범위가 지정됨 (Custom scoped) - @Singleton 어노테이션과 같이 앱 컨테이너 등의 지정된 컴포넌트 범위동안 같은 인스턴스를 제공한다.
    • Module에서 Scope 지정은 @InstallIn으로 지정한 Component의 범위와 일치해야 합니다.

    @Singleton
    class MemoRepository @Inject constructor(
      private val database: Database,
      private val remoteDataSource: RemoteDataSource
    ) { ... }

     

     

    @InstallIn

    • 어떤 Component에 이 모듈을 설치할 지 가리킨다.
    • Module의 필수 요소
    • Module에서 Scope 지정은 @InstallIn으로 지정한 Component의 범위와 일치해야 합니다.
    • Hilt에 항상 동일한 데이터베이스 인스턴스를 제공하도록 하려면 @Provides provideMemoDB함수에 @Singleton 어노테이션을 추가한다.
    @Module
    @InstallIn(ApplicationComponent::class)     // 이곳과
    object DataModule {
     
      @Provides
      @Singleton    // 이곳의 범위가 일치해야 한다.
      fun provideMemoDB(
         @ApplicationContext context: Context)
      ) = Room.databaseBuilder(context, MemoDatabase::class.java, "Memo.db")
    }

    @EntryPoint

    • Hilt가 지원하지 않는 클래스에서 의존성이 필요한 경우 사용
    • Example) ContentProvider, DynamicFeatureModule, Dagger를 사용하지 않는 3rd-party 라이브러리 등등
    • 특징
      • @EntryPoint는 인터페이스에서만 사용
      • @InstallIn이 반드시 함께 있어야 함
      • EntryPoints 클래스의 정적 메서드를 통해 그래프에 접근
    // EntryPoint 생성하기
    @EntryPoint
    @IntallIn(ApplicationComponent::class)
    interface FooBarInterface {
        fun getBar(): Bar
    }
     
    // EntryPoint로 접근하기
    val bar = EntryPoints.get(
       applicationContenxt,
       FooBarInterface::class.java
    ).getBar()

    Hilt의 사전 정의된 qualifier 

    • @ApplicationContext 또는 @ActivityContext를 사용하여 적절한 Context를 제공 받을 수 있다.
    class AnalyticsServiceImpl @Inject constructor(
      @ApplicationContext context: Context
    ) : AnalyticsService { ... }
    
    
    class AnalyticsAdapter @Inject constructor(
      @ActivityContext context: Context
    ) { ... }

     

    AndroidX Extension

    • Hilt에는 다른 Jetpack 라이브러리의 클래스를 제공하기 위한 extension이 있다.
      • ViewModel
      • WorkManager
    ...
    dependencies {
      ...
      implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha01'
      // When using Kotlin.
      kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha01'
      // When using Java.
      annotationProcessor 'androidx.hilt:hilt-compiler:1.0.0-alpha01'
    }
    
    class ExampleViewModel @ViewModelInject constructor(
      private val repository: ExampleRepository,
      @Assisted private val savedStateHandle: SavedStateHandle
    ) : ViewModel() {
      ...
    }

     

    • Component 인스턴스화가 끝난 뒤에 변경될 수 있는 동적인 매개변수인 SavedStateHandle에 @Assisted annotation 을 붙임으로써 해결된다.
    @AndroidEntryPoint
    class ExampleActivity : AppCompatActivity() {
      private val exampleViewModel: ExampleViewModel by viewModels()
      ...
    }

     

    Reference

Designed by Tistory.