Migration to Dagger-Hilt

강성우, 12 July 2020

dagger에서 hilt를 이용하여 안드로이드에서 더 효율높은 DI를 사용 하는 방법들에 대해 정리 하였다.

이전에 사용하던 dagger를 안드로이드에 적용 하면서 많은 보일러플레이트 코드가 생겨났다. 높디 높은 러닝커브를 제켜두고서라도 dagger를 사용 하면서 필연적으로 발생할 수 밖에 없는 보일러 플레이트 코드를 어떻게든 제거해보려고 dagger.android를 사용 해 보거나 Koin을 이용 하여 dagger를 대체해보려고도 했었다.

그러다 dagger의 hilt를 발견 하였고 일단 기존 토이 프로젝트에서 새로 브랜치를 따서 dagger hilt를 마이그레이션 해보고 어떤지 느낌을 정리 해보려고 한다.

글 작성 시점에서 dagger-hilt는 2.28-alpha버전으로 아직 알파이다. 아래내용은 시간에 따라 변경 될 수 있다.

1. build.gradle 종속 변경

root 프로젝트의 build.gradle파일에 dagger-hilt의 classpath를 dependencies항목에 추가 한다.

buildscript {	
	ext.dagger_hilt = '2.28-alpha'
	dependencies {
		classpath "com.google.dagger:hilt-android-gradle-plugin:$dagger_hilt"        
	}
}

그리고 dagger-hilt를 사용할 서브 모듈의 build.gradle파일에 dagger-hilt 플러그인 과 dependencies항목내에 아래와 같은 라이브러리 의존을 추가 한다.

dagger-hilt는 java8의 기능을 사용하기 때문에 compileOptions를 아래처럼 추가 해 준다.

apply plugin: 'dagger.hilt.android.plugin'

android {
    // ... 
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
  }
}

dependencies {
    implementation "com.google.dagger:hilt-android:$dagger_hilt"
	kapt "com.google.dagger:hilt-android-compiler:$dagger_hilt"
}

2. Application 파일 변경

Application을 상속받는 프로젝트의 Application클래스에 @HiltAndroidApp어노테이션으로 변경 한다. 만약 DaggerApplication클래스를 상속받아 applicationInjector()메소드를 재정의해서 사용 하고 있다면 상속을 제거 하고 재정의된 메소드도 모두 제거 해야 한다.

@HiltAndroidApp
class BookSearchApplication: MultiDexApplication() {
    // ... 
}

3. 주입대상 안드로이드 컴포넌트에 대한 설정

안드로이드 컴포넌트들에 대해 @AndroidEntryPoint어노테이션을 추가 하여 주입대상임을 설정 한다.

@AndroidEntryPoint
abstract class BookSearchActivity : AppCompatActivity() {
    @Inject lateinit var messageHelper: MessageHelper()
    // ... 
}

ActivityFragment의 경우 HasAndroidInjector인터페이스를 구현함으로서 android injector를 반환하게 하는데 @AndroidEntryPoint를 설정했을 경우 제거 해 준다.

주입대상 안드로이드 컴포넌트에 대한 주입 인스턴스의 동기화된 라이프사이클은 이 링크를 참고 하도록 하자.

안드로이드 컴포넌트 클래스에 @AndroidEntryPoint을 추가하였을 경우 그 컴포넌트(혹은 클래스)에 종속적인 클래스에도 같은 어노테이션을 추가 해야 한다. 예를 들어 Activity에 어노테이션을 추가 했을 때 이 Activity에 사용될 Fragment에도 같은 어노테이션을 추가 해야 한 다.

추상클래스의 경우 @AndroidEntryPoint를 적용하지 않아도 @Inject어노테이션을 통해 주입 받을 수 있다.

4. Application module

기존에 만들었던 ApplicationComponent와 같은 Component인터페이서는 제거 한다. 그리고 기존 Module클래스 만 남겼다. 모듈클래스는 이전과 같이 @Module로 선언하고 쓰면 되며 주입될 종속 컴포넌트 대상에 대한 인터페이스 지정을 @InstallIN()어노테이션을 통해 지정 하면 된다.

예를 들면 Application에 종속된 모듈의 경우 @InstallIn(ApplicationComponent::class) 가 되며, Activity에 종속된 모듈의 경우에는 @InstallIn(ActivityComponent::class)을 추가 하면 자동으로 해당되는 코드들이 생성된다.

아래 예제는 기존에 사용하던 모듈을 수정한 것 이다.

@Module
@InstallIn(ApplicationComponent::class)
object ApplicationModule {
    @Singleton
    @Provides
    fun provideMiddlewares(): @JvmSuppressWildcards List<MiddleWare<AppState>> {
        return listOf(
            ActionProcessorMiddleware(
                CombinedActionProcessor(
                    listOf()
                )
            )
        )
    }

    @Singleton
    @Provides
    fun provideAppStore(
        messageReducer: MessageReducer,
        bookSearchReducer: BookSearchReducer,
        middlewares: @JvmSuppressWildcards List<MiddleWare<AppState>>
    ): AppStore {
        return AppStore(
            AppState(HandledMessageState, InitializedState),
            AppReducer(
                messageReducer,
                bookSearchReducer
            ),
            middlewares
        )
    }

