본문 바로가기
Android Studio(Kotlin)

의존성 주입 라이브러리 Hilt

by Yuno. 2022. 7. 10.
728x90

* 이 공부 글은 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에게 알려줍니다. (진입점)
  • 주입될 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문서

위 글은 공부하면서 작성된 정리글입니다.

- 중간에 잘못된 부분이 있다면 댓글로 남겨주세요. 수정하겠습니다.
- 더 알고계시다면 댓글남겨주세요. 큰 도움이 됩니다. 감사합니다!

728x90

댓글