Android

안드로이드 [Kotlin] - 프로젝트에 의존성 주입(DI) 적용해보기 - Hilt

🤖 Play with Android 🤖 2022. 11. 10. 16:16
728x90


의존성 주입, 안드로이드에서의 의존성 주입, 그리고 안드로이드 의존성 주입 라이브러리인 Hilt에 대해서 지난번 포스팅에서 다뤘다. 

https://jminie.tistory.com/180

 

안드로이드 [Kotlin] - 의존성 주입(DI) 알아보기 - Hilt

📌 들어가며 의존성 주입은 객체지향 프로그래밍의 개념 중 하나이다. 이것을 이해하기 위해서는 객체지향 설계의 5대 원칙으로 불리는 SOLID 원칙에 대해 알아보아야 한다. 바로 직전 포스팅에

jminie.tistory.com

오늘은 Hilt를 프로젝트에 적용해보자

 

 

📌 의존성주입

build.gradle(Project)

plugins {
    ...
    ...
    id 'com.google.dagger.hilt.android' version '2.41' apply false
}
  • 2022년 11월 버전은 2.41이다 
  • 버전은 언제든 올라갈 수 있다.

build.gradle(Module)

plugins {
    ...
    id 'dagger.hilt.android.plugin'
}

android {
    ...
    ...
    kapt {
        correctErrorTypes true
    }
}

dependencies {
    ...
    ...
    
    // Hilt
    implementation 'com.google.dagger:hilt-android:2.41'
    kapt 'com.google.dagger:hilt-compiler:2.41'
}
  • 이때 kapt가 에러 타입을 알아서 판단할 수 있도록 kapt의 correctErrorTypes true 설정을 넣어주도록 하자

 

 

📌 Application 클래스 작성

Hilt 라이브러리는 Application와 ApplicationContext에 접근하기 위해 ApplicationClass가 필요하다.

@HiltAndroidApp
class BookSearchApplication : Application() {
    ... 
}
  • 보통 Application 클래스의 이름은 앱 이름 뒤에 Application을 붙여주는 식으로 많이 명명한다.
  • @HiltAndroidApp 어노테이션에 의해 Singleton Component를 생성하게 된다.
  • 따라서 앱이 살아있는 동안 의존성(Dependency)을 제공하는 역할을 하는 애플리케이션(Application) 레벨의 Component가 되는 것이다.

또한 Application 클래스를 생성한 뒤에는 AndroidManifest 파일에 application 태그에 name 설정을 꼭 해주어야 오류가 생기지 않는다.

<application
        android:name=".BookSearchApplication"
        ...
        ...

</application>

 

이 상태에서 프로젝트를 빌드하면 안드로이드 스튜디오 좌측 project 탭의 java(generated)에 Hilt에 의해서 자동으로 의존성 그래프(Dependency Graph) 파일이 생성되는 것을 확인할 수 있다.

 

 

 

📌 AppModule 클래스 생성

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    ...
}
  • AppModule은 앱 전체에서 사용할 모듈이다. 따라서 SingletonComponent로 설정해주었다.
  • 또한 Module임을 알리는 @Module 어노테이션을 붙여준다.

 

그다음 Retrofit 설정을 해보자

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    // Retrofit
    @Singleton
    @Provides
    fun provideOkHttpClient(): OkHttpClient {
        val httpLoggingInterceptor = HttpLoggingInterceptor()
            .setLevel(HttpLoggingInterceptor.Level.BODY)
        return OkHttpClient.Builder()
            .addInterceptor(httpLoggingInterceptor)
            .build()
    }

    @Singleton
    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .addConverterFactory(MoshiConverterFactory.create())
            .client(okHttpClient)
            .baseUrl(BASE_URL)
            .build()
    }

    @Singleton
    @Provides
    fun provideApiService(retrofit: Retrofit): BookSearchApi {
        return retrofit.create(BookSearchApi::class.java)
    }
}
  • Interceptor로써 로깅에 사용될 OkHttpClient를 provideOkHttpClient라는 함수로 만들고
  • 그리고 Retrofit 객체를 생성하는 provideRetrofit 함수를 만들어주고 그 안에 매개변수에 OkHttpClient를 넣어준다. 
  • 마지막으로 Api Service 객체를 생성하는 provideApiService 함수를 만들어주면 된다.

 

