* 이 공부 글은 developers에서 제공되는 codelab을 공부하며 작성된 글입니다.
codelab -> https://developer.android.com/codelabs/android-hilt?hl=ko#6
Hilt란?
- 2020년 6월에 발표된 Dagger을 좀더 쉽게 사용하기위한 Android용 DI 라이브러리입니다
- DI는 Dependency Injection의 약자로 의존성 주입을 말하며 객체를 클래스 내부에서 생성하는것이 아닌 외부에서 생성하여 주입해주는것을 말합니다.
- Dagger의 러닝커브를 낮추고 Android용으로 개발된 라이브러리입니다.
Project에 Hilt 추가하기
- Project수준의 build.gradle에 아래를 추가합니다.
buildscript {
...
ext.hilt_version = '2.28-alpha'
dependencies {
...
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}
- App수준의 build.gradle에 아래를 추가합니다.
...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
android {
...
}
...
dependencies {
...
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}
- ApplicationClass에 @HiltAdroidApp Annotation추가하고 AndroidManifest파일 수정
- Hilt라이브러리는 ApplicationClass와 ApplicationContext에 접근하는 일이 많기 때문
//----GlobalApplication.kt
@HiltAndroidApp
class GlobalApplication : Application(){
...
}
//----AndroidManifest
<application
android:name=".GlobalApplication"
...
</application>
여기까지면 Hilt의 사용준비 완료입니다.
Hilt를 시작하기전
- A클래스를 B클래스에 주입하려고 한다면 각각 “주입받을 Class”, “주입될 Class”인지를 Hilt에게 알려주어야합니다.
- 주입받을 클래스는 @AndroidEntryPoint Annotation을 붙여 해당 클래스가 객체를 주입받아야 한다는것을 Hilt에게 알려줍니다. (진입점)
- Annotation을 붙이면 해당 class의 생명주기를 따르는 컨테이너가 생성됩니다.
- ( Container에 대한 자세한 정보 : https://developers-kr.googleblog.com/2021/10/introduction-to-hilt-in-the-mad-skills-series.html )
- “수동으로 수행하기” 부분이 Annotation으로 생성된 컨테이너가 내부적으로 해주고 있는 역할입니다.
- 주입받는 객체는 생명주기에 따라 생성되고 제거됩니다.
- Annotation을 붙이면 해당 class의 생명주기를 따르는 컨테이너가 생성됩니다.
- 주입될 Class는 생성자에 @Inject Annotation을 붙여 Hilt에게 주입될 Class라는것과 생성방법을 알려줍니다.
- Hilt로 주입한 객체는 private할 수 없습니다.
- Hilt는 외부 라이브러리 (retrofit, okhttp), interface를 외부에서 생성해서 주입할 수 없습니다.
- 외부라이브러리는 Annotation을 붙일 수 없고, interface는 생성자가 정의되어있지 않기 때문입니다.
- 하지만 Module을 생성해 Module에서 객체를 생성하고
- @Binds와 @Provides Annotation으로 Hilt에게 생성방법을 알려주고 @Inject construct()로 주입해줄 수 있습니다.
Annotation은 Hilt와의 소통을 위한 것 같네요
- Hilt가 의존성을 주입해줄수 있는 Class의 종류
- Hilt의 주입 객체생성 제거 시기
- Hilt 구성요소 범위
- Hilt 구성요소 계층 구조
의존성 주입하기
Field 주입하기
- Class Field안에서 주입하는 방법입니다.
- 객체를 주입받을 Class에 @AndroidEntryPoint Annotation을 붙여줍니다.
- Hilt에게 이 클래스는 객체를 주입받는 클래스라는 것을 알려줍니다.
- Android 클래스 수명주기를 따르는 종속항목 컨테이너가 생성됩니다.
- 아래에서 주입받은 객체 Logger, dataFormatter는 MainActivity의 수명주기에 따라 생성되고 제거됩니다.
//MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var dateFormatter: DateFormatter
...
}
* 종속항목 컨테이너 - Hilt를 공부할 때 이게 뭐지 싶어시 이리저리 찾아봤지만 제대로된 정의는 없더라구요.. 그래서 라이브러리가 생성하는 파일좀 해석해보려했는데 dagger경량화 라이브러리라 그런지.. dagger이 껴있어서 해석 불가 ㅜㅜ 하지만 dagger이 사용되지 않은 DaggerLogApplication_HiltComponents_SingletonC 파일이 있는데 이 클래스 안에 Hilt에서 지원하는 class들의 builder로 되어있는 클래스들이 있었습니다. 이 클래스 안에서 객체를 생성해주는 코드가 있었는데 이 클래스틀을 종속항목 컨테이너라 명칭하는게 아닐까 생각했습니다.
위 내용은 개인적인 생각이니 참고만 해주세요 자 그럼 계속 해서
현재 Hilt는 MainActivity Class가 객체를 주입받는다는 사실을 알고 있지만, 주입해줄 객체를 어디서 어떻게 생성하는지 모르는상태입니다. 따라서 주입받을 객체의 위치와 생성방법을 Hilt에게 알려주어야합니다.
- @Inject construct()를 붙여 주입해줄 객체의 위치와 생성방법을 알려줍니다.
//DateFormatter.kt
class DateFormatter @Inject constructor() { ... }
//LoggerLocalDataSource.kt
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
이로써 객체주입이 끝났습니다. 하지만 각 클래스들은 매번 다른 instance를 제공합니다.
- 어플리케이션의 실행동안 같은 Instance를 제공하고싶다면 주입할 객체에 @Singleton Annotation을 추가합니다.
//LoggerLocalDataSource.kt
@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
//LogDao.kt
@Dao
interface LogDao {
@Query("SELECT * FROM logs ORDER BY id DESC")
fun getAll(): List<Log>
@Insert
fun insertAll(vararg logs: Log)
@Query("DELETE FROM logs")
fun nukeTable()
}
이로써 MainActivity의 수명주기에 따라 LoggerLocalDataSource가 생성되어 주입될 때 항상 같은 instance를 제공할 수 있게 됩니다.
- 하지만 Hilt는 LoggerLocalDataSource의 객체생성방법을 알지만, 종속항목의 logDao의 생성방법을 모르는 상태입니다.
- 따라서 해당 클래스의 생성방법도 Hilt에게 알려주어야 합니다.
- 아래부터는 interface 또는 외부라이브러리의 객체주입 방법인 Module의 설명입니다.
Hilt Module 생성자 주입
- Class의 생성자에 Interface 또는 외부 라이브러리 객체를 주입하는 방법입니다.
- Module은 생성자 삽입이 불가능한 유형의 결합 정보를 Hilt에게 알려줄 수 있습니다.
- @Module은 @Installin과 @Module이 달린 클래스입니다. @Module : Hilt에게 모듈임을 알려주는 주석입니다. @Installin : 어느 컨테이너에서 Hilt구성요소를 지정하여 결합하여 사용할 수 있는지 알려줍니다.
Module 생성시 지켜야할 조건
- 효율적이 구성을 위해 모듈의 이름은 제공하는 정보 유형을 포함해야합니다.
- 한개의 모듈에 @Binds와 @Provides가 같이 있어서는 안됩니다.
결합정보 제공방법 @Binds와 @Provides
@Provides
- 프로젝트에서 소지하지않은 외부 라이브러리의 클래스 instance 제공하거나 instance생성에 builder패턴을 사용하는경우 사용합니다.
- 아래는 RoomDatabase의 DAO Interface를 제공하는 예시입니다.
- 아래의 예는 LogDao의 instance를 주입하기 위해서는 RoomDatabase instance를 생성해야합니다.
- RoomDB의 객체를 builder패턴을 사용하여 instance를 생성하고 builder패턴을 사용한 instance를 Hilt로 제공하기위해서 @Provides를 사용합니다.
Module생성
- 먼저 아래처럼 모듈을 생성해줍니다.
//DatabaseModule.kt
@InstallIn(SingletonComponent::class)
@Module
object DatabaseModule {
...
}
- 기존의 LogDao의 instance는 logsDatabase.logDao()를 호출해 가져오는데 logsDatabase는 applicationContext에 접근해서 RoomDB의 객체를 생성하기 때문에 위의 모듈의 구성요소 범위를 SingletonComponent로 지정해줍니다.
//ServiceLocator.kt
class ServiceLocator(applicationContext: Context) {
private val logsDatabase = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"logging.db"
).build()
val loggerLocalDataSource = LoggerLocalDataSource(logsDatabase.logDao())//<------------요기
fun provideDateFormatter() = DateFormatter()
fun provideNavigator(activity: FragmentActivity): AppNavigator {
return AppNavigatorImpl(activity)
}
}
@Provides로 instance주입
- @Provides는 외부 라이브러리의 instance
- @Provides Annotation을 붙인 providesLogDao함수를 생성합니다.
- LogDao의 instance를 제공할때 해당 함수가 실행되어야 한다고 hilt에게 알려주는 것입니다.
- 하지만 LogDao의 instance를 제공하려면 전이종속항목인 AppDatabase의 instance가 있어야합니다.
//DatabaseModule.kt
@InstallIn(SingletonComponent::class)
@Module
object DatabaseModule {
@Provides
fun provideLogDao(database: AppDatabase): LogDao {
return database.logDao()
}
}
////////////@Provides Annotaion을 붙인 함수가 Hilt에게 제공하는 정보
Return type : 제공하는 인스턴스
Parameter : 인스턴스의 종속 항목
Body: 인스턴스를 제공하는 방법
//DatabaseModule.kt
@InstallIn(SingletonComponent::class)
@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의 생성방법인 providesDatabase를 정의해서 @Provides Annotation을 붙여 Hilt에게 알려줍니다.
- AppDatabase의 instance는 어플리케이션 범위에서 같은 instance를 제공해야 하기 때문에 @Singleton Annotation을 추가해줍니다.
- MainActivity의 LoggerLocalDataSource 인스턴스의 객체 주입이 끝났습니다.
@Binds
- 생성자를 정의할 수 없는 interface의 instance를 제공하는데 사용합니다.
- 아래 예시는 interface AppNavigator의 instance를 제공하는 예시입니다.
최종 결과 AppNavigator interface의 instance를 MainActivity class에 주입할 수 있습니다
//AppNavigator.kt
enum class Screens {
BUTTONS,
LOGS
}
interface AppNavigator {
// Navigate to a given screen.
fun navigateTo(screen: Screens)
}
//MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var navigator: AppNavigator
...
}
Module 생성
- AppNavigator는 activit를 종속항목으로 포함하기때문에 구성요소로 ActivityComponent를 전달합니다.
- AppNavigater의 instance를 제공하려면 AppNavigatorImpl의 인스턴스 생성방법 또한 알아야 합니다.
@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
@Binds
abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}
/////////@Binds Annotations을 붙인 함수가 Hilt에게 제공하는 정보
Return type : 인스턴스로 제공되는 인터페이스
Parameter : 실제 제공하는 클래스(interface를 상속받아 재정의한 클래스)
//ServiceLocator.kt
class ServiceLocator(applicationContext: Context) {
private val logsDatabase = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"logging.db"
).build()
val loggerLocalDataSource = LoggerLocalDataSource(logsDatabase.logDao())
fun provideDateFormatter() = DateFormatter()
fun provideNavigator(activity: FragmentActivity): AppNavigator {
return AppNavigatorImpl(activity)
}
}
- AppNavigatorImpl class는 프로젝트가 소유한 클래스이므로 Hilt가 가져올 수 있도록 @Inject 를 통해 알려줍니다.
- AppNavigatorImpl class는 FragmentActivity에 종속됩니다.
- 하지만 AppNavigator instance가 ActivityComponent에 설치되어있으므로 하위 Component인 Fragment에서도 사용가능합니다.
// AppNavigatorImpl.kt
class AppNavigatorImpl @Inject constructor(private val activity: FragmentActivity) : AppNavigator {
override fun navigateTo(screen: Screens){
...
}
}
- MainActivity가 종속성 주입이 되어야할 class라는것을 알려주기위해 @AndroidEntryPoint 주석이 추가되었습니다.
- Hilt주입한 종속성은 private할 수 없으므로 private을 삭제합니다.
- Hilt에서 종속성을 주입할 수 있도록 @Inject가 추가되었습니다.
//MainActivity.kt 기존
class MainActivity : AppCompatActivity() {
private lateinit var navigator: AppNavigator
...
}
-----------------------------------------------------------------------------
//MainActivity.kt 의존성 주입 후
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var navigator: AppNavigator
...
}
한정자
- interface별로 상황에 따라 다른구현이 필요할 때가 있습니다.
- 이런상황에서 서로 다르게 재정의된 interface에 대한 주입방법으로 한정자를 소개합니다.
- 아래는 구현하고 싶은 interface입니다.
//LoggerDataSource.kt
interface LoggerDataSource {
fun addLog(msg: String)
fun getAllLogs(callback: (List<Log>) -> Unit)
fun removeLogs()
}
- 위 interface를 상속받아 한개의 클래스에서는 Local접근, 한개의 클래스에서는 RoomDB접근으로 구현하겠습니다.
- Hilt는 아래 두 클래스의 생성방법을 알고 있습니다.
//RoomDB접근 LoggerInMemoryDataSource.kt
@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() { ... }
}
//Local접근 LoggerLocalDataSource.kt
@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor() : LoggerDataSource {
private var 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()
}
}
- 이제 모듈을 생성해서 interface에대한 재정의된 클래스의 instance를 생성해줍니다.
//LoggingDataModule.kt
@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
}
- 다른 클래스에서 해당 interface객체를 주입해보겠습니다.
- 이처럼하고 빌드한다면 오류가 생깁니다.
- 그 이유는 LoggerDataSource interface의 module에는 현재 2개가 있는데 어떤 instance를 주입해주어야 할지 Hilt는 알 수 없기 때문입니다.
//LogsFragment.kt
@AndroidEntryPoint
class LogsFragment : Fragment() {
@Inject lateinit var logger: LoggerDataSource
...
}
- 이때 사용되는 Annotation이 한정자 입니다.
- 한정자는 Annotaion을 통해 동일한 유형의 서로다른 구현을 한 instance에 Name을 붙여주기 위해 사용됩니다.
- 먼저 LoggingDataModule파일 즉 Module을 생성해준 .kt 파일에 아래와 같이 @Qualifier Annotation을 붙여 각 instance에 사용될 Annotation Class를 생성합니다.
- 생성한 Annotation을 지정해줄 @Binds함수에 주석을 달아줍니다.
////LoggingDataModule.kt
@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
}
- instance를 주입받아야할 클래스에도 @Inject 와 함께 어떤 Name의 instance를 제공할지 Annotation을 달아줍니다.
//LogsFragment.kt
@AndroidEntryPoint
class LogsFragment : Fragment() {
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
...
}
Hilt에서 지원하지 않는 Android class에는 @EntryPoint
- Hilt에서 지원하는 android class는 맨위의 그림을 참고해주세요
- Codelab에 나온 예는 ContentProvider입니다.
- ContentProvider는 Hilt에서 지원하지 않는 class입니다.
- 먼저 ContentProvider에서 사용할 정보를 반환할 수 있도록 LogDAO interface에 아래를 추가합니다.
@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?
}
- ContenProvider를 상속하는 class LogContentProvider를 생성합니다.
- 현재는 LogsConentProviders에서 LogDao가져올 수 없습니다.
class LogsContentProvider: ContentProvider() {
...
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")
}
}
...
}
- @AndroidEntryPoint처럼 Hilt가 지원하지 않는 클래스에서 Hilt가 활동 할 수 있도록 해주어야합니다.
- 이를 위해서 interface를 생성하고 @Installin주석으로 활동 범위를 지정해 @EntryPoint로 Hilt가 활동 할 수 있도록 해줍니다.
- Hilt의 권장사항은 새 진입점에 interface를 주가하는 것입니다만 외부로 빼도 별 다른 문제는 없었습니다.
class LogsContentProvider: ContentProvider() {
@InstallIn(ApplicationComponent::class)
@EntryPoint
interface LogsContentProviderEntryPoint {
fun logDao(): LogDao
}
...
}
- 이제 EntryPointContainer는 LogDAO의 객체를 가지고 있습니다. 따라서 LogContentProviders에서 LogDAO의 객체를 사용하려면 EntryPoint에 접근해 객체를 가져와야합니다.
- getLogDao를 호출하여 EntyPointContainer에 접근 LogDAO의 객체를 가져올 수 있습니다.
class LogsContentProvider: ContentProvider() {
...
private fun getLogDao(appContext: Context): LogDao {
val hiltEntryPoint = EntryPointAccessors.fromApplication(
appContext,
LogsContentProviderEntryPoint::class.java
)
return hiltEntryPoint.logDao()
}
}
저는 koin이나 dagger도 사용해본적이 없어 익히는데 생각보다 오래 걸렸습니다.. Hilt가 dagger 경량화 라이브러리라해도 여전히 러닝커브가 높은가봅니다..ㅜㅜ
https://developer.android.com/codelabs/android-hilt?hl=ko#0 - Hilt codelab
https://developer.android.com/training/dependency-injection/hilt-android?hl=ko#inject-provides - Hilt developer문서
위 글은 공부하면서 작성된 정리글입니다.
- 중간에 잘못된 부분이 있다면 댓글로 남겨주세요. 수정하겠습니다.
- 더 알고계시다면 댓글남겨주세요. 큰 도움이 됩니다. 감사합니다!
댓글