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 의 특징
- Dagger 와 다르게 직접적으로 인스턴스화 할 필요가 없다. (바이트 코드에서 조작하기 때문에)
- 표준화된 컴포넌트 세트와 스코프를 제공한다 (BuiltIn)
- 컴포넌트들은 계층으로 이루어져 있으며, 하위 컴포넌트는 상위 컴포넌트의 의존성에 접근할 수 있다(Subcomponent)
Hilt Scope
- 표준화된 스코프를 제공한다
- retained 가 붙어있는 scope 는 Configuration(language, orientation 등) 에도 유지된다
@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 {
...
}
혹여나 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 를 사용하여 의존성 주입받기 실전편
위의 그림처럼 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 이 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 등을 통해 급격하게 늘어날 수 있다)
'Android > Development Tips' 카테고리의 다른 글
Proguard 로 난독화 된 코드 복호화 (0) | 2021.05.31 |
---|---|
Bitmap, contentUri 를 Retrofit RequestBody 에 담을 때 Tips (1) | 2021.05.17 |
DialogFragment 를 상속하는 다이얼로그에서 dismiss 할 때 Tips (0) | 2021.01.10 |
java.lang.IllegalStateException: Software rendering doesn't support hardware bitmaps 에러 수정하기 (1) | 2021.01.10 |
다양한 뷰타입을 가지는 Recyclerview 만들기 (0) | 2020.12.26 |