본문 바로가기

IT/안드로이드 관련

[안드로이드] hilt code-lab

안녕하세요 남갯입니다.

오늘은 hilt code lab 에 대해 공부한것을 정리해보도록 하겠습니다.

 

힐트는 기존 Dagger(단검)가 사용하기 복잡하여 많은 사용자들이 사용을 못하고 있는부분을

Hilt(단검 손잡이) 더 쉽게 사용하도록 만들어주는 라이브러리입니다.

 

대부분 ServiceLocater 패턴을 통해 런타임에 생성해서 주입하거나

Koin을 이용하거나, 혹은 Dagger를 이용하겠지만

Hilt를 이용하면 Dagger를 더 쉽게 이용 가능합니다.

 

developer.android.com/codelabs/android-hilt?hl=ko#1

 

Using Hilt in your Android app  |  Android 개발자  |  Android Developers

In this codelab, you'll build an Android app that uses Hilt to do Dependency Injection.

developer.android.com

$ git clone https://github.com/googlecodelabs/android-hilt

을 통해 프로젝트 생성

 

@HlitAndroidApp 어노테이션을 을 안드로이드 어플리케이션쪽에 입력해두면

힐트코드가 포함된 곳에 자동으로 베이스코드가 포함된 코드를 생성한다.

 

@HiltAndroidAppclass LogApplication : Application() {    ...}

 

이런 형태로 사용한 뒤

@AntoidEntryPoint 어노테이션을 사용해서 (AppCompatActivity/FragmentActivity) or (Fragment[Anroidx]) 에 주입합니다.

 

그 후 외부에서 주입했던 변수에 @Inject 키워드를 통해 주입

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

 

주입을 하는 방법은 다양하게 있겠지만 필드 주입을 하기위해서는 @Inject 키워드를 필드옆에 작성하면 됩니다.

 

위의 테스트코드에서 보면

 

ServiceLocater 패턴에서 DateFormatter 생성하는부분을 보면 매번 이용할때마다 새로운 객체를 생성하고 있습니다.

ex)

fun provideDateFormatter() = DateFormatter()

생성자 자체를 주입하도록 변경하여 한개의 객체만 사용가능하도록 변경합니다. DateFormatter 부분에 @Inject 어노테이션 사용

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

또한 DataSource부분도 단일 객체를 사용하기 위해 @Inject를 사용합니다

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

 

힐트를 통해서 @Inject를 통해 (Bindings)라고 부르는 객체를 제공한다.

 

여기서 우리는 주목해야하는건

val loggerLocalDataSource = LoggerLocalDataSource(logsDatabase.logDao())

위의 코드를 통해서 DataSource 라는 단일 객체를 제공했었는데, 위와같이 변경함으로써

ServiceLocator 에서는 항상 같은 단일 객체를 생성한다.

이것은 "Scoping an instance to a container" 라고 부른다.

 

이것이 어떻게 가능할까?

힐트는 다른 라이프 사이클을 가진 다른 컨테이너들을 생성 가능하다.

 

@Singleton 어노테이션을 사용하면 Application container 즉 어플리케이션 라이프사이클을 따르는 

필드 주입과 다른 종속성과 관계없이 하나의 객체를 생성한다. 

여기서 중요한건 컨테이너에서 더 상위계층의 Application container에서 생성한 객체는 하위계층인 Activity container에서 사용가능하다.

 

** Application Container 가 아닌 Activity Conatiner를 따르길 원한다면 @ActivityScoped 어노테이션을 사용해서

액티비티의 라이프 사이클내에 같은 객체를 사용할 수 있다.

 

Modules 는 bindings들을 Hilt에 추가하는데 사용한다. 힐드가 어떻게 다른 타입을 가진 인스턴스를 제공하는지 말해보면 힐트 모듈에서 인터페이스나 클래스에 생성자 Inejct를 할수 없다. 

힐트 모듈은 클래스 @Module  @InstallIn 를 가진다. 

 

@Module은 힐트한테 힐트모듈이다라고 알려주는것이고

@InstallIn은 힐트한테 힐트 컴포넌트를 지정해서 container bindings를 사용할 수 있다고 알려주는것 이다.

InstallIn 주석에 참조할 수 있는 구성요소 : developer.android.com/training/dependency-injection/hilt-android?hl=ko#generated-components

 

Hilt를 사용한 종속 항목 삽입  |  Android 개발자  |  Android Developers

Hilt는 프로젝트에서 수동 종속 항목 삽입을 실행하는 상용구를 줄이는 Android용 종속 항목 삽입 라이브러리입니다. 수동 종속 항목 삽입을 실행하려면 모든 클래스와 종속 항목을 수동으로 구성

developer.android.com

Hilt @InstallIn Component

 

각 안드로이드에서 제공하는 클래스들은 힐트에 의해 주입이 가능합니다. 예를들어 application의 경우

applicationComponent와 연관되어 있고, fragment container는 FragmentComponent와 연관이 있습니다.

 

** 모듈 생성

일단 DatabaseModule을 생성해보자.

이전에 사용하던 LoggerLocalDataSource는 application container의 스코프를 가지고 있기때문에

 

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

위와같이 LogDao를 사용하기 위해서는 Application Container가 필요하다. 

 

    @Provides
    fun provideLogDao(database: AppDatabase) : LogDao{
        return database.logDao()
    }

 

위의 코드는 LogDao인스턴스를 제공하기 위해 database.logDao()가 실행되야한다고 힐트한테 말하고 있는것이다.

전이의존성로서 AppDatabase것을 갖고있고 힐트한테 어떻게 생성하는지 말하는것이다.

 

AppDatabase도 하나 생성해야한다.

