Android/Development Tips

Hilt 수박 겉핥기

Nanamare 2021. 5. 7. 20:11

www.youtube.com/watch?v=gkUCs6YWzEY

앞으로 Hilt 를 사용할 것 같은 느낌이 강하게 들어, 드로이드 나이츠 2020 옥수환님이 발표하신 영상 보면서 정리해봤습니다

 

 

Dependency Injection(의존성 주입) 이란 ?

 - 생성자 또는 메세드 등을 통해 외부로부터 생성된 객체를 전달받는 행위

 

의존성 주입의 특징?

 - 클래시간 결합도를 느슨하게 만든다

 - 인터페이스 기반으로 설계되며, 코드를 유연하게 변경 가능하도록 한다

 - Stub 또는 Mock 객체를 사용하여 단위 테스트를 하기가 더욱 쉬워진다

 

간단한 예제코드

// 의존성 주입이 없는 코드
class MemoRepository {
    private val db = SQLiteDatabase()
    
    fun load(id: String) { ... }
}

fun main() {
    val repository = MemoRepository()
    repository.load("8092")
}
// 의존성을 외부로 부터 주입 받음
class MemoRepository(private val db: Database) {
    fun load(id: String) { ... }
}

fun main() {
    val db = SQLiteDatabase()
    val repository = MemoRepository(db)
    repository.load("8092")
}

 

안드로이드에서 의존성 주입이 어려운 이유?

 - Android 의 Activity, Fragment 등은 개발자가 생성자를 통해 만드는 것이 아닌 Framework 에 의해 인스턴스화 된다

 - Factory(FragmentFactory 등) 를 API 28 부터 제공하지만 현실적인 방법은 아니다.

 

Dagger2 란?

 - Dagger2 는 자바와 안드로이드를 위한 강력하고 빠른 의존성 주입 Framework

 

Dagger2 특징

 - 컴파일 타임에 의존성 그래프를 구성한다

 - 생성된 코드는 명확하고 디버깅이 그낭하다

 - 리플렉션을 사용하지 않고, 런타임에 바이트 코드를 생성하지 않는다.

 - 생명주기와 계층별로 잘 정리된 의존성 그래프 속에서 객체들을 효율적으로 공유할 수 있다

 - 작은 라이브러리 크기를 가진다

 - 상위 10000개의 안드로이드 앱 중에 74% 가 Dagger 를 사용하고 있다

 

Dagger2 단점

 - 배우기 어렵고, 프로젝트 설정이 힘들다

 - 간단한 프로그램을 만들 때는 번거롭다

 - 같은 결과에 대해 다양한 방법이 존재한다

개발자 설문조사에서 개발자 중 49% 가 DI 솔루션 개선을 요청했다

 

의존성 주입 Framework 의 궁극적인 목표

 - 정확한 사용 방법을 제안

 - 쉬운 설정 방법 (Dagger 를 사용하지 않고 Koin 사용하는 이유중 큰 비중을 차지했다)

 - 중요한 것들에 집중할 수 있도록 한다 (컴파일 타임에 에러를 수정할 수 있는 것은 매우 큰 장점이나 너무 빈번하게 발생하면 개발자에게 피곤함을 줄 수 있다)

 

그리하여 Hilt 가 등장하였습니다.

 

Hilt 란?

 - Application 에서 DI 를 사용하는 표준적인 방법을 제공한다. (Android Dagger, Dagger2 등을 하나로 통합한다)

 

Hilt 목표

 - Dagger 사용의 단순화

 - 표준화된 컴포넌트 세트와 스코프로 설정과 가독성/이해도 높이기

 - 쉬운 방법으로 다양한 빌드 타입에 대해 다른 바인딩을 제공할 수 있도록 한다

 

