[안드로이드] Android 앱에 Hilt 사용
https://developer.android.com/codelabs/android-hilt?hl=ko#0
Android 앱에서 Hilt 사용 | Android 개발자 | Android Developers
이 Codelab에서는 Hilt를 사용하여 종속 항목 삽입을 실행하는 Android 앱을 빌드해 보겠습니다.
developer.android.com
이 글은 안드로이드 developers에서 제공하는 Android 앱에서 Hilt 사용 코드랩을 공부하며 작성한 글입니다.
DI using Hilt
DI는 프로그래밍에서 널리 사용되는 기법으로, DI 원칙을 따르면 훌륭한 앱 아키텍처를 설계할 수 있다. DI를 구현하면 다음과 같은 이점이 있다.
- 코드 재사용 가능
- 리팩터링 편의성
- 테스트 편의성
Hilt는 독보적인 Android용 의존성 주입 라이브러리로 프로젝트에서 수동 DI를 사용하는 상용구 코드를 줄여준다. 수동으로 DI를 하려면 모든 클래스와 클래스의 종속 항목을 수동으로 생성하고 *컨테이너를 사용해 종속항목을 재사용하고 관리해야 한다.
* 컨테이너는 코드베이스에 종속 항목을 제공하는 클래스로, 다른 유형의 앱 인스턴스를 만드는 데 사용된다. 이러한 인스턴스를 생성하고 수명 주기를 관리하여 인스턴스를 제공하는 데 필요한 종속 항목 그래프를 관리한다. |
Hilt는 이러한 불편함을 줄이며, 프로젝트의 모든 안드로이드 구성요소에 컨테이너를 제공하고, 컨테이너의 수명 주기를 자동으로 관리한다.
예제 프로그램의 시작 코드를 살펴보면 ServiceLocater 클래스의 인스턴스를 확인할 수 있다.
ServiceLocater는 요구되는 클래스에서 요청에 따라 가져온 종속 항목을 만들고 저장한다.
즉, 앱이 소멸될 때 함께 소멸되므로 앱의 수명 주기에 연결되는 종속 항목의 컨테이너라고 할 수 있다.
service locater는 비교적 적은 상용구로 시작하지만 확장성이 떨어지며, 테스팅이 어렵다.
Hilt는 개발자가 직접 작성해야 하는 코드(ex. ServiceLocater 클래스)를 자동으로 생성하여 안드로이드 앱에서 수동 DI 또는 서비스 로케이터 패턴을 사용해야 하는 불필요한 상용구를 삭제한다.
프로젝트에서 Hilt 사용
Hilt는 현재 Android 요소 중 Application(@HiltAndroidAp), Activity, Fragment, View, Service, BroadcastReceiver를 지원한다. 여기서 주의할 점은 Jetpack 라이브러리 Fragment를 확장하는 Fragment만 지원한다는 점이다.
build.gradle(Project)
buildscript {
...
ext.hilt_version = '2.28-alpha'
dependencies {
...
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}
build.gradle(Module)
...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
...
dependencies {
...
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}
1. 애플리케이션에서 Hilt 사용
앱의 수명주기와 연결된 컨테이너를 추가하려면 @HiltAndroidApp을 Application 클래스에 주석을 달아야 한다.
@HiltAndroidApp은 DI를 사용할 수 있는 애플리케이션의 기본 클래스를 비롯하여 Hilt 코드 생성을 트리거한다.
이 Hilt 구성요소는 Application 객체의 수명 주기에 연결되며 이와 관련된 종속성(dependencies)을 제공한다.
Application 컨테이너는 앱의 상위 컨테이너이므로 다른 컨테이너는 이 상위 컨테이너에서 제공하는 종속 항목에 액세스할 수 있다.
* 컨테이너: 인스턴스를 저장하는 공간
-> 컨테이너는 참조가 필요한 클래스의 인스턴스를 생성해주고, 수명 주기를 관리해 인스턴스를 제공한다.
@HiltAndroidApp
class LogApplication : Application() {
...
}
Hilt를 사용하는 모든 앱은 @HiltAndroidApp 주석이 지정된 Application 클래스를 포함해야 한다!
@HiltAndroidApp 클래스는 Application Container이다.
2. Hilt로 필드 삽입
이제 ServiceLocater에서 종속 항목을 가져오는 대신 Hilt를 사용해 종속 항목을 제공한다.
즉, Android 클래스에 dependency를 inject하는 과정이다.
LogsFragment의 onAttach 메서드를 보면 ServiceLocater를 통해 직접 인스턴스를 채우는 걸 알 수 있다.
이 대신 Hilt를 사용하여 이러한 유형의 인스턴스를 생성하고 관리할 수 있다.
Hilt를 통해 필드를 받으려면 @AndroidEntryPoint로 주석을 달아야 한다.
쉽게 말해, Hilt는 @AndroidEntryPoint 주석이 있는 클래스에 dependencies를 제공할 수 있다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
...
}
Android 클래스에 @AndroidEntryPoint 주석을 달면 Android 클래스 수명 주기를 따르는 종속 항목 컨테이너가 생성된다.
즉, LogsFragment의 수명 주기에 연결된 종속 항목 컨테이너를 생성하고, 해당 프래그먼트에 인스턴스를 삽입할 수 있다.
필드는 @Inject 주석을 사용해 Hilt에서 삽입하려는 다른 유형의 인스턴스를 삽입하도록 할 수 있다.
이를 필드 삽입이라고 한다. private한 필드에는 주입되지 않는 점을 주의하자!
@AndroidEntryPoint
class LogsFragment : Fragment() {
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var dateFormatter: DateFormatter
...
}
이렇게 하면 Hilt에서 위 선언된 필드를 대신 채워주기 때문에 ServiceLocater를 통해 필드를 채우던 메서드는 더 이상 필요하지 않다. 내부적으로 Hilt는 자동 생성된 LogsFragment의 종속 항목 컨테이너에서 빌드한 인스턴스를 사용해 이러한 필드를 채운다.
2.1 Hilt Binding
위 필드를 채우기 위해선 Hilt가 LoggerLocalDataSource, DateFormatter의 인스턴스 제공 방법을 알아야 한다.
필드 주입을 위해 Hilt에게 해당 인스턴스를 제공하는 방법을 알려주는 것을 Binding이라고 한다.(이 예제에서는 2개의 Binding)
Hilt에게 바인딩 정보를 제공하는 방법은 두 가지가 있다.
- 생성자 주입
- Hilt 모듈
아래 방법은 생성자를 주입하는 방법을 사용한다.
인스턴스 제공 방법은 위 두 클래스에 @Inject 주석을 붙이면 된다.
유형의 인스턴스 제공 방법을 Hilt에 알리려면 삽입하려는 클래스의 생성자에 @Inject 주석을 추가해라!
코틀린에서 생성자에 주석을 달려면 constructor 키워드도 필요하다.
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
@Inject 주석을 통해 종속 항목의 인스턴스를 생성하고, 매개변수로 종속항목을 주입하는 것이다.
* 필드 삽입과 클래스가 인스턴스를 제공하게 하려면 Hilt에서 삽입하려는 Android 클래스의 필드와 에 @Inject 주석을 사용해라!
3. 인스턴스 범위를 컨테이너로 지정
class ServiceLocator(applicationContext: Context) {
...
val loggerLocalDataSource = LoggerLocalDataSource(logsDatabase.logDao())
}
위 코드에서 ServiceLocater는 호출될 때마다 항상 동일한 LoggerLocalDataSource 인스턴스를 반환한다.
이를 '인스턴스 범위를 컨테이너로 지정'이라고 한다.
Hilt에서는 @Singleton 주석을 통해 인스턴스 범위를 애플리케이션 컨테이너로 지정한다.
이 주석을 사용하면 유형이 다른 유형의 종속 항목으로 사용되는지 또는 삽입된 필드여야 하는지와 관계없이 애플리케이션 컨테이너에서 항상 같은 인스턴스를 제공한다.
@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
Hilt는 수명 주기가 다른 여러 컨테이너를 생성할 수 있으므로 이러한 컨테이너로 범위가 지정된 다양한 주석이 있다.
예를 들어, Activity 컨테이너에서 특정 유형에 관해 동일한 인스턴스를 제공하게 하려면 유형에 @ActivityScoped 주석을 추가하면 된다.
4. Hilt 모듈
@Inject constructor와 @Singleton 주석을 통해 Hilt가 인스턴스 제공 방법을 알게 되었으며, 항상 같은 인스턴스를 제공하게 되었다. 하지만 이번에는 전이 종속 항목이 있다. LoggerLocalDataSource 인스턴스를 제공하려면 Hilt에서 LogDao 인스턴스를 제공하는 법도 알아야 한다. LogDao는 인터페이스이므로 생성자가 없어 @Inject 주석을 달 수 없다.
이럴 때 위에서 말한 Hilt 모듈을 통해 Hilt에 Binding을 추가한다.
즉, 모듈을 사용하여 Hilt에 다양한 유형의 인스턴스 제공방법을 알려준다.
인터페이스나 프로젝트에 포함되지 않은 클래스와 같이 생성자가 삽입될 수 없는 유형의 결합을 Hilt 모듈에 포함한다.
Hilt 모듈은 @Module과 @InstallIn 주석이 달린 클래스이다.
- @Module: Hilt에 Module임을 알린다.
- @InstallIn: 어느 컨테이너에서 Hilt 구성요소를 지정하여 결합을 사용할 수 있는지 Hilt에 알린다.
Hilt에서 삽입할 수 있는 Android 클래스마다 연결된 Hilt 구성요소가 있다. 예를 들어, Application 컨테이너는 ApplicationComponent와 연결되며 Fragment 컨테이너는 FragmentComponent와 연결된다.
- 모듈 생성
@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {
}
LoggerLocalDataSource는 애플리케이션 컨테이너로 범위가 지정되므로 @InstallIn에 애플리케이션 컨테이너에 연결된 Hilt 구성요소 클래스를 전달한다.
- @Provide로 인스턴스 제공
Hilt 모듈 내 함수에 @Provides 주석을 달아 Hilt에 생성자가 삽입될 수 없는 유형의 인스턴스 제공 방법을 알려준다.
@Provides 주석이 있는 함수 본문은 Hilt에서 이 유형의 인스턴스를 제공해야 할 때마다 실행됩니다.
@Provides 주석이 있는 함수의 반환 유형은 Hilt에 결합 유형 또는 유형의 인스턴스 제공 방법을 알려 줍니다. 함수 매개변수는 유형의 종속 항목입니다.
@Module
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
return Room.databaseBuilder(
appContext,
AppDatabase::class.java,
"logging.db"
).build()
}
@Provides
fun provideLogDao(database: AppDatabase): LogDao {
return database.logDao()
}
}
AppDatabase가 전이 종속 항목이므로 Hilt에 이 유형의 제공방법도 알려줘야 한다.
AppDatabase는 Room에서 생성하지 않으므로 프로젝트에서 소유하지 않는 다른 클래스이기 때문에 데이터베이스 인스턴스를 빌드하는 방식과 비슷하게 @Provides 함수를 사용해 유형 제공 방법을 알린다. 항상 동일한 데이터베이스 인스턴스를 제공하기 위해 @Singleton 주석도 추가한다. 각 Hilt 컨테이너는 맞춤 결합에 종속 항목으로 삽입될 수 있는 일련의 기본 결합을 제공한다. 이는applicationContext의 사례로, 액세스하려면 필드에@ApplicationContext주석을 달아야 한다.
이제 LogsFragment에 인스턴스를 삽입하는 데 필요한 모든 정보를 갖고 있다.
하지만 Fragment를 호스팅하는 Activity에 대한 정보를 모르기 때문에 MainActivity에 @AndroidEntryPoint 주석을 추가한다.
5. @Binds로 인터페이스 제공
인터페이스에 사용할 구현을 Hilt에 알리려면 Hilt 모듈 내 함수에 @Binds 주석을 사용하면 된다.
@Provides와 @Binds 모두 생성자를 통해 필드를 삽입할 수 없는 경우 Hilt 모듈 내에서 사용된다는 공통점이 있지만, @Provides는 외부 라이브러리에 사용하고, @Binds는 인터페이스에 사용한다.
@Binds 주석은 추상 함수에 달아야 하며, 유형의 범위가 지정된 경우 범위 지정 주석이 있어야 한다.(ex. @Singleton, ActivityScopted)
추상 함수의 반환 유형은 구현을 제공하려는 인터페이스이다.
구현은 인터페이스 구현 유형(ex. AppNavigatorImpl)으로 고유한 매개변수를 추가하여 지정된다.
하지만 지금 존재하는 DatabaseModule 클래스에 함수를 추가하지 못하고 새로운 모듈을 만들어야 한다. 이유는 다음과 같다.
- 지금 존재하는 모듈은 데이터와 관련된 DatabaseModule이지만 현재 추가하려는 인터페이스는 탐색과 관련되어 있다.
- DatabaseModule은 애플리케이션 컨테이너에서 결합을 사용할 수 있지만, 새 탐색 정보(AppNavigator)에는 활동의 특정 정보가 필요하기 때문에 Activity 컨테이너에 설치해야 한다.
- Hilt 모듈에 비정적 결합 메서드(@Provides)와 추상 결합 메서드(@Binds)를 모두 포함할 수 없다.
@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
@Binds
abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}
이 후 AppNavigatorImpl에도 @Inject 주석을 추가하면 Hilt로 의존성 주입하는 모든 단계가 완료된 것이다.
ServiceLocater와 관련된 코드를 정리해도 무관하다.
6. 한정자
한 인터페이스의 다양한 구현은 서로 다른 컨테이너로 범위가 지정되므로 동일한 모듈을 사용할 수 없다.
LoggerInMemoryDataSource의 범위는 Activity 컨테이너로, LoggerLocalDataSource는 Application 컨테이너로 지정된다.
따라서 다음 코드는 같은 인터페이스를 사용하는 두 모듈을 생성한 것이다.
@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {
@DatabaseLogger // 아래 설명한 한정자가 여기에 추가됨
@Singleton
@Binds
abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}
@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {
@InmemoryLogger
@ActivityScoped
@Binds
abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}
지금 LoggerDataSource를 사용하려고 하면 Hilt는 같은 유형(LoggerDataSource)에 두 개의 결합이 있어 어느 구현을 사용해야 하는지 모르기 때문에 DuplicateBindings 오류가 발생한다.
Hilt에 동일한 유형의 다른 구현(여러개의 결합)을 제공하는 방법을 알리려면 한정자를 사용한다.
한정자는 결합(Binding)을 식별하는 데 사용되는 주석이므로, 구현별로 한정자를 정의해야 한다.위 모듈 파일에 한정자를 다음과 같이 정의할 수 있다.
@Qualifier
annotation class InMemoryLogger
@Qualifier
annotation class DatabaseLogger
이러한 한정자는 각 구현을 제공하는 @Binds(또는 Provides) 함수에 주석을 달아 어느 결합에 사용됐는 지 알린다.
* @DatabaseLogger 한정자는 ApplicationComponent에 설치되므로 LogApplication 클래스에 삽입될 수 있다. 그러나, @InMemoryLogger가 ActivityComponent에 설치되므로 애플리케이션 컨테이너에서 이 결합을 알 수 없기 때문에 이 한정자는 LogApplication 클래스에 삽입될 수 없다.
또한, 이러한 한정자는 삽입하려는 구현과 함께 삽입 지점에서 사용해야 한다.
같은 유형에 여러 결합이 있는 경우, 해당 유형을 사용할 때 어떤 결합으로 사용되는 지 한정자를 통해 알려줘야 한다는 뜻이다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
...
}
LogsFragment의 logger에서는 @InMemoryLogger 한정자로 구현된 인터페이스가 사용된다.
7. UI 테스트
Hilt는 각 테스트의 새로운 구성요소 집합을 자동 생성하므로 Hilt를 사용한 테스트에는 유지관리가 필요하지 않다.
테스트 종속 항목 추가
dependencies {
// Hilt testing dependency
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
// Make Hilt generate code in the androidTest folder
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
}
맞춤 TestRunner
Hilt를 사용하여 계측된 테스트는 Hilt를 지원하는 Application에서 실행되어야 한다.
따라서 테스트에 사용할 Application을 지정하기 위해 새 테스트 실행기를 생성해야 한다.
androidTest 폴더 아래 다음 파일을 작성한다.
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
위 테스트 실행기를 계측 테스트에서 사용할 수 있도록 프로젝트에 알려야 한다.
bulild.gradle(Module)의 testInstrumentationRunner 속성에 테스트 실행기 값을 아래와 같이 바꾼다.
android {
...
defaultConfig {
...
testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
}
...
}
테스트 클래스에서 Hilt를 사용하려면 다음 과정을 진행해야 한다.
- 각 테스트의 Hilt 구성요소 생성을 담당하는 @HiltAndroidTest 주석을 추가합니다.
- 구성요소의 상태를 관리하고 테스트에 삽입을 실행하는 데 사용되는 HiltAndroidRule을 사용합니다.
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
...
}
8. @EntryPoint
Hilt에서 지원하지 않는 클래스에 종속 항목을 삽입하기 위해선 @EntryPoint 주석을 사용하면 된다.
EntryPoint는 종속 항목을 삽입하는 데 Hilt를 사용할 수 없는 코드에서 Hilt가 제공하는 객체를 가져올 수 있는 경계 지점이다.
구글 Codelab을 통해 Hilt를 사용하는 방법을 알아보았다. 초반까지는 괜찮았는데 한 클래스에 종속된 인터페이스를 또 주입하고 그런 과정이 반복되다 보니 확실히 복잡하긴 했다. 다른 예제나 설명들 더 찾아보고 적용해봐야 할 듯!