DB는 동일한 객체를 제공하기 위해 @Singleton 어노테이션을 사용하면 된다.

또한 applicationContext를 접근하기 위해 @ApplicationContext 어노테이션 필드랑 함께 제공하면 된다.

 

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "logging.db"
        ).build()
    }

 

이제 앱을 돌리려고 봤더니

LogFragment의 정보는 다 갖고있지만 실제 LogFragment가 호스트될 액티비티의 정보는 알지 못하고 있으므로

액티비티에도 @AnroidEntryPoint 어노테이션이 필요하다.

 

 

이제 메인액티비티쪽을 보면 navigator를 이용하고 있는데, 서비스로케이터를 통해 제공하고 있다.

하지만 AppNavigator는 인터페이스이기 때문에 우리는 생성자 주입을 사용할 수 없어서

@Binds 어노테이션을 통해 Hilt Module 내에 함수를 사용할 수 있다.

@Binds는 추상 함수같은걸로 명시되어야한다. 그리고 리턴타입은 우리가 제공하고 싶은 AppNavigator를

쓰되, 특정 인터페이스를 구체화한 타입을 반환하고 싶으면 파라미터로 추가하면된다.(AppNavigatorImpl)

 

 

** Module을 분리해야하는 이유

기존에 DatabaseModule에 말고 다른 이름의 모듈을 생성하자

1. 여기다가 넣을거였으면 이름을 DatabaseModule이라고 안짓는게 좋다

2. DatabaseModule은 ApplicationComponent에 설치되어 있고 바인딩들은 어플리케이션 컨테이너 내부에서 사용

가능하다. AppNavigator는 특정 앱 내부에서 사용하기 때문에 Activity Container 내부에 설치되야한다.

3. 힐트모듈들은 non Static과 abstract binding 메소드 같이 포함할수 없다. 그래서 @Binds와 @Provides는 같은

클래스 내부에 위치할 수 없다.

 

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
   
@Binds
   
abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {    
  @Binds    
  abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

 

위와같은 코드를 작성한 뒤 다시 파라미터로 제공하는 AppNavigatorImpl는 어떻게 제공할지 힐트한테 알려줘야한다.

 

AppNavigatorImpl 는 FragmentActivity에 의존한다. AppNavigator 인스턴스는 Activity container에서 제공된다.

그리고 FragmentActivity는 이미 이용가능하다.

 

    @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)
        }
    }

ButtonFragment 도 hilt로 변경 가능하다.

@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?) {
        view.findViewById<Button>(R.id.button1).setOnClickListener {
            logger.addLog("Interaction with 'Button 1'")
        }

        view.findViewById<Button>(R.id.button2).setOnClickListener {
            logger.addLog("Interaction with 'Button 2'")
        }

        view.findViewById<Button>(R.id.button3).setOnClickListener {
            logger.addLog("Interaction with 'Button 3'")
        }

        view.findViewById<Button>(R.id.all_logs).setOnClickListener {
            navigator.navigateTo(Screens.LOGS)
        }

        view.findViewById<Button>(R.id.delete_logs).setOnClickListener {
            logger.removeLogs()
        }
    }
}

그리고 서비스 로케이터도 삭제 가능하다.

 

 

**추가적인 Hilt에 대한 기능을 알아보자

일단 LoggerDataSource를 만든다.

 

기존에 사용하던 LoggerLocalDataSource를 사용하는 인터페이스를 만들어보자

interface LoggerDataSource {
    fun addLog(msg : String)
    fun getAllLogs(callback : (List<Log>) -> Unit)
    fun removeLogs()
}

그리고 기존에 사용하던 LoggerLocalDataSource inject 코드를 위의 인터페이스로 변경한다.

    @Inject lateinit var logger: LoggerDataSource

 

@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() { ... }
}

이렇게 변경한 뒤

메모리에서 갖고있을 수 있는 DataSource를 만들어보자.

 

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()
    }
}

기존에 있는 InMemoryDataSource를 ActivityContainer로 변경하기 위해서 @Inject와 함께 생성자를 넣어주자

 

샘플코드에서는 하나의 액티비티만 사용하지만 여러 프래그먼트에서

이렇게 생성하게 되면 각 프래그먼트에서 매번 다른 객체를 생성한다. 따라서 액티비티 컨테이너를

따르기 위해서 @ActivityScoped 어노테이션을 추가해준다.

 

이렇게 추가해주면 두개의 DataSource를 힐트에서 제공받을 수 있다

하지만 인터페이스인 LoggerDataSource는 알지 못한다. 이전시간에 배웠던

@Binds annotation을 통해 받으면 된다. 하지만 둘다 같은 프로젝트에서 구체화시켜 받을방법이 있을까?

 

@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
}

실제 위와같이 두개의 코드를 만들면 힐트의 사용하는 위치에서는 어떤 타입인지를 알지 못한다.

따라서

 

@Qualifier 어노테이션을 이용한다.

 

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

실제 두개를 가리키는 Qulifier를 넣어주고

 

@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
}

이렇게 어노테이션을 추가해준 코드를 작성한다.

 

두개 Logger는 위치하는 컨테이너가 다르기때문에 LogApplication에서 activity container에 주입된

InMemoryLogger는 사용할 수없다.

 

@InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...

실제 사용할 Logger에 어노테이션을 붙여주면 된다.

 

 

 

*** 힐트를 통한 UI Testing

// 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"

 

일단 androidTest folder에 

 

class CustomTestRunner : AndroidJUnitRunner(){
    override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

의 클래스를 만들고,

 

 // 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"

디펜던시 추가와

        testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"

config 설정에 추가한다.

 

기존에 테스트 코드에 있던 HiltAndroidTest를 수정한다.

 

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}