Hilt 특징 (직접 컴포넌트를 인스턴스화 할 필요 없다는 것이 매우 큰 장점 같다)

 - Dagger2 기반의 라이브러리

 - 표준화된 Dagger2 사용법을 제시

 - 보일러플레이트 코드 감소(Google IO 앱을 Hilt 로 리팩토링 한 결과 의존성 주입 코드를 75 % 나 줄임)

 - 프로젝트 설정의 간소화

 - 쉬운 모듈 탐색과 통합

 - 개선된 테스트 환경

 - Android Studio 에서 만들어진 그래프를 가시적으로 확인 가능

 - Android X 라이브러리와 함께 사용할 수 있도록 별도의 라이브러리 제공 (현재는 ViewModel, WorkManager 컴포넌트와 함께 사용가능)

 

빠르게 적용해보기

// 힐트 적용시 필수로 적용되어야 합니다
@HiltAndroidApp // 모든 의존성 주입의 시작점
class MemoApp: Application()

@AndroidEntryPoint // Activity 내에 선언된 Inject 어노테이션이 달린 변수에 대해 의존성 주입을 수행
class MemoActivity: AppcompatActivity() {

    @Inject
    lateinit var repository: MemoRepository
    
    override fun onCreate(savedInstanceState: Bundle) {
        super.onCreate(savedInstanceState)
        repository.load("8922")
    }
}
// 생성자 주입
class MemoRepository @Inject constructor(
    private val db: MemoDatabase
) {
    fun load(id: String) { ... }
}
@InstallIn(ApplicationComponent::class)
@Module
object DataModule {

    @Provides
    fun provideMemoDB(@ApplicationContext context: Context) = 
    	Room.databaseBuilder(context, MemoDatabase::class.java, "Memo.db").build()

}

 

1. @HiltAndroidApp 을 통해 ApplicationComponent 가 먼저 생성된다

2. @InstallIn 을 통해 ApplicationComponent 안에 DataModule 이 설치된다

3. @AndroidEntryPoint 를 통해 ApplicationComponent 의 하위 컴포넌트인 ActivityComponent 가 생성되고, ActivityComponent 를 사용하여 MemoRepository 를 주입받는다.

 

Hilt 의 주요 Annotation

 - HiltAndroidApp

 - AndroidEntryPoint

 - InstallIn

 - EntryPoint

 

@HildAndroidApp 없이 컴포넌트 생성하기

// 전형적인 Dagger2 에서 컴포넌트를 생성하는 방법이다 (현재도 같은 방식으로 사용하고 있다)
class MemoApplication: Application() {

	override fun onCreate() {
    	super.onCreate()
		DaggerMemoComponent.builder()
        	.build()
			.inject(this)
    }

}

 

@HiltAndroidApp

@HiltAndroidApp
class MemoApplication: Application() {
	override fun onCreate() {
    	super.onCreate() // 의존성 주입은 super.onCreate() 에서 bytecode 변환을 통해 이뤄진다
    }
}

 

 

어떻게 위와 같이 마법이 일어날까?

1. @HiltAndroidApp 이 붙은 MemoApplication 클래스는  Hilt 접두어가 붙은 Hilt_MemoApplication 클래스를 컴파일 타임에 생성해낸다 그리고 Hilt_MemoApplication 클래스안에는 대거 컴포넌트 생성과 주입하는 코드들이 작성되어 있다.

 

오른쪽과 같이 변경하지 않아도 된다
바이트 코드 조작

2. 그리고 MemoApplication 은 Hilt_MemoApplication 을 상속받는 구조가 되면 해결이 될 것 같지만, 실제로는 Hilt_MemoApplication 을 상속받지 않아도, 컴파일시 Gradle 에서  MemoApplication 바이트 코드를 Hilt_MemoApplication 을 상속하는 MemoApplication 바이트 코드로 변경(조작)합니다.

 

3. 혹시나 바이트 코드 변환을 원하지 않으면, Gradle 옵션을 disabled 시키면 된다. 이때는 Hilt_MemoApplication 을 상속하도록 개발자가 직접 코드를 변경해주면 됩니다.

(역시나 은탄환은 없었네요)

 