안드로이드 스튜디오에서는 위 사진처럼 코드 좌측에 의존성을 GUI 형태로 알려주는 어시스턴트 기능을 제공하는데 저 GUI 버튼들을 눌러보면 실제 객체들의 의존성 관계를 쉽게 알 수 있다.

 

 

 

 

📌 RepositoryModule 클래스 생성

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    
}
  • RepositoryModule 역시 앱 전체에서 사용할 모듈이기 때문에 SingletonComponent로 설정해주었다.
  • 또한 AppModule 클래스와 마찬가지로 Module임을 알리는 @Module 어노테이션을 붙여준다.

 

이제 RepositoryModule 내부 코드를 구현해보자

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Singleton
    @Binds
    abstract fun bindBookSearchRepository(
        bookSearchRepositoryImpl: BookSearchRepositoryImpl,
    ): BookSearchRepository

    @Singleton
    @Binds
    abstract fun bindBookReportRepository(
        bookReportRepositoryImpl: BookReportRepositoryImpl,
    ): BookReportRepository

    @Singleton
    @Binds
    abstract fun bindAlarmRepository(
        alarmRepositoryImpl: AlarmRepositoryImpl,
    ): AlarmRepository
}

 

  • 인터페이스인 BookSearchRepository, BookReportRepository, AlarmRepository를 @Binds 어노테이션을 통해 Hilt가 의존성 객체를 생성할 수 있도록 하였다.
  • 세 함수 모두 인터페이스인 Repositroy를 반환하고 그 매개변수에는 구현체인 RepositoryImpl을 넣어준다.
  • 주의할 점은 모듈(Module)과 함수가 모두 abstract 측 추상 클래스와 추상 함수여야 한다.

 

 

 

📌 의존성 주입하기

안드로이드 앱 아케텍쳐(AAC) 구조에 따르면 의존성 주입이 단방향으로 이루어진다.

  1. AppModule의 의존성을 Repository에 주입
  2. Repository 객체는 ViewModel에 주입
  3. 각 View에 ViewModel 객체를 주입

 

AppModule에서 만든 Retrofit 의존성을 Repository에 주입

@Singleton
class BookSearchRepositoryImpl @Inject constructor(
    private val api: BookSearchApi
) : BookSearchRepository {
    ...
    ...
}
  • RepositoryImpl에 @Singleton을 붙여서 의존성 주입 가능한 스코프로 지정해준다.
  • 다음에는 @Inject constructor ( ... ) 를 붙여준다.
  • 그러면 constructor 안에 있는 객체들은 Hilt가 주입하게 된다.
  • Hilt를 사용하기 전에는 Object 키워드를 통해 전역 싱글톤으로 Retrofit을 구현했었는데 이로써 Hilt로 대체할 수 있다.

이제 주입받은 Api를 이용해 원하는 로직을 구현하면 된다.

@Singleton
class BookSearchRepositoryImpl @Inject constructor(
    private val api: BookSearchApi
) : BookSearchRepository {

    override suspend fun searchBooks(
        query: String,
        sort: String,
        page: Int,
        size: Int
    ): Response<SearchResponse> {
        return api.searchBooks(query, sort, page, size)
    }
}

 

 

Repository 의존성을 ViewModel에 주입

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val bookSearchRepository: BookSearchRepository,
) : ViewModel() {

}
  • 로직은 Retrofit 의존성을 Repository에 주입해줄 때와 동일하다
  • 다만 이번에는 주입받을 대상이 ViewModel이기 때문에 @HiltViewModel을 어노테이션 붙여서 의존성 주입 가능한 스코프로 지정해준다.
  • 다음에는 @Inject constructor ( ... ) 를 붙여준다.

이제 주입받은 Repository를 이용해 원하는 로직을 구현해보자

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val bookSearchRepository: BookSearchRepository,
) : ViewModel() {

    private val _searchWord = MutableSharedFlow<String>()
    val searchWord = _searchWord.debounce { 200 }

    private val _searchPagingResult = MutableStateFlow<PagingData<Book>>(PagingData.empty())
    val searchPagingResult: StateFlow<PagingData<Book>> = _searchPagingResult.asStateFlow()

    fun handleSearchWord(word: String) {
        viewModelScope.launch(exceptionHandler) {
            _searchWord.emit(word)
        }
    }

    fun searchBooksPaging(query: String) {
        viewModelScope.launch(exceptionHandler) {
            bookSearchRepository.searchBooksPaging(query, getSortMode())
                .cachedIn(viewModelScope)
                .collect {
                    _searchPagingResult.value = it
                }
        }
    }
}

 

 

