이 Codelab에서는 대규모 프로젝트로 확장할 수 있는 견고한 확장형 애플리케이션을 만들기 위한 종속 항목 삽입(DI)의 중요성을 알아봅니다. 여기서는 Hilt를 DI 도구로 사용하여 종속 항목을 관리합니다.
DI는 프로그래밍에 널리 사용되는 기법으로, Android 개발에 적합합니다. DI의 원칙을 따르면 훌륭한 앱 아키텍처를 위한 토대를 마련할 수 있습니다.
DI를 구현하면 다음과 같은 이점이 있습니다.
- 코드 재사용 가능
- 리팩터링 편의성
- 테스트 편의성
Hilt는 독보적인 Android용 종속 항목 삽입 라이브러리로 프로젝트에서 수동 DI를 사용하는 상용구 코드를 줄여줍니다. 종속 항목을 직접 삽입하려면 모든 클래스와 클래스의 종속 항목을 수동으로 생성하고 컨테이너를 사용하여 종속 항목을 재사용하고 관리해야 합니다.
Hilt는 애플리케이션에 DI를 삽입하는 표준 방식으로, 프로젝트의 모든 Android 구성요소에 컨테이너를 제공하고 컨테이너의 수명 주기를 자동으로 관리합니다. 이 방식은 많이 사용하는 DI 라이브러리인 Dagger를 활용한 것입니다.
이 Codelab을 진행하는 동안 코드 버그, 문법 오류, 불명확한 문구 등의 문제가 발생하면 Codelab 왼쪽 하단에 있는 오류 신고 링크를 통해 신고해 주세요.
기본 요건
- Kotlin 구문 사용 경험
- 애플리케이션에 종속 항목 삽입이 중요한 이유에 관한 이해
학습할 내용
- Android 앱에서 Hilt를 사용하는 방법
- 지속 가능한 앱 제작과 관련된 Hilt 개념
- 한정자를 사용하여 동일한 유형에 결합을 여러 개 추가하는 방법
@EntryPoint
를 사용하여 Hilt에서 지원하지 않는 클래스의 컨테이너에 액세스하는 방법- 단위 테스트 및 계측 테스트를 사용하여 Hilt를 사용하는 애플리케이션을 테스트하는 방법
필요한 항목
- Android 스튜디오 4.0 이상
코드 가져오기
GitHub에서 Codelab 코드를 가져옵니다.
$ git clone https://github.com/googlecodelabs/android-hilt
또는 저장소를 ZIP 파일로 다운로드할 수 있습니다.
Android 스튜디오 열기
이 Codelab을 사용하려면 Android 스튜디오 4.0 이상이 필요합니다. Android 스튜디오를 다운로드하려면 여기를 방문하세요.
샘플 앱 실행
이 Codelab에서는 사용자 상호작용을 로깅하고 Room을 사용하여 로컬 데이터베이스에 데이터를 저장하는 애플리케이션에 Hilt를 추가합니다.
다음 안내에 따라 Android 스튜디오에서 샘플 앱을 엽니다.
- ZIP 보관 파일을 다운로드했다면 파일을 로컬에서 압축 해제합니다.
- Android 스튜디오에서 프로젝트를 엽니다.
- Run 버튼을 클릭하고 에뮬레이터를 선택하거나 Android 기기를 연결합니다.
위 이미지와 같이 번호가 매겨진 버튼 중 하나와 상호작용할 때마다 로그가 생성되어 저장됩니다. See All Logs 화면에서 이전 상호작용을 모두 볼 수 있습니다. 로그를 삭제하려면 Delete Logs 버튼을 탭합니다.
프로젝트 설정
이 프로젝트는 GitHub의 여러 분기에서 빌드됩니다.
master
는 개발자가 확인하거나 다운로드한 분기로 Codelab의 시작점입니다.solution
에는 이 Codelab의 솔루션이 포함되어 있습니다.
master
분기로 시작하고 각자의 속도에 맞게 Codelab을 단계별로 따라하는 것이 좋습니다.
Codelab을 진행하는 중에 프로젝트에 추가해야 하는 코드 스니펫이 제공됩니다. 코드 스니펫의 댓글에 명시된 코드를 삭제해야 하는 경우도 있을 수 있습니다.
git을 사용하여 solution
분기를 가져오려면 다음 명령어를 사용합니다.
$ git clone -b solution https://github.com/googlecodelabs/android-hilt
또는 다음 위치에서 솔루션 코드를 다운로드합니다.
자주 묻는 질문(FAQ)
Hilt를 사용하는 이유
시작 코드를 살펴보면 LogApplication
클래스에 저장된 ServiceLocator
클래스의 인스턴스를 확인할 수 있습니다. ServiceLocator
는 요구되는 클래스에서 요청에 따라 가져온 종속 항목을 만들고 저장합니다. 이 클래스는 앱이 소멸될 때 함께 소멸되므로 앱의 수명 주기에 연결되는 종속 항목의 컨테이너라고 생각할 수 있습니다.
Android DI 가이드에 설명한 대로 서비스 로케이터는 비교적 적은 상용구 코드로 시작하지만 확장성도 떨어집니다. Android 앱을 대규모로 개발하려면 Hilt를 사용해야 합니다.
Hilt는 개발자가 직접 작성했어야 하는 코드(예: ServiceLocator
클래스 코드)를 생성하여 Android 애플리케이션에서 수동 DI 또는 서비스 로케이터 패턴을 사용해야 하는 불필요한 상용구를 삭제합니다.
다음 단계에서는 Hilt를 사용하여 ServiceLocator
클래스를 대체합니다. 그다음, 프로젝트에 새 기능을 추가하여 Hilt 기능에 관해 더 살펴보겠습니다.
프로젝트에서 Hilt 사용
Hilt는 이미 master
분기(다운로드한 코드)에 구성되어 있습니다. 다음의 코드는 이미 생성되었으므로 프로젝트에 포함하지 않아도 됩니다. 그렇지만 Android 앱에서 Hilt를 사용하기 위해 무엇이 필요한지 살펴보겠습니다.
라이브러리 종속 항목과 별개로 Hilt는 프로젝트에서 구성된 Gradle 플러그인을 사용합니다. 루트의 build.gradle
파일을 열고 클래스 경로에서 다음의 Hilt 종속 항목을 확인하세요.
buildscript {
...
ext.hilt_version = '2.28-alpha'
dependencies {
...
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}
그런 다음, app
모듈에서 Gradle 플러그인을 사용하기 위해 아래 kotlin-kapt
플러그인을 app/build.gradle
파일의 최상단에 추가하여 파일에 플러그인을 지정합니다.
...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
android {
...
}
마지막으로, 동일한 app/build.gradle
파일의 프로젝트에 Hilt 종속 항목을 포함합니다.
...
dependencies {
...
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}
Hilt를 비롯한 모든 라이브러리는 프로젝트를 빌드하고 동기화할 때 다운로드됩니다. 이제 Hilt를 사용해 보세요.
LogApplication
클래스의 ServiceLocator
인스턴스를 사용하고 초기화하는 방식과 마찬가지로, 앱의 수명 주기에 연결된 컨테이너를 추가하려면 @HiltAndroidApp
으로 Application
클래스에 주석을 달아야 합니다. LogApplication.kt
를 열고 다음과 같이 클래스에 주석을 추가합니다.
@HiltAndroidApp
class LogApplication : Application() {
...
}
@HiltAndroidApp
은 종속 항목 삽입을 사용할 수 있는 애플리케이션의 기본 클래스가 포함된 Hilt 코드 생성을 트리거합니다. 애플리케이션 컨테이너는 앱의 상위 컨테이너이므로 다른 컨테이너는 이 상위 컨테이너에서 제공하는 종속 항목에 액세스할 수 있습니다.
이제 앱에서 Hilt를 사용할 준비가 되었습니다.
필요 시 클래스의 ServiceLocator
에서 종속 항목을 가져오는 대신 Hilt를 사용하여 이러한 종속 항목을 제공합니다. 이제 이 클래스의 ServiceLocator
호출을 대체해 보겠습니다.
ui/LogsFragment.kt
파일을 엽니다. LogsFragment
은 onAttach
에서 필드를 채웁니다. ServiceLocator
를 사용하여 직접 LoggerLocalDataSource
와 DateFormatter
인스턴스를 채우는 대신 Hilt를 사용하여 이러한 유형의 인스턴스를 생성하고 관리할 수 있습니다.
LogsFragment
에서 Hilt를 사용하려면 @AndroidEntryPoint
로 주석을 달아야 합니다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
...
}
Android 클래스에 @AndroidEntryPoint
주석을 달면 Android 클래스 수명 주기를 따르는 종속 항목 컨테이너가 생성됩니다.
@AndroidEntryPoint
를 사용하면 Hilt는 LogsFragment
의 수명 주기에 연결된 종속 항목 컨테이너를 생성하고 LogsFragment
에 인스턴스를 삽입할 수 있습니다. 어떻게 Hilt로 필드를 삽입할 수 있을까요?
@Inject
주석을 사용하여 Hilt에서 삽입하려는 다른 유형의 인스턴스(예: logger
, dateFormatter
)를 필드에 삽입하도록 할 수 있습니다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var dateFormatter: DateFormatter
...
}
이를 필드 삽입이라고 합니다.
Hilt에서 이러한 필드를 대신 채워주므로 더이상 populateFields
메서드가 필요하지 않습니다. 클래스에서 메서드를 삭제해 보겠습니다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
// Remove following code from LogsFragment
override fun onAttach(context: Context) {
super.onAttach(context)
populateFields(context)
}
private fun populateFields(context: Context) {
logger = (context.applicationContext as LogApplication).serviceLocator.loggerLocalDataSource
dateFormatter =
(context.applicationContext as LogApplication).serviceLocator.provideDateFormatter()
}
...
}
내부적으로 Hilt는 자동 생성된 LogsFragment
의 종속 항목 컨테이너에서 빌드한 인스턴스를 사용하여 onAttach()
수명 주기 메서드 내에서 이러한 필드를 채웁니다.
필드 삽입을 실행하려면 Hilt에서 이러한 종속 항목의 인스턴스를 어떻게 제공하는지 알아야 합니다. 이 경우 Hilt는 LoggerLocalDataSource
와 DateFormatter
의 인스턴스 제공 방법을 알아야 합니다. 하지만, Hilt는 아직 이러한 인스턴스를 제공하는 방법을 모릅니다.
@Inject로 Hilt에 종속 항목 제공 방법 알리기
ServiceLocator.kt
파일을 열어 ServiceLocator
의 구현 방법을 확인합니다. provideDateFormatter()
를 호출하면 어떻게 항상 다른 DateFormatter
인스턴스가 반환되는지 알 수 있습니다.
이것이 Hilt를 사용하여 얻고 싶은 결과입니다. 다행히 DateFormatter
는 다른 클래스에 종속되지 않으므로 현재로서는 전이 종속 항목에 관해 걱정하지 않아도 됩니다.
유형의 인스턴스 제공 방법을 Hilt에 알리려면 삽입하려는 클래스의 생성자에 @Inject 주석을 추가하세요.
util/DateFormatter.kt
파일을 열고 DateFormatter
의 생성자에 @Inject
주석을 추가합니다. Kotlin에서 생성자에 주석을 달려면 constructor
키워드도 필요합니다.
class DateFormatter @Inject constructor() { ... }
이로써 Hilt에서 DateFormatter
인스턴스 제공 방법을 알게 됩니다. LoggerLocalDataSource
도 같은 방식으로 완료합니다. data/LoggerLocalDataSource.kt
파일을 열고 생성자에 @Inject
주석을 추가합니다.
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
ServiceLocator
클래스를 다시 열어보면 공개 필드로 LoggerLocalDataSource
가 있는 것을 확인할 수 있습니다. 즉, ServiceLocator
는 호출될 때마다 항상 동일한 LoggerLocalDataSource
인스턴스를 반환합니다. 이를 '인스턴스 범위를 컨테이너로 지정'이라고 합니다. Hilt에서 이것을 어떻게 처리할 수 있을까요?
주석을 사용하여 인스턴스의 범위를 컨테이너로 지정할 수 있습니다. Hilt는 수명 주기가 다른 여러 컨테이너를 생성할 수 있으므로 이러한 컨테이너로 범위가 지정된 다양한 주석이 있습니다.
인스턴스 범위를 애플리케이션 컨테이너로 지정하는 주석은 @Singleton
입니다. 이 주석을 사용하면 유형이 다른 유형의 종속 항목으로 사용되는지 또는 삽입된 필드여야 하는지와 관계없이 애플리케이션 컨테이너에서 항상 같은 인스턴스를 제공합니다.
Android 클래스에 연결된 모든 컨테이너에는 동일한 로직을 적용할 수 있습니다. 문서에서 범위 지정 주석의 전체 목록을 확인할 수 있습니다. 예를 들어, 활동 컨테이너에서 항상 특정 유형에 관해 동일한 인스턴스를 제공하게 하려면 유형에 @ActivityScoped
주석을 달면 됩니다.
위에서 언급한 대로 애플리케이션 컨테이너에서 항상 동일한 LoggerLocalDataSource
인스턴스를 제공하도록 하기 위해 클래스에 @Singleton
주석을 추가합니다.
@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
이제 Hilt에서 LoggerLocalDataSource
인스턴스 제공 방법을 알고 있습니다. 하지만, 이번에는 유형에 전이 종속 항목이 있습니다. LoggerLocalDataSource
인스턴스를 제공하려면 Hilt에서 LogDao
인스턴스 제공 방법도 알아야 합니다.
그러나, LogDao
는 인터페이스이므로 생성자가 없기 때문에 생성자에 @Inject
로 주석을 달 수 없습니다. Hilt에 이러한 유형의 인스턴스 제공 방법을 어떻게 알릴 수 있을까요?
모듈을 사용하여 Hilt에 결합을 추가합니다. 즉, 모듈을 사용하여 Hilt에 다양한 유형의 인스턴스 제공 방법을 알려 줍니다. 인터페이스나 프로젝트에 포함되지 않은 클래스와 같이 생성자가 삽입될 수 없는 유형의 결합을 Hilt 모듈에 포함합니다. 빌더를 사용하여 인스턴스를 생성해야 하는 OkHttpClient
를 한 예로 들 수 있습니다.
Hilt 모듈은 @Module
과 @InstallIn
주석이 달린 클래스입니다. @Module
은 Hilt에 모듈임을 알려 주고 @InstallIn
은 어느 컨테이너에서 Hilt 구성요소를 지정하여 결합을 사용할 수 있는지 Hilt에 알려 줍니다. Hilt 구성요소는 컨테이너로 간주할 수 있으며 구성요소 전체 목록은 여기에서 확인할 수 있습니다.
Hilt에서 삽입할 수 있는 Android 클래스마다 연결된 Hilt 구성요소가 있습니다. 예를 들어, Application
컨테이너는 ApplicationComponent
와 연결되며 Fragment
컨테이너는 FragmentComponent
와 연결됩니다.
모듈 만들기
결합을 추가할 수 있는 Hilt 모듈을 만들어 보겠습니다. hilt
패키지 아래 di
라는 새 패키지를 만들고 패키지 내에 DatabaseModule.kt
라는 새 파일을 만듭니다.
LoggerLocalDataSource
는 애플리케이션 컨테이너로 범위가 지정되므로 애플리케이션 컨테이너에서 LogDao
결합을 사용할 수 있어야 합니다. 여기서는 애플리케이션 컨테이너에 연결된 Hilt 구성요소 클래스(예: ApplicationComponent:class
)를 전달하여 @InstallIn
주석으로 요구사항을 지정합니다.
package com.example.android.hilt.di
@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {
}
ServiceLocator
클래스 구현에서 LogDao
인스턴스는 logsDatabase.logDao()
를 호출하여 가져옵니다. 따라서, LogDao 인스턴스를 제공하기 위해 AppDatabase
클래스에 전이 종속 항목이 있습니다.
@Provides로 인스턴스 제공
Hilt 모듈에 있는 함수에 @Provides
주석을 달아 Hilt에 생성자가 삽입될 수 없는 유형의 제공 방법을 알려 줄 수 있습니다.
@Provides
주석이 있는 함수 본문은 Hilt에서 이 유형의 인스턴스를 제공해야 할 때마다 실행됩니다. @Provides
주석이 있는 함수의 반환 유형은 Hilt에 결합 유형 또는 유형의 인스턴스 제공 방법을 알려 줍니다. 함수 매개변수는 유형의 종속 항목입니다.
이 경우 DatabaseModule
클래스에 이 함수가 포함됩니다.
@Module
object DatabaseModule {
@Provides
fun provideLogDao(database: AppDatabase): LogDao {
return database.logDao()
}
}
위의 코드는 Hilt에 LogDao
인스턴스를 제공할 때 database.logDao()
가 실행되어야 한다고 알려 줍니다. AppDatabase
가 전이 종속 항목이므로 Hilt에 이 유형의 인스턴스 제공 방법도 알려 줘야 합니다.
AppDatabase
는 Room에서 생성하지 않으므로 프로젝트에서 소유하지 않는 다른 클래스이기 때문에 ServiceLocator
클래스에서 데이터베이스 인스턴스를 빌드하는 방식과 비슷하게 @Provides
함수를 사용하여 제공할 수도 있습니다.
@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()
}
}
Hilt에서 항상 동일한 데이터베이스 인스턴스를 제공하도록 하려면 @Provides provideDatabase
메서드에 @Singleton
주석을 추가합니다.
각 Hilt 컨테이너는 맞춤 결합에 종속 항목으로 삽입될 수 있는 일련의 기본 결합을 제공합니다. 이는 applicationContext
의 사례로, 액세스하려면 필드에 @ApplicationContext
주석을 달아야 합니다.
앱 실행
이제 Hilt는 LogsFragment
에 인스턴스를 삽입하는 데 필요한 모든 정보를 갖고 있습니다. 그러나, Hilt는 앱을 실행하기 전에 작동을 위해 Fragment
를 호스팅하는 Activity
를 알아야 합니다. MainActivity
에 @AndroidEntryPoint
로 주석을 달아야 합니다.
ui/MainActivity.kt
파일을 열고 MainActivity
에 @AndroidEntryPoint
주석을 추가합니다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }
이제 앱을 실행하고 모든 부분이 전과 같이 제대로 동작하는지 확인할 수 있습니다.
MainActivity
에서 ServiceLocator
호출을 삭제하도록 앱을 계속 리팩터링해 보겠습니다.
MainActivity
는 provideNavigator(activity: FragmentActivity)
함수를 호출하여 ServiceLocator
에서 AppNavigator
인스턴스를 가져옵니다.
AppNavigator
는 인터페이스이므로 생성자 삽입을 사용할 수 없습니다. 인터페이스에 사용할 구현을 Hilt에 알리려면 Hilt 모듈 내 함수에 @Binds
주석을 사용하면 됩니다.
@Binds
주석은 추상 함수에 달아야 합니다(이 함수는 추상 함수이므로 코드를 포함하지 않고 클래스도 추상화되어야 함). 추상 함수의 반환 유형은 구현을 제공하려는 인터페이스(예: AppNavigator
)입니다. 구현은 인터페이스 구현 유형(예: AppNavigatorImpl
)으로 고유한 매개변수를 추가하여 지정됩니다.
이전에 생성한 DatabaseModule
클래스에 정보를 추가할 수 있을까요? 아니면 새 모듈이 필요할까요? 새 모듈을 만들어야 하는 데는 다음과 같은 몇 가지 이유가 있습니다.
- 효율적인 구성을 위해 모듈 이름은 제공하는 정보 유형을 전달해야 합니다. 예를 들어,
DatabaseModule
이라는 모듈에 탐색 결함을 포함하는 것은 적절하지 않습니다. DatabaseModule
모듈은ApplicationComponent
에 설치되므로 애플리케이션 컨테이너에서 결합을 사용할 수 있습니다. 새 탐색 정보(예:AppNavigator
)에는 활동의 특정 정보가 필요합니다(AppNavigatorImpl
은Activity
를 종속 항목으로 포함하기 때문). 따라서,Application
컨테이너가 아닌Activity
정보를 사용할 수 있는Activity
컨테이너에 설치해야 합니다.- Hilt 모듈에는 비정적 결합 메서드와 추상 결합 메서드를 모두 포함할 수 없으므로 동일한 클래스에
@Binds
와@Provides
주석을 배치하면 안 됩니다.
di
폴더에 NavigationModule.kt
라는 새 파일을 만듭니다. 여기에서는 위에 설명한 것처럼 @Module
및 @InstallIn(ActivityComponent::class)
주석이 달린 NavigationModule
이라는 새 추상 클래스를 만들어 보겠습니다.
@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
@Binds
abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}
모듈 내에서 AppNavigator
의 결합을 추가할 수 있습니다. 이는 Hilt에 알려 주고 있는 인터페이스를 반환하는 추상 함수(예: AppNavigator
)이며 매개변수는 인터페이스의 구현(예: AppNavigatorImpl
)입니다.
이제 AppNavigatorImpl
인스턴스 제공 방법을 Hilt에게 알려 줘야 합니다. 이 클래스처럼 생성자를 삽입할 수 있는 경우 생성자에 @Inject
주석을 추가하기만 하면 됩니다.
navigator/AppNavigatorImpl.kt
파일을 열고 다음과 같이 합니다.
class AppNavigatorImpl @Inject constructor(
private val activity: FragmentActivity
) : AppNavigator {
...
}
AppNavigatorImpl
은 FragmentActivity
에 종속됩니다. AppNavigator
인스턴스가 Activity
컨테이너에 제공될 때(이 인스턴스는 NavigationModule
이 ActivityComponent
에 설치되어 있으므로 Fragment
컨테이너와 View
컨테이너에서도 사용할 수 있음) 사전 정의된 결합으로 제공되므로 이미 FragmentActivity
를 사용할 수 있습니다.
활동에서 Hilt 사용
이제 Hilt에서 AppNavigator
인스턴스를 삽입할 수 있는 모든 정보를 보유하고 있습니다. MainActivity.kt
파일을 열고 다음 안내를 따릅니다.
- Hilt에서 가져올 수 있도록
navigator
필드에@Inject
주석을 추가합니다. - 공개 상태 수정자
private
을 삭제합니다. onCreate
함수에서navigator
초기화 코드를 삭제합니다.
새 코드는 다음과 같아야 합니다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var navigator: AppNavigator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState == null) {
navigator.navigateTo(Screens.BUTTONS)
}
}
...
}
앱 실행
앱을 실행하여 예상대로 작동하는지 확인할 수 있습니다.
리팩터링 완료
종속 항목을 가져오기 위해 아직 ServiceLocator
를 사용하는 클래스는 ButtonsFragment
뿐입니다. Hilt는 ButtonsFragment
에 필요한 모든 유형의 제공 방법을 이미 알고 있으므로 클래스에서 필드 삽입만 실행하면 됩니다.
이전에 배운 대로 클래스를 Hilt에서 삽입한 필드로 만들려면 다음과 같이 합니다.
ButtonsFragment
에@AndroidEntryPoint
주석을 추가합니다.logger
와navigator
필드의 비공개 수정자를 삭제하고@Inject
주석을 추가합니다.- 필드 초기화 코드(예:
onAttach
및populateFields
메서드)를 삭제합니다.
ButtonsFragment
코드는 다음과 같습니다.
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var navigator: AppNavigator
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_buttons, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
}
}
LoggerLocalDataSource
인스턴스는 유형의 범위가 애플리케이션 컨테이너로 지정되므로 LogsFragment
에서 사용한 인스턴스와 동일하게 됩니다. 하지만, AppNavigator
인스턴스는 관련된 Activity
컨테이너로 범위를 지정하지 않았기 때문에 MainActivity
인스턴스와 다릅니다.
이 시점에서 ServiceLocator
클래스는 더 이상 종속 항목을 제공하지 않으므로 프로젝트에서 완전히 삭제해도 됩니다. 이 클래스의 사용은 인스턴스를 유지했던 LogApplication
클래스에 유일하게 남아있습니다. 이 클래스는 더 이상 필요하지 않으므로 이 클래스를 삭제해 보겠습니다.
LogApplication
클래스를 열고 ServiceLocator
사용을 삭제합니다. Application
클래스의 새 코드는 다음과 같습니다.
@HiltAndroidApp
class LogApplication : Application()
이제 프로젝트에서 ServiceLocator
클래스를 완전히 삭제해도 됩니다. 테스트에서 ServiceLocator
를 계속 사용하므로 AppTest
클래스에서도 사용하는 부분을 삭제합니다.
관련된 기본 콘텐츠
지금까지 배운 것으로 충분히 Android 애플리케이션에서 종속 항목 삽입 도구로 Hilt를 사용할 수 있습니다.
이제부터 다양한 상황에서 Hilt 고급 기능을 사용하는 방법을 배우기 위해 앱에 새 기능을 추가해 보겠습니다.
프로젝트에서 ServiceLocator
클래스를 삭제하고 Hilt의 기본 사항을 알았다면 이제 앱에 새 기능을 추가하여 다른 Hilt 기능을 살펴보겠습니다.
이 섹션에서 설명하는 내용은 다음과 같습니다.
- 활동 컨테이너로 범위를 지정하는 방법
- 한정자의 정의, 한정자가 해결하는 문제 및 한정자 사용 방법
이를 알아보려면 앱에 다른 동작이 필요합니다. 앱 세션 동안 로그 기록을 목적으로 로그 저장소를 데이터베이스에서 메모리 내 목록으로 바꾸겠습니다.
LoggerDataSource 인터페이스
이제 데이터 소스를 인터페이스로 추상화해 보겠습니다. 다음 콘텐츠를 사용하여 data
폴더에 LoggerDataSource.kt
라는 새 파일을 만듭니다.
package com.example.android.hilt.data
// Common interface for Logger data sources.
interface LoggerDataSource {
fun addLog(msg: String)
fun getAllLogs(callback: (List<Log>) -> Unit)
fun removeLogs()
}
LoggerLocalDataSource
는 ButtonsFragment
와 LogsFragment
, 두 프래그먼트에서 사용됩니다. LoggerDataSource
인스턴스를 사용하기 위해 두 프래그먼트를 사용하도록 리팩터링해야 합니다.
LogsFragment
를 열고 LoggerDataSource
유형의 logger 변수를 만듭니다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
@Inject lateinit var logger: LoggerDataSource
...
}
ButtonsFragment
에도 동일한 작업을 합니다.
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
@Inject lateinit var logger: LoggerDataSource
...
}
다음은 LoggerLocalDataSource
에서 이 인터페이스를 구현하도록 하겠습니다. data/LoggerLocalDataSource.kt
파일을 열고 다음을 실행합니다.
LoggerDataSource
인터페이스를 구현합니다.- 위에서 구현한 인터페이스의 메서드를
override
로 표시합니다.
@Singleton
class LoggerLocalDataSource @Inject constructor(
private val logDao: LogDao
) : LoggerDataSource {
...
override fun addLog(msg: String) { ... }
override fun getAllLogs(callback: (List<Log>) -> Unit) { ... }
override fun removeLogs() { ... }
}
이제 메모리에 로그를 저장하는 LoggerDataSource
의 또 다른 구현(LoggerInMemoryDataSource
라고 함)을 작성해 보겠습니다. 다음 콘텐츠를 사용하여 data
폴더에 LoggerInMemoryDataSource.kt
라는 새 파일을 만듭니다.
package com.example.android.hilt.data
import java.util.LinkedList
class LoggerInMemoryDataSource : LoggerDataSource {
private val logs = LinkedList<Log>()
override fun addLog(msg: String) {
logs.addFirst(Log(msg, System.currentTimeMillis()))
}
override fun getAllLogs(callback: (List<Log>) -> Unit) {
callback(logs)
}
override fun removeLogs() {
logs.clear()
}
}
활동 컨테이너로 범위 지정
LoggerInMemoryDataSource
를 구현 세부정보로 사용할 수 있으려면 이 유형의 인스턴스 제공 방법을 Hilt에 알려야 합니다. 위와 마찬가지로, 클래스 생성자에 @Inject
주석을 추가합니다.
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }
여기서 사용하는 애플리케이션은 하나의 활동(단일 활동 애플리케이션이라고도 함)으로만 구성되므로 Activity
컨테이너에 LoggerInMemoryDataSource
인스턴스가 있어야 하고 Fragment
에서 인스턴스를 재사용해야 합니다.
LoggerInMemoryDataSource
의 범위를 Activity
컨테이너로 지정하여 메모리에 로깅하는 동작을 실행할 수 있습니다. 생성된 모든 Activity
에는 자체 컨테이너, 즉 다른 인스턴스가 있습니다. 로거가 종속 항목으로 필요하거나 필드 삽입에 필요한 경우 각 컨테이너에는 LoggerInMemoryDataSource
의 동일한 인스턴스가 제공됩니다. 또한, 구성요소 계층구조 아래의 컨테이너에도 동일한 인스턴스가 제공됩니다.
구성요소로 범위 지정 문서에 따라 유형의 범위를 Activity
컨테이너로 지정하려면 유형에 @ActivityScoped
주석을 추가해야 합니다.
@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }
현재 Hilt는 LoggerInMemoryDataSource
와 LoggerLocalDataSource
의 인스턴스 제공 방법은 알고 있지만, LoggerDataSource
의 경우는 어떨까요? Hilt는 LoggerDataSource
를 요청 시 사용할 구현에 관해 모릅니다.
이전 섹션에서 살펴본 것처럼, 모듈에서 @Binds
주석을 사용하여 Hilt에 사용할 구현을 알려 줄 수 있습니다. 하지만, 동일한 프로젝트에서 두 구현을 모두 제공해야 한다면 어떻게 할까요? 예를 들어, 앱이 실행되는 동안 LoggerInMemoryDataSource
를 사용하고 Service
에서는 LoggerLocalDataSource
를 사용하는 경우입니다.
동일한 인터페이스를 위한 두 개의 구현
di
폴더에 LoggingModule.kt
이라는 새 파일을 만들어 보겠습니다. LoggerDataSource
의 다양한 구현은 서로 다른 컨테이너로 범위가 지정되므로 동일한 모듈을 사용할 수 없습니다. LoggerInMemoryDataSource
의 범위는 Activity
컨테이너로, LoggerLocalDataSource
는 Application
컨테이너로 지정됩니다.
다행히 방금 만든 파일에서 두 모듈의 결합을 모두 정의할 수 있습니다.
package com.example.android.hilt.di
@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {
@Singleton
@Binds
abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}
@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {
@ActivityScoped
@Binds
abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}
@Binds
메서드에 범위 지정 주석이 있어야 하므로(유형의 범위가 지정된 경우) 위의 함수에 @Singleton
과 @ActivityScoped
주석이 달립니다. @Binds
또는 @Provides
가 특정 유형의 결합으로 사용되면 유형의 범위 지정 주석은 더 이상 사용되지 않으므로 다른 구현 클래스에서 이러한 주석을 삭제할 수 있습니다.
지금 프로젝트를 빌드하려고 하면 DuplicateBindings
오류가 표시됩니다.
error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times
이는 LoggerDataSource
유형이 Fragment
에 삽입되어 있지만, Hilt는 같은 유형에 두 개의 결합이 있어 어느 구현을 사용해야 하는지 모르기 때문입니다. Hilt는 어떤 구현을 사용할지 어떻게 알 수 있을까요?
한정자 사용
Hilt에 동일한 유형의 다른 구현(여러 개의 결합)을 제공하는 방법을 알리려면 한정자를 사용하면 됩니다.
각 한정자는 결합을 식별하는 데 사용되므로 구현별로 한정자를 정의해야 합니다. Android 클래스에 유형을 삽입할 때 또는 이 유형을 다른 클래스의 종속 항목으로 포함할 때는 모호성을 피하기 위해 한정자 주석을 사용해야 합니다.
한정자는 주석일 뿐이므로 모듈을 추가했던 LoggingModule.kt
파일에서 정의할 수 있습니다.
package com.example.android.hilt.di
@Qualifier
annotation class InMemoryLogger
@Qualifier
annotation class DatabaseLogger
이제 이러한 한정자는 각 구현을 제공하는 @Binds
(또는 필요한 경우 @Provides
) 함수에 주석을 달아야 합니다. 전체 코드를 확인하고 @Binds
메서드에서 한정자 사용을 알아보세요.
package com.example.android.hilt.di
@Qualifier
annotation class InMemoryLogger
@Qualifier
annotation class DatabaseLogger
@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
}
또한, 이러한 한정자는 삽입하려는 구현과 함께 삽입 지점에서 사용해야 합니다. 이 경우에는 Fragment
에서 LoggerInMemoryDataSource
구현을 사용하겠습니다.
LogsFragment
를 열고 로거 필드에 @InMemoryLogger
한정자를 사용하여 LoggerInMemoryDataSource
인스턴스를 삽입하도록 Hilt에 알려 줍니다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
...
}
ButtonsFragment
에서도 동일한 작업을 실행합니다.
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
...
}
사용하려는 데이터베이스 구현을 변경하려면 삽입된 필드에 @InMemoryLogger
대신 @DatabaseLogger
주석을 추가하면 됩니다.
앱 실행
앱을 실행한 다음 버튼으로 상호작용하고 '모든 로그 보기' 화면에 적절한 로그가 표시되는지 관찰하여 실행한 작업을 확인할 수 있습니다.
로그는 이제 데이터베이스에 저장되지 않습니다. 로그는 세션 간에 지속되지 않으며, 앱을 닫고 다시 열 때마다 빈 로그 화면이 표시됩니다.
이제 앱이 Hilt로 완전히 이전되었으므로 프로젝트에 있는 계측 테스트도 이전할 수 있습니다. 앱 기능을 확인하는 테스트는 app/androidTest
폴더의 AppTest.kt
파일에 있습니다. 테스트를 열어보세요.
프로젝트에서 ServiceLocator
클래스를 삭제했기 때문에 컴파일되지 않는 것을 확인할 수 있습니다. 클래스에서 @After tearDown
메서드를 삭제하여 더 이상 사용하지 않는 ServiceLocator
의 참조를 삭제합니다.
androitTest
테스트는 에뮬레이터에서 실행됩니다. happyPath
테스트에서는 'Button 1'을 탭할 때 데이터베이스에 로그가 기록된 것을 확인합니다. 앱에서 메모리 내 데이터베이스를 사용하므로 테스트가 종료된 후에는 모든 로그가 사라집니다.
Hilt를 사용한 UI 테스트
Hilt는 프로덕션 코드에서 발생하는 것처럼 UI 테스트에 종속 항목을 삽입합니다.
Hilt는 각 테스트의 새로운 구성요소 집합을 자동 생성하므로 Hilt를 사용한 테스트에는 유지관리가 필요하지 않습니다.
테스트 종속 항목 추가
Hilt는 프로젝트에 추가해야 하는 hilt-android-testing
이라는 코드를 더 쉽게 테스트할 수 있도록 테스트 관련 주석이 있는 추가 라이브러리를 사용합니다. 또한, Hilt에서 androidTest
폴더에 클래스 관련 코드를 생성해야 하므로 주석 프로세서도 이 위치에서 실행할 수 있어야 합니다. 이렇게 하려면 app/build.gradle
파일에 두 개의 종속 항목을 포함해야 합니다.
이러한 종속 항목을 추가하려면 app/build.gradle
을 열고 dependencies
섹션 하단에 이 구성을 추가합니다.
...
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
에서 실행되어야 합니다. 라이브러리에는 이미 UI 테스트를 실행하는 데 사용할 수 있는 HiltTestApplication
이 포함되어 있습니다. 테스트에 사용할 Application
을 지정하려면 프로젝트에서 새 테스트 실행기를 생성합니다.
androidTest
폴더 아래 AppTest.kt
파일과 같은 수준에 CustomTestRunner
라는 이름의 새 파일을 생성합니다. CustomTestRunner
는 AndroidJUnitRunner에서 확장되며 다음과 같이 구현합니다.
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
그런 다음, 이 테스트 실행기를 계측 테스트에 사용하도록 프로젝트에 알려야 합니다. app/build.gradle
파일의 testInstrumentationRunner
속성에 테스트 실행기를 명시합니다. 파일을 열고 기본 testInstrumentationRunner
콘텐츠를 아래와 같이 바꿉니다.
...
android {
...
defaultConfig {
...
testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
}
...
}
...
이제 UI 테스트에서 Hilt를 사용할 준비가 되었습니다.
Hilt를 사용하는 테스트 실행
다음으로 에뮬레이터 테스트 클래스에서 Hilt를 사용하기 위해 다음을 실행해야 합니다.
- 각 테스트의 Hilt 구성요소 생성을 담당하는
@HiltAndroidTest
주석을 추가합니다. - 구성요소의 상태를 관리하고 테스트에 삽입을 실행하는 데 사용되는
HiltAndroidRule
을 사용합니다.
AppTest
에 포함해 보겠습니다.
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
...
}
이제 클래스 정의 또는 테스트 메서드 정의 옆의 재생 버튼을 사용하여 테스트를 실행하면 에뮬레이터가 시작하고(에뮬레이터가 구성된 경우) 테스트가 통과됩니다.
테스트 및 기능(예: 필드 삽입 또는 테스트에서 결합 교체)에 관해 자세히 알아보려면 문서를 확인하세요.
Codelab의 이 섹션에서는 Hilt에서 지원하지 않는 클래스에 종속 항목을 삽입하기 위해 사용하는 @EntryPoint
주석의 사용법을 알아봅니다.
앞에서 본 바와 같이 Hilt는 가장 일반적인 Android 구성요소를 지원합니다. 그러나, Hilt에서 직접 지원하지 않거나 Hilt를 사용할 수 없는 클래스에 필드를 삽입해야 할 수 있습니다.
이러한 경우에는 @EntryPoint
를 사용하면 됩니다. 진입점은 종속 항목을 삽입하는 데 Hilt를 사용할 수 없는 코드에서 Hilt가 제공하는 객체를 가져올 수 있는 경계 지점입니다. 이는 Hilt에서 관리하는 컨테이너에 코드가 처음 진입하는 지점입니다.
사용 사례
애플리케이션 프로세스 외부로 로그를 내보내려고 합니다. 이를 위해 ContentProvider
를 사용해야 합니다. 현재 소비자가 특정한 하나의 로그(id
지정)를 쿼리하거나 ContentProvider
를 사용하는 앱의 모든 로그를 쿼리하는 것만 허용하고 있습니다. Room 데이터베이스를 사용하여 데이터를 가져올 예정입니다. 따라서, LogDao
클래스는 Cursor
데이터베이스를 사용하여 필수 정보를 반환하는 메서드를 노출해야 합니다. LogDao.kt
파일을 열고 인터페이스에 다음 메서드를 추가합니다.
@Dao
interface LogDao {
...
@Query("SELECT * FROM logs ORDER BY id DESC")
fun selectAllLogsCursor(): Cursor
@Query("SELECT * FROM logs WHERE id = :id")
fun selectLogById(id: Long): Cursor?
}
그런 다음 새로운 ContentProvider
클래스를 만들고 query
메서드를 재정의하여 로그와 함께 Cursor
를 반환해야 합니다. 새 contentprovider
디렉터리 아래 LogsContentProvider.kt
라는 새 파일을 만들고 다음 콘텐츠를 포함합니다.
package com.example.android.hilt.contentprovider
import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import com.example.android.hilt.data.LogDao
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import java.lang.UnsupportedOperationException
/** The authority of this content provider. */
private const val LOGS_TABLE = "logs"
/** The authority of this content provider. */
private const val AUTHORITY = "com.example.android.hilt.provider"
/** The match code for some items in the Logs table. */
private const val CODE_LOGS_DIR = 1
/** The match code for an item in the Logs table. */
private const val CODE_LOGS_ITEM = 2
/**
* A ContentProvider that exposes the logs outside the application process.
*/
class LogsContentProvider: ContentProvider() {
private val matcher: UriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
addURI(AUTHORITY, LOGS_TABLE, CODE_LOGS_DIR)
addURI(AUTHORITY, "$LOGS_TABLE/*", CODE_LOGS_ITEM)
}
override fun onCreate(): Boolean {
return true
}
/**
* Queries all the logs or an individual log from the logs database.
*
* For the sake of this codelab, the logic has been simplified.
*/
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
val code: Int = matcher.match(uri)
return if (code == CODE_LOGS_DIR || code == CODE_LOGS_ITEM) {
val appContext = context?.applicationContext ?: throw IllegalStateException()
val logDao: LogDao = getLogDao(appContext)
val cursor: Cursor? = if (code == CODE_LOGS_DIR) {
logDao.selectAllLogsCursor()
} else {
logDao.selectLogById(ContentUris.parseId(uri))
}
cursor?.setNotificationUri(appContext.contentResolver, uri)
cursor
} else {
throw IllegalArgumentException("Unknown URI: $uri")
}
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
throw UnsupportedOperationException("Only reading operations are allowed")
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int {
throw UnsupportedOperationException("Only reading operations are allowed")
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
throw UnsupportedOperationException("Only reading operations are allowed")
}
override fun getType(uri: Uri): String? {
throw UnsupportedOperationException("Only reading operations are allowed")
}
}
getLogDao(appContext)
호출이 컴파일되지 않는 것을 볼 수 있습니다. Hilt 애플리케이션 컨테이너에서 LogDao
종속 항목을 가져와 구현해야 합니다. 하지만, Hilt에서는 @AndroidEntryPoint
로 활동을 사용하여 삽입하는 것처럼 즉시 ContentProvider
에 삽입하는 것을 지원하지 않습니다.
@EntryPoint
주석이 달린 새 인터페이스를 만들어 액세스해야 합니다.
@EntryPoint의 실제 사례
진입점은 원하는 각 결합 유형에 접근자 메서드를 사용하는 인터페이스입니다(한정자 포함). 또한, 진입점을 설치할 구성요소를 지정하려면 인터페이스에 @InstallIn
주석을 달아야 합니다.
권장사항은 이를 사용하는 클래스 내에 새 진입점 인터페이스를 추가하는 것입니다. 따라서, LogsContentProvider.kt
파일에 인터페이스를 포함합니다.
class LogsContentProvider: ContentProvider() {
@InstallIn(ApplicationComponent::class)
@EntryPoint
interface LogsContentProviderEntryPoint {
fun logDao(): LogDao
}
...
}
Application
컨테이너의 인스턴스에서 종속 항목을 가져와야 하므로 인터페이스를 @EntryPoint
로 주석 처리하고 ApplicationComponent
에 설치합니다. 이 인터페이스 내에서 액세스하려는 결합 관련 메서드(여기서는 LogDao
)를 노출합니다.
진입점에 액세스하려면 EntryPointAccessors
의 적절한 정적 메서드를 사용하세요. 매개변수는 구성요소 인스턴스이거나 구성요소 소유자 역할을 하는 @AndroidEntryPoint
객체여야 합니다. 매개변수로 전달하는 구성요소와 EntryPointAccessors
정적 메서드가 모두 @EntryPoint
인터페이스의 @InstallIn
주석에 있는 Android 클래스와 일치하는지 확인합니다.
이제 위의 코드에서 누락된 getLogDao
메서드를 구현할 수 있습니다. 위의 LogsContentProviderEntryPoint
클래스에서 정의한 진입점 인터페이스를 사용해 보겠습니다.
class LogsContentProvider: ContentProvider() {
...
private fun getLogDao(appContext: Context): LogDao {
val hiltEntryPoint = EntryPointAccessors.fromApplication(
appContext,
LogsContentProviderEntryPoint::class.java
)
return hiltEntryPoint.logDao()
}
}
applicationContext
를 정적 EntryPoints.get
메서드 및 @EntryPoint
주석이 달린 인터페이스의 클래스에 전달하는 방법을 살펴보세요.
이제 Hilt에 익숙해졌으므로 Android 앱에 추가할 수 있습니다. 이 Codelab에서 배운 내용은 다음과 같습니다.
@HiltAndroidApp
을 사용하여 Application 클래스에서 Hilt를 설정하는 방법@AndroidEntryPoint
를 사용하여 다양한 Android 수명 주기 구성요소에 종속 항목 컨테이너를 추가하는 방법- 모듈을 사용하여 Hilt에 특정 유형 제공 방법을 알려 주는 방법
- 한정자를 사용하여 특정 유형에 여러 결합을 제공하는 방법
- Hilt를 사용하여 앱을 테스트하는 방법
@EntryPoint
가 유용한 경우와 사용 방법