@AndroidEntryPoint

 - 어노테이션이 추가된 안드로이드 클래스에 DI 컨테이너를 추가하게 된다.

 - Dagger2 와 비교를 해보면 아래와 같다.

Hilt Dagger2
@HiltAndroidApp @Component
@AndroidEntryPoint @Subcomponent

 

 - HiltAndroidApp 추가 후에 사용가능하다.

 

@AndroidEntryPoint 를 지원하는 타입(우와 많다)

 - Activity

 - Fragment

 - View

 - Service

 - BroadCastReceiver

ContentProvider 는 조금 까다로운 생명주기를 가지고 있어 지원하지 않지만, 경우에 따라 다른 방법으로 의존성 주입을 구현할 수 있다.

 

 

Hilt component hierarchy

 - Hilt 는 이미 정의된 표준화된 컴포넌트 세트를 제공한다

 - AndroidEntryPoint 를 사용하여 해당 타입에 맞는 Component 를 추가(InstallIn)하게된다

 - 하위 컴포넌트는 상위 컴포넌트가 가지고 있는 의존성에 대해 접근 가능하다 (직계 수직 관계에서만 가능)

Hilt component 의 계층구조

Hilt component 의 특징

 - Dagger 와 다르게 직접적으로 인스턴스화 할 필요가 없다. (바이트 코드에서 조작하기 때문에)

 - 표준화된 컴포넌트 세트와 스코프를 제공한다 (BuiltIn)

 - 컴포넌트들은 계층으로 이루어져 있으며, 하위 컴포넌트는 상위 컴포넌트의 의존성에 접근할 수 있다(Subcomponent)

 

Hilt Scope

 - 표준화된 스코프를 제공한다

 - retained 가 붙어있는 scope 는 Configuration(language, orientation 등) 에도 유지된다

hilt 의 표준화된 Scope

@Scoped Binding

// No scope annotation
class MemoRepository @Inject constructor(
	private val db: MemoDatabase
) {
    fun load(id: String) { ... }
}

 

 

위처럼 스코프를 지정해주지 않으면, MemoRepository 를 다른 Activity 에서 inject 하게 되면 매번 다른 인스턴스가 새로 만들어져서 inject 된다.

다른 인스턴스

 

Singleton scoped binding

 -  모듈에서 사용되는 Scope 어노테이션은 반드시 IntallIn 에 명시된 Component 와 쌍을 이뤄야 합니다 (ActivityComponent 에는 ActivityScoped, FragmentComponent 에는 FragmentScoped 사용하기)

@Singleton
class MemoRepository @Inject constructor(
    private val db: MemoDatabase) 
{
    fun load(id: String) { ... }
}

같은 인스턴스

기본 컨포넌트 바인딩

 - ApplicationContext, ActivityContext 를 사용하여 적절한 Context 를 제공받을 수 있다

 

Hilt Module

 

 @InstallIn 

 - Hilt 가 생성하는 DI 컨테이너에 어떤 모듈을 사용할 지 가리킨다. 

 - 해당 모듈이 어떤 컴포넌트에 설치될 것인지 지정 해서 컴파일 타임에 관련 코드를 만든다.(올바르지 않은 컴포넌트 혹은 스코프를 사용하게 되면 오류를 발생한다)

@InstallIn(ActivityComponent::class)
@Module
object MyModule {
    ...
}

당연히 ActivityComponent 에 설치된다

혹여나 FragmentComponent 에서도 MyModule 을 사용하고 싶으면 어떻게 하면 될까?

ActivityComponent 에 설치하면 된다. 하위 컴포넌트은 상위 컴포넌트를 참조할 수 있다.

 

Hilt Module 의 제약사항

 - @Module 클래스에 @InstallIn 이 없으면 컴파일 에러가 발생한다.

 - 혹여나 Dagger2 -> Hilt 로 마이그레이션 하면 컴파일 에러가 발생할 수 있으니 이때는 아래와 같이 해결할 수 있다

// @InstallIn 검사 비활성화
android {
    defaultConfig {
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += ["dagger.hilt.disableModulesHaveInstallInCheck":"true"]
            }
        }
    }
}

 

