ANDROID/Android DI

[DI/Android] Hilt 실습(1) (with. Codelab)

주 녕 2022. 2. 8. 01:02
728x90

앞서 포스팅에서 공부했던 내용이 어떻게 적용되는지는 Codelab을 통해서 경험해보려고 한다.

아래 Codelab을 참고하여 실습했으며, 이 포스팅 또한 아래 내용을 정리한 내용이다. 

 

Android 앱에서 Hilt 사용  |  Android 개발자  |  Android Developers

이 Codelab에서는 Hilt를 사용하여 종속 항목 삽입을 실행하는 Android 앱을 빌드해 보겠습니다.

developer.android.com

 

[DI/Android] Hilt 라이브러리 이해하기

Hilt 2020년 6월 Google에서 발표한 Android 전용 DI 라이브러리 Hilt는 프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리함으로써 애플리케이션에서 DI를 사용하는 표준

junyoung-developer.tistory.com

 

Codelab을 통해 배울 수 있는 것

  • 지속 가능한 앱 제작과 관련된 Hilt 개념
  • 한정자(Qualifier)를 이용하여 동일한 타입에 바인딩을 여러 개 추가하는 방법
  • @EntryPoint를 사용하여 Hilt에 지원하지 않는 클래스의 컨테이너에 엑세스하는 방법
  • 단위 테스트 / 계측 테스트를 사용하여 Hilt에 사용하는 앱 테스트하는 방법

지난 포스팅에서는 Hilt의 기본적인 개념과 한정자를 사용하는 방법까지 공부했는데,

이번 실습을 통해서 @EntryPoint 사용법까지 공부할 예정이다.

 

 

초기 프로젝트 구조

초기 코드 (수동 DI / Service Locator 패턴)

class LogApplication : Application(){

    lateinit var serviceLocator: ServiceLocator
    override fun onCreate() {
        super.onCreate()
        serviceLocator = ServiceLocator(applicationContext)
    }
}
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)
    }
}

LogApplication 클래스에 종속 항목을 만들고 저장하는 ServiceLocator 클래스의 인스턴스를 생성함

ServiceLocator은 앱이 소멸될 때 함께 소멸되므로, 앱의 수명 주기에 연결되는 종속 항목의 컨테이너(container) 역할

 

❇️ 컨테이너(container) 란?

  • 코드베이스에 종속 항목을 제공하는 클래스
  • 인스턴스를 생성하고 수명 주기를 관리하여 인스턴스를 제공하는 데 필요한 종속 항목 그래프를 관리함
  • 컨테이너에서 제공하는 타입의 인스턴스를 가져올 수 있는 메서드를 노출함

 

Hilt 적용하기

1. Application 클래스에서 Hilt 적용

@HiltAndroidApp
class LogApplication : Application(){
    ...
}

@HiltAndroidApp 어노테이션을 사용하여 앱의 수명 주기에 연결된 컨테이너 추가

→ ServiceLocator 인스턴스를 사용하고 초기화하는 방식을 자동화함

  • @HiltAndroidApp은 종속 항목 삽입을 사용할 수 있는 application의 기본 클래스가 포함된 Hilt 코드 트리거
  • Application container는 앱의 상위 컨테이너 → 다른 컨테이너는 이 컨테이너가 제공하는 종속 항목에 접근 가능

 

2. Hilt로 필드 삽입

수동 DI 코드에서는 ServiceLocator에서 종속 항목을 가져왔음

LogsFragment와 ButtonsFragment 클래스의 onAttach에서 해당 필드를 채워주었는데, 이 부분을 Hilt로 변경

 

LogsFragment.kt

@AndroidEntryPoint
class LogsFragment : Fragment() {

    private lateinit var logger: LoggerLocalDataSource
    private lateinit var dateFormatter: DateFormatter
    ...
    
    override fun onAttach(context: Context) {
        super.onAttach(context)
        // onAttach 부분에서 종속 필드를 채움
        populateFields(context)
    }

    private fun populateFields(context: Context) {
        logger = (context.applicationContext as LogApplication).serviceLocator.loggerLocalDataSource
        dateFormatter = (context.applicationContext as LogApplication).serviceLocator.provideDateFormatter()
    }
    ...
}

@AndroidEntryPoint 어노테이션을 사용하여 Android 클래스의 수명주기를 따르는 컨테이너 생성

→ Hilt 컴포넌트의 수명주기에 따라 onAttach 시점에 필드가 삽입됨

*지난 Hilt 포스팅의 @Installin 설명 부분의 Hilt component 표 참고

 

Hilt로 주입할 필드 알려주기

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var dateFormatter: DateFormatter
    ...
}

@Inject 어노테이션을 이용하여 Hilt에게 주입할 필드를 알려줌

→ onAttach()에서 주입하는 방식에서 Hilt에게 어떤 필드를 채워야 하는지 알려주는 방식으로 바뀜

✔️ Hilt에서 주입할 필드는 private일 수 없음

 

Hilt에게 종속 항목의 인스턴스 제공 방법 알려주기

필드 주입을 실행하기 위해서는 Hilt가 종속 항목의 인스턴스를 어떻게 제공해야 하는지 알아야 함

  1. 생성자 주입 방식 (생성자 생성이 가능한 경우)
  2. 모듈을 이용한 주입 방식 (생성자 생성이 불가능한 경우 - 인터페이스 / 외부 라이브러리)

 < 1. 생성자 주입 방식 >

 클래스의 생성자에 @Inject 어노테이션을 추가하여 알려주기 