    @Singleton
    @Provides
    fun provideMessageHelper(context: Context): MessageHelper {
        return MessageHelperImpl(context)
    }

    @Singleton
    @Provides
    fun provideResourceHelper(context: Context): ResourceHelper {
        return ResourceHelperImpl(context)
    }
}

아래는 다른 Application component에 종속된 다른 모듈의 예 이다.

@Module
@InstallIn(ApplicationComponent::class)
class NetworkModule {
    private val TIMEOUT_SEC = 10L

    @Singleton
    @Provides
    fun provieOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(TIMEOUT_SEC, TimeUnit.SECONDS)
            .readTimeout(TIMEOUT_SEC, TimeUnit.SECONDS)
            .addInterceptor(AddKakaoAkHeaderIntercepter())
            .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
            .build()
    }

    @Singleton
    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .addConverterFactory(MoshiConverterFactory.create())
            .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
            .baseUrl("https://...")
            .client(okHttpClient)
            .build()
    }

    @Singleton
    @Provides
    fun provideBookSearchApi(retrofit: Retrofit): BookSearchApi {
        return retrofit.create(BookSearchApi::class.java)
    }

    @Singleton
    @Provides
    fun provideBookSearchRepository(api: BookSearchApi): BookSearchRepository {
        return BookSearchRepositoryImpl(api)
    }
}

@Module
@InstallIn(ApplicationComponent::class)
class ReducerModule {

    @Singleton
    @Provides
    fun provideMessageReducer(): MessageReducer {
        return MessageReducer()
    }

    @Singleton
    @Provides
    fun provideBookSearchReducer(): BookSearchReducer {
        return BookSearchReducer()
    }

}

5. Activity module

Activity에 대한 모듈 선언 또한 비슷하지만 @InstallIn의 대상이 ActivityComponent인터페이스 인 것이 다르다.

@Module
@InstallIn(ActivityComponent::class)
object BookSearchActivityModule {
    @ActivityScoped
    @Provides
    fun provideBookSearchNavigationHelper(
        @ActivityContext context: Context
    ): BookSearchNavigationHelper {
        return BookSearchNavigationHelperImpl(context as BookSearchActivity)
    }
}

@ActivityScoped어노테이션으로 provide될 모듈의 인스턴스가 Activity와 동일한 라이프사이클을 갖음을 설정했다. provide메소드 패러미터로 context가 있는데 이 context는 @ActivityContext을 이용 하여 Activity 컨텍스트 임을 적용 하였다. 이는 이 모듈이 ActivityComponent이기 때문에 가능하다. 다만 패러미터로 받는 context는 Context타입으로 들어오기 때문에 타입 캐스팅 해 주어야 한다.

6. Fragment module

Fragment또한 비슷하다. 대상이 FragmentComponent인터페이스임을 확인 할 수 있다.

@Module
@InstallIn(FragmentComponent::class)
abstract class BookSearchFragmentModule {
    @Binds
    @IntoMap
    @ViewModelKey(BookSearchViewModel::class)
    abstract fun bindBookSearchViewModel(viewModel: BookSearchViewModel): ViewModel
}

ViewModel factory를 통해 map으로 저장될 viewmodel에 대한 선언 메소드가 기존 dagger android support과 동일하게 사용 한다.

ViewModel factory에 대한 모듈은 아래와 같다.

@Module
@InstallIn(FragmentComponent::class)
abstract class ViewModelBuilder {
    @Binds
    abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
}

7. 마무리

예제를 통해 마이그레이션을 진행 하면서 확실히 보일러플레이터 코드가 많이 줄어들었음을 확인 할 수 있었다. 특히 dagger android support을 사용 하면서 더 높아진 러닝커브로 인하여 디버깅 하느라 하루종일 시간을 보내면서 고생한 경험이 있어 괜찮은 경험이라고 생각 되었다.

특히 간단해진 코드로 인하여 코드의 직관성이 높아지고 이전에 사용되던 코드를 그대로 사용할 수 있어 괜찮았다.

하지만 제네릭에 대한 지원에 문제가 있는거 같았고 이 때문에 @AndroidEntryPoint대상의 제네릭을 제거 해야 했다. 그런데 어차피 dagger를 사용 하면서 제네릭에 대한 적용이 어려워서 @JvmSuppressWildcards와 같은 어노테이션을 도배하듯이 사용 해야 했던 경험이 있었고 이 경험은 그대로 dagger-hilt에도 이어진다.

결국 dagger(hilt)는 사용하기 까다롭지만 어쩔수 없이 선택할 수 밖에 없다는 생각이 들었다. 몰론 koin이라는 대안이 있기는 하지만 말이다. 그만큼 dagger는 아직 의존성 주입이라는 도구의 틀 안에서 가장 좋은 효율을 내고 있으며 dagger hilt를 통해 더 나은 코드 생산성을 제공 해주고 있어 조금 더 기대를 해도 될거 같다.