@EntryPoint ?

 - Hilt 가 지원하지 않는 클래스에서 의존성이 필요한 경우 사용한다.

(예 : ContentProvider, DFM, Dagger 를 사용하지 않는 3rd-part 라이브러리 등)

 

특징

 - @EntryPoint 는 인터페이스에서만 사용할 수 있다

 - @InstallIn 과 반드시 함께 사용해야한다

 - EntryPoints 클래스의 정적 메서드를 통해 그래프에 접근한다

// EntryPoint 생성하기
@EntryPoint
@InstallIn(ApplicationComponent::class)
intreface FooBarInterface {
    fun getBar: Bar
}

// EntryPoint 로 접근하기
val bar = EntryPoints.get(application, FooBarInterface::class.java).getBar()

 

@EntryPoint 를 사용하여 의존성 주입받기 실전편

MemoRepository 를 주입받는 ContentProvider

위의 그림처럼 ContentProvider 가 MemoRepository 를 주입받아 사용할 수 있도록 만들어보자

@EntryPoint
@InstallIn(ApplicationComponent::class)
interface MemoEntryPoint {
    fun getRepository(): MemoRepository
}

class MemoProvider: ContentProvider() {
    override fun query() {
        // EntryPointAccessors 클래스는 EntryPoint 를 쉽게 가져올 수 있도록 도와주는 클래스 입니다
        val entryPoint = EntryPointAccessors.fromApplication(context, MemoEntryPoint::class.java)
        val repository = entryPoint.getRepository()
    }
}

 

@EntryPoint 사용하기 실전 2편 DFM

DFM 이 적용된 그래프

 - DFM 이 APP 모듈에 의존하게 됨 (의존성 역전 - DFM 특징)

 - APP 모듈은 DFM 을 참조하지 못하기 때문에 컴파일 타임에서 Subcomponent 구조로 그래프를 생성하는 것이 불가능하다

 - 하지만 Component inject 을 사용하여 가능하다

// 구현해보기

@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
    }
}
// 적용해보기
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)
    }
}

 

그 외 Hilt 에 대한 내용들

 

AndroidX Extensions

 - Hilt 는 Jetpack 라이브러리 클래스를 위해 Extension 을 제공한다

현재 지원하는 jetpack 컴포넌트로는 ViewModel, WorkManager 가 있다.

 

Hilt 로 ViewModel 주입하기

class MemoViewModel @ViewModelInject constructor(
    private val repository: MemoRepository,
    @Assisted private val savedStateHandle: SavedStateHandle
): ViewModel() { ... }

@AndroidEntryPoint
class MemoActivity: AppCompatActivity() {
    pirvate val viewModel: MemoViewModel by viewModels()
}

 

Hilt 로 WorkManager 주입하기

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()
    
}

 

Custom Component

 - 표준 Hilt 컴포넌트 이외에 새로운 컴포넌트를 만들 수 있다

 - 커스텀 컴포넌트를 사용하면 복잡하고 이해하기 어려워지기 때문에 꼭 필요한 경우만 사용한다.

 

사용하는 Annotation

 - @DefineComponent

 - @DefineComponent.Builder

 

제약조건

 - 반드시 APplicationComponent 의 하위 계층의 컴포넌트로 만들어야 한다.

 - 표준 컴퍼넌트 계층 사이에 추가할 수 없다.

 

 

Hilt 의 설계 철학

Monolithic components 특징

 - Single binding key space (특정 바인딩이 어디로 부터 왔는지 추적하기 쉽다, 코드량도 줄어든다)

 - 간단한 설정 (모듈이 설치되는 부분될 수 있는 부분들을 줄이기 때문에 설정 및 테스트가 간단해진다)

 - 생성되는 코드 줄인다 (모듈이 여러 subcomponent 에 사용되게 되면, 반복적으로 생기게 된다 - activity, fragment, view 등을 통해 급격하게 늘어날 수 있다)