ViewModel 의존성을 View에 주입

  • 이제 View에 ViewModel 의존성을 주입할 것이다.
  • 이때 Delegate 패턴으로 ViewModel을 초기화하기 위해서 ktx 의존성을 추가해주어야 한다.
dependencies {
    ...
    ...
    
    // Hilt
    implementation 'com.google.dagger:hilt-android:2.41'
    kapt 'com.google.dagger:hilt-compiler:2.41'
    
    // ViewModel delegate
    implementation 'androidx.activity:activity-ktx:1.4.0'
    implementation 'androidx.fragment:fragment-ktx:1.4.1'
}
  • Delegate 패턴을 사용하면 ViewModelFactory를 사용하지 않고도 ViewModel을 생성할 수 있다.

 

📌 1:1 vs N:1

이제 두 가지의 예제를 보자

  • 첫 번째 예제는 하나의 View(Fragment)에 하나의 ViewModel을 가지고 있는 1:1 구조를 바탕으로 작성하였다.
  • 두 번째 예제는 여러 개의 View(Fragment)에 하나의 ViewModel을 두고 있는 N:1 구조를 바탕으로 작성하였다.
  • 여러 개의 View, 즉 Activity에 ViewModel을 두어 그 Activity에 종속되어 있는 여러 Fragment에서 하나의 ViewModel을 공유하는 N:1 구조는 하나의 ViewModel에서 데이터의 공유가 가능하다는 장점이 있지만 관심사의 분리 측면에서는 바람직하지 않을 수 있다. 

 

📌 1:1 

FragmentA

@AndroidEntryPoint
class SearchFragment : Fragment() {
    ...
    ...
    private val searchViewModel: SearchViewModel by viewModels()
}

 

FragmentB

@AndroidEntryPoint
class BookReportFragment : Fragment() {
    ...
    ...
    private val bookReportViewModel: BookReportViewModel by viewModels()
}
  • View에 @AndroidEntryPoint을 붙여서 View를 의존성 주입 가능한 스코프로 지정해준다.
  • ViewModel을 분리하면 이렇게 View에서 필요한 로직을 나누어 ViewModel에 저장하게 되므로 하나의 ViewModel이 비대해지는 것을 방지할 수 있다.
  • by viewModels() 키워드를 사용하여 ViewModelFactory를 사용하지 않고 ViewModel을 초기화하였다.
  • by viewModels()는 Activity와 Fragment 모두 사용 가능하며, by viewModels()를 이용한 초기화는 해당 ViewModel이 초기화되는 Activity 혹은 Fragment의 Lifecycle에 종속된다.

 

 


 

 

📌 N:1 

하지만 하나의 ViewModel에서, 즉 Fragment를 가지고 있는 Activity의 ViewModel에서 가지고 있는 데이터를 여러 개의 Fragment에서 공유하고 싶을 수 있다. 

 

Activity

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    ...
    ...
    private val mainViewModel: MainViewModel by viewModels()
}

 

FragmentA

@AndroidEntryPoint
class FragmentA : Fragment() {
    ...
    ...
    private val mainViewModel: MainViewModel by activityViewModels()
}

 

FragmentB

@AndroidEntryPoint
class FragmentB : Fragment() {
    ...
    ...
    private val mainViewModel: MainViewModel by activityViewModels()
}

 

  • 우선 전제는 FragmentA와 FragmentB가 Activity에 종속되어 있는 상황이다.
  • by activityViewModels()는 Fragment에서만 사용 가능한 viewModel 초기화 방식이다.
inline fun <reified VM : ViewModel> Fragment.activityViewModels

내부 코드를 보아도 Fragment에 대한 확장 함수로 짜여 있는 것을 확인할 수 있다.

  • Fragment는 Activity에 종속되어 있기 때문에 Fragment가 생성된 Activity의 Lifecycle에 ViewModel이 종속되어 있다.