Hilt를 공부하면서 가장 헷갈리면서도 정보를 찾기 힘들었던 것이 바로
Component와 Scope의 차이점이었습니다.
다른 분들이 올려주신 여러 자료들을 보아도 잘 이해가 되지 않아..😭
그냥 안드로이드 스튜디오에서 여러 경우의 수를 다 디버깅 해보면서 저 나름대로 차이점을
정리해보았어요.ㅎㅎ
안드로이드를 독학하고 있기 때문에 잘못된 점이 있을 수 있습니다.😌
만약 잘못 정리한 부분이 있다면 꼭 댓글로 말씀 부탁드립니다! 🔥
테스트를 위해 작성한 프로젝트의 구조는 아래와 같습니다.
1. Component는 반드시 Module 또는 EntryPoint와 함께 사용해야 한다.
가장 먼저 일반 클래스에 Component를 사용해보자!
@InstallIn(ViewModelComponent::class)
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
!! 에러가 출력되고 있습니다. 😱
때문에 일반 타입에서는 Scope annotation만을 사용할 수 있고, @InstallIn + Component는 @Module과 함께 사용하는 것을 알 수 있습니다. 👍🏻
2. Scope Annotation 1 : Component
그럼 먼저 Module이 아닌 별도의 타입들에 Scope annotation을 사용할 때의 동작에 대해 알아보겠습니다.
Hilt에 대한 안드로이드 공식문서를 보면 Component들은 위와 같이 계층 구조를 가진다는 것을 알 수 있습니다.
🌹 Component란! 각각의 Component에 해당하는 라이프사이클에 맞게 생성되고 파괴되는 인스턴스들의 집합(?)이라고 이야기할 수 있을거 같습니다.
예를 들어, ViewModel에서 아래와 같이 ActivityScope로 지정된 UseCase를 사용하고 있다고 합시다.
ActivityScoped로 지정된 UseCase는 ActivityComponent에 생성되며, 이 ActivityComponent는 Activity의 라이프사이클에 맞게 생성되고 파괴될 수 있습니다.
하지만, 알다싶이 Activity의 생명주기는 ViewModel의 생명주기보다 짧을 수 있기 때문에
ViewModel에서 주입받은 testUseCase가 언제나 메모리상에 존재하고 있다는 것을 보장해주지 못합니다.
ViewModel은 계속 살아있지만, Configuration Change로 인해 Activity가 파괴되었을 때, ActivityComponent 내에 존재하는 UseCase도 함께 파괴될 수 있습니다.
이러한 문제점을 방지하기 위해 호출하는 쪽(여기서는 ViewModel)와 동일하거나 더 긴 라이프사이클을 가진 Component의 타입만 사용할 수 있도록 하는 듯 합니다.
위 예제를 코드로 구현하면 아래와 같습니다.👇🏻
@ActivityScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
여기서 알 수 있는 점은 의존성을 주입받을 때, 자신을 포함한 자신보다 상위에 있는 Scope 타입만 주입받을 수 있다는 점입니다.🤗
👇🏻 요렇게!
🌹 즉, ViewModel에서는 ViewModelScoped, ActivityRetainedScoped, Singleton으로 범위가 지정된 타입만 사용할 수 있습니다.
이는 의존성 주입으로 인해 생긴 tree에서도 동일하게 적용됩니다.
예를 들어, 아래와 같은 구조로 되어있다고 하자
이를 코드로 표현하면
@Module
@InstallIn(ViewModelComponent::class)
abstract class RepositoryModule {
@ViewModelScoped
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ActivityScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
이와 같으며, 예상하셨듯이 동일한 에러가 발생해 컴파일이 되지 않습니다. 🤗
📌 정리
Scope 어노테이션으로 인해 자신을 포함한 상위 Scope의 타입만 주입받을 수 있습니다. 🍿
3. Scope Annotation 2 : Singleton
이 하나만 기억하고 있으면 됩니다.
🌹 동일한 Scope에서는 매번 새로운 인스턴스, 상위 Scope에서는 동일한 인스턴스
여기서는 테스트를 아래와 같이 수행하였습니다.
3.1 서로 다른 두 viewModel에서 ViewModelScope로 지정된 UseCase를 주입받아보자
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
@HiltViewModel
class OtherViewModel @Inject constructor(
private val testUseCase: TestUseCase
) : ViewModel() {
fun init() {
testUseCase("main")
println("use case : $testUseCase")
}
}
3.1.1 MainViewModel에서
viewModel | @20812 |
useCase | @20814 |
3.1.2 OtherViewModel에서
viewModel | @20819 |
useCase | @20821 |
ViewModel에서 동일한 Scope인 ViewModelScope로 지정된 UseCase를 주입받았으므로, 두 ViewModel은 다른 UseCase 인스턴스를 받아 사용하고 있습니다. 🤗
이는 앞에서도 말했듯이 ViewModelScoped로 지정된 UseCase는 ViewModelComponent 내에 존재하게 되고, 각 ViewModelComponent는 자신을 호출한 ViewModel의 라이프 사이클에 맞추어 동작하기 때문입니다.
👇🏻 요렇게?
그렇다면, 만약 UseCase가 상위 Scope인 AcitivtyRetainedScope였다면?
3.2 서로 다른 두 viewModel에서 ActivityRetainedScope로 지정된 UseCase를 주입받아보자
// 위 코드에서 UseCase 부분만 다르다.
@ActivityRetainedScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
3.2.1 MainViewModel에서
viewModel | @20814 |
useCase | @20816 |
3.2.2 OtherViewModel에서
viewModel | @20821 |
useCase | @20816 |
ViewModel에서 상위 Scope인 ActivityRetainedScope로 지정된 UseCase를 주입받아 사용하고 있으므로,
두 ViewModel에서 동일한 인스턴스의 UseCase를 사용하고 있는 것을 알 수 있습니다. 🔥
동일한 Activity에서 호출되는 MainViewModel과 OtherViewModel이 동일한 ActivityRetainedComponent를 바라보고 있기 때문입니다.
👇🏻 요렇게?
📌 정리
동일한 Scope라면 매번 새로운 인스턴스, 상위 Scope라면 동일한 Instance를 제공
4. @Module & @InstallIn & Component
이번에는 Repository를 Module로 만들어서 Scope와 어떻게 다른지 확인해보겠습니다!
저는 아래 이미지와 같다고 이해를 하였습니다. 😭
앞서 살펴보았듯이 Scope Annotation에서는
- 동일한 Scope 또는 상위 Scope로만 접근 가능
- 상위 Scope에서는 동일한 Instance(Singleton)를 받아옴
이 2가지 역할을 담당하고 있었습니다.
각 역할이 @Module에서는 2가지로 나누어 지게 됩니다.
동일한 Scope 또는 상위 Scope로만 접근이 가능하도록 하는 것은 Component가
Singleton으로 Instance를 제공하는 것은 Scope 어노테이션이 담당하고 있습니다.
예제를 보면서 하나씩 확인해보도록 하겠습니다.
4.1 @ViewModelScoped UseCase & SingletonComponent + Not Singleton Scoped Repository
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.1.1 MainViewModel에서 호출된 TestUseCase
useCase | @20812 |
repository | @20815 |
4.1.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20825 |
repository | @20826 |
ViewModelScope보다 상위 Scope인 SingletonComponent로 지정되어 있지만,
매번 다른 인스턴스를 제공하는 것을 볼 수 있습니다.
여기서 알 수 있듯이, Module에서의 @InstallIn(SingletonComponent::class)는 접근 제한 기능만 제공할 뿐, 객체를 Singleton으로 제공하지는 않습니다.
🌹 여기서 바로 Scope라는 영어 의미를 다시 생각해보면 쉽게 이해할 수 있습니다.
Scoped 어노테이션을 통해 해당 타입을 모듈이 생성한 Component에 포획되면 매번 동일한 Instance를 제공해주는 것이 아닐까 해요.
이런 느낌으로..
4.2 @ActivityRetainedScoped UseCase & ViewModelComponent + Not ViewModel Scoped Repository
@Module
@InstallIn(ViewModelComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ActivityRetainedScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
위와 같이 ActivityRetainedScope에서 하위 Scope인 ViewModelComponent로 지정된 Repository를 사용하려고 하면 에러가 발생합니다.
4.3 @ViewModelScoped UseCase & SingletonComponent + Singleton Scoped Repository
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Singleton
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.3.1 MainViewModel에서 호출된 TestUseCase
useCase | @20814 |
repository | @20817 |
4.3.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20827 |
repository | @20817 |
@Singleton 어노테이션으로 메서드를 지정해주니, 동일한 인스턴스를 제공해주고 있는 것을 알 수 있습니다. 🔥👍🏻
📌 정리
Module에서 접근 제한은 @InstallIn + Component로, Singleton 객체는 Scope 어노테이션으로!
🌹 여기서 주의해야 할 점은 Scope 어노테이션은 해당 모듈의 Component와 동일해야 합니다.
👇🏻 참고!
Application | SingletonComponent | @Singleton |
Activity | ActivityRetainedComponent | @ActivityRetainedScoped |
ViewModel | ViewModelComponent | @ViewModelScoped |
Activity | ActivityComponent | @ActivityScoped |
Fragment | FragmentComponent | @FragmentScoped |
View | ViewComponent | @ViewScoped |
View annotated with @WithFragmentBindings | ViewWithFragmentComponent | @ViewScoped |
Service | ServiceComponent | @ServiceScoped |
예를 들어, ActivityRetainedComponent로 지정된 모듈 내에서 @Singleton 어노테이션을 사용한다면
👇🏻 요러한 에러와 만날 수 있습니다.
4.4 @ViewModelScoped UseCase & ViewModelComponent + ViewModel Scoped Repository
@Module
@InstallIn(ViewModelComponent::class)
abstract class RepositoryModule {
@ViewModelScoped
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ViewModelScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.4.1 MainViewModel에서 호출된 TestUseCase
useCase | @20813 |
repository | @20816 |
4.4.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20826 |
repository | @20827 |
ViewModelComponent 내에서 Scope 어노테이션을 지정했지만 서로 다른 인스턴스를 제공하는 것을 알 수 있습니다.
이전에도 말했든 ViewModelScoped로 지정된 UseCase와 Repository는 ViewModelComponent에 속하게 되고, 이는 각 ViewModel 라이프사이클에 의해 관리되고 있기 때문입니다.
4.5 @ActivityRetainedScoped UseCase & ActivityRetainedComponent + ActivityRetainedScoped Repository
@Module
@InstallIn(ActivityRetainedComponent::class)
abstract class RepositoryModule {
@ActivityRetainedScoped
@Binds
abstract fun bindTestRepository(
testRepositoryImpl: TestRepositoryImpl
): TestRepository
}
@ActivityRetainedScoped
class TestUseCase @Inject constructor(
private val testRepository: TestRepository
) {
operator fun invoke(caller: String): Int {
println("$caller : repository $testRepository")
return 10
}
}
4.5.1 MainViewModel에서 호출된 TestUseCase
useCase | @20811 |
repository | @20814 |
4.5.2 OtherViewModel에서 호출된 TestUseCase
useCase | @20811 |
repository | @20814 |
위와 같이 ViewModelScope 상위에 위치한 ActivityRetainedScope로 지정된 UseCase와 Repository는 ViewModel 라이프 사이클보다 오래 살아있는 ActivityReatinedComponent에 속하기 때문에 동일한 인스턴스를 제공해줍니다. 🤗
[정리]
1. Scope 어노테이션
- 접근 제한 + 싱글톤
2. @InstallIn + Component
- Module 접근 제한
3. @Module 내 Scope 어노테이션
- 싱글톤
제한된 메모리로 인해 life time 관리가 필요한 안드로이드에서 이러한 Component와 Scope를 적절히 지정하는 것은 중요합니다.🔥
이론적으로는 이렇게 알아보았는데,
실제 어플리케이션에서는 어떻게 반영할지는 직접 해봐야 알 듯 하네요. 😭
현재 진행중인 토이 프로젝트의 기획을 마무리하고 개발을 진행할 때,
이러한 부분과 관련된 기획도 선행하면 좋을거 같네요. 🤗
감사합니다. 😌
'Android > Android' 카테고리의 다른 글
[Android/Compose] Compose에서 Paging3 적용기 (0) | 2023.03.03 |
---|---|
[공식문서 열어보기] AlarmManger로 정확한 시간에 알림 띄워주자 (0) | 2023.02.13 |
[Android:Codelab] Dependency Injection and Test Doubles - 5 (0) | 2023.01.26 |
[Android:Codelab] Dependency Injection and Test Doubles - 4 (0) | 2023.01.25 |
[Android:Codelab] Dependency Injection and Test Doubles - 3 (0) | 2023.01.25 |