DateFormatter.kt

class DateFormatter @Inject constructor() {
    ...
}

LoggerLocalDataSource.kt

class LoggerLocalDataSource(private val logDao: LogDao) {
    ...
}
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

constructor 키워드로 생성자를 만들고 @Inject 어노테이션을 추가함

→ Hilt에서 DateFormatter, LoggerLocalDateSource 인스턴스 제공 방법을 알게 됨

== 현재 Hilt에는 2가지 바인딩이 있음 (서로 다른 타입의 인스턴스를 제공하는 방법에 대해 알고 있는 정보를 결합(binding))

 

인스턴스 범위를 컨테이너로 지정하기

ServiceLocator(컨테이너)를 다시 보면, 

LoggerLocalDataSource가 공개 필드로 있으며 항상 동일한 LoggerLocalDataSource 인스턴스를 반환함

→ 컨테이너의 인스턴스를 가져오는 메서드가 항상 동일한 인스턴스를 제공하는 경우, 인스턴스의 유형은 컨테이너로 범위가 지정

  • 인스턴스 범위를 Application container로 지정하는 경우 : @Singleton
  • Activity container에서 항상 특정한 타입에 대해 동일한 인스턴스를 제공하게 하려는 경우 : @ActivityScoped

* 지난 포스팅의 Hilt 컴포넌트의 계층 구조 참고

@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

 

 

3. Hilt 모듈

Hilt에서 LoggerLocalDataSource 인스턴스 제공 방법을 알고 있지만,

위의 코드에서도 볼 수 있듯이 LoggerLocalDataSource 인스턴스를 제공하기 위해서 LogDao 인스턴스 제공 방법도 알아야 함

👀 주어진 코드의 LogDao는 인터페이스이므로 Hilt는 생성자 주입을 사용하여 Hilt에게 인스턴스 주입 방식을 알려줄 수 없음

 

 < 2. 모듈을 이용한 방식 >

 @Module & @Installin 어노테이션을 이용하여 모듈을 생성하여 알려주기 

모듈을 이용하는 경우에도 제공하고자 하는 인스턴스의 종류에 따라 방법이 나뉨

  1. 인터페이스 - @Binds
  2. 외부 라이브러리 - @Provides

DatabaseModule.kt

@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {

}

@Module 어노테이션을 사용하여 Hilt에게 모듈임을 알려줌

@InstallIn 어노테이션을 사용하여 어느 컨테이너에서 바인딩을 사용할 수 있는지 Hilt에게 알려줌

→ LoggerLocalDataSource는 Application container로 범위가 지정되었으므로 Application container에서 바인딩을 할 수 있도록 @InstallIn 어노테이션에 ApplicationComponent:class 임을 지정함

*Kotlin에서 @Provides 함수만 포함되는 모듈은 object 클래스가 될 수 있음

 

모듈을 이용하여 인스턴스를 제공하는 방법

< 2-1. 모듈 - 외부 라이브러리 >

 @Provides로 인스턴스 제공 

AppDatabase.kt

@Database(entities = arrayOf(Log::class), version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun logDao(): LogDao
}

@Provides를 사용한 이유 : 프로젝트에서는 AppDatabase 클래스는 Room에 의해 생성되기 때문에 소유하고 있지 않음

 

DatabaseModule.kt

@InstallIn(ApplicationComponent::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()
    }
}

@Provides 어노테이션이 있는 함수의 반환 타입은 Hilt에 바인딩 타입/해당 타입의 인스턴스 제공 방법을 알려줌

@Singleton 어노테이션으로 Hilt에서 항상 동일한 인스턴스를 제공하도록 함

→ Hilt에 LogDao 인스턴스를 제공할 때, database.logDao()가 실행되어야 한다고 알려줌

 

 

< 2-2. 모듈 - 인터페이스 >

 @Binds로 인스턴스 제공 

AppNavigator.kt 

interface AppNavigator {
    // Navigate to a given screen.
    fun navigateTo(screen: Screens)
}

@Binds를 사용한 이유 : AppNavigator는 인터페이스이므로 생성자 삽입을 할 수 없음

 

NavigationModule.kt

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {

    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator

}
class AppNavigatorImpl @Inject constructor(private val activity: FragmentActivity) : AppNavigator {
    ...
}

@Module, @InstallIn 어노테이션을 사용하여 이전과 동일하게 모듈을 만들어줌

@Binds 어노테이션을 abstract 메소드에 달아 구현을 제공하는 인터페이스를 반환하고, 인터페이스의 구현부를 매개변수로 추가함

→ AppNavigator 인터페이스의 구현부인 AppNavigatorImpl은 Activity에 종속되므로 Activity container에 설치함

→ Hilt에 AppNavigatorImpl 인스턴스 제공 방법을 알려주기 위해서 @Inject 어노테이션을 사용하여 생성자 삽입(1)을 함

* FragmentActivity는 하나의 Activity 안에 Fragment를 상속받는 다른 화면들을 넣는 것

 

MainActivity.kt

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    @Inject lateinit var navigator: AppNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
//        navigator = (applicationContext as LogApplication).serviceLocator.provideNavigator(this)
        if (savedInstanceState == null) {
            navigator.navigateTo(Screens.BUTTONS)
        }
    }
    ...
}

@Inject 어노테이션을 사용하여 Hilt에서 AppNavigator 인스턴스를 가져올 수 있도록 함

 


reference >

728x90