2024. 4. 30. 14:36ㆍIT/Android
<소스 코드>
ViewModel
WorkManager
<정리>
Service Locator - 종속 항목을 생성하고 반환하는 함수를 정의하는 싱글톤 클래스로, 클래스 내부에서 해당 함수를 호출하여 종속 항목을 생성한다. 종속 항목을 직접 생성하기 때문에 다형성을 사용할 수 없고, 사용자/피사용자 클래스 둘 중 하나만 변경되어도 다른 한쪽의 변경이 요구된다.
종속 항목 컨테이너 - 종속 항목 인스턴스를 포함하고 있는 클래스로, Application 클래스에서 해당 컨테이너 객체를 생성하여 앱 전체에서 접근할 수 있도록 구현된다. 일반적으로 ViewModel의 팩토리를 정의하면서 Application 클래스 내부에 선언된 컨테이너 객체를 사용하여 ViewModel의 파라미터에 저장소를 전달하는 방식으로 사용된다. 이를 종속 항목 수동 삽입이라고 하는데 Hilt 라이브러리를 사용하면 해당 과정이 자동으로 진행된다.
Hilt - 안드로이드 종속 항목 삽입을 자동으로 해주는 라이브러리로, 프로젝트의 모든 안드로이드 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리해 준다. Dagger를 기반으로 하여 성능, 확장성, 안드로이드 지원과 같은 Dagger의 모든 이점을 포함하고 있다.
*안드로이드 클래스 : 액티비티, 프래그먼트, 서비스, 브로드캐스트 리시버와 같은 안드로이드 프레임 워크의 구성 요소.
@HiltAndroidApp - Application 클래스에 해당 어노테이션을 지정하면 Hilt는 해당 Application 클래스에 앱 전체의 종속 항목들을 포함하는 컨테이너를 생성한다. 이렇게 생성된 컨테이너의 생명 주기는 Application 객체에 종속되고 이는 앱의 최상위 범위이므로 앱 전체에서 해당 객체가 제공하는 종속 항목에 접근할 수 있다.
@HiltAndroidApp
class LogApplication : Application() {
...
}
@AndroidEntryPoint - 해당 어노테이션이 지정된 안드로이드 클래스는 Application 클래스에서 생성된 컨테이너로부터 해당 클래스에 필요한 종속 항목들을 포함하는 컨테이너를 생성하는데, 이는 해당 어노테이션이 붙은 안드로이드 클래스마다 생성된다. 만약 부모 클래스에 해당 어노테이션을 지정하고 이를 상속받는 자식 클래스가 있다면 해당 어노테이션을 생략해도 동일한 기능을 하게 된다. 단, 부모 클래스에 생성된 컨테이너를 자식 클래스와 공유하는 것이 아니라 독립적인 컨테이너가 생성된다.
*Hilt가 지원하는 안드로이드 클래스 : Application, ViewModel, Activity, Fragment, View, Service, BroadcastReceiver.
@Inject - 해당 어노테이션을 프로퍼티의 선언 앞에 지정하면 생성된 컨테이너로부터 적절한 종속 항목을 가져오고, 해당 어노테이션을 클래스 생성자에 지정하면 Hilt에게 종속 항목 대상임을 알리는데, 이때 생성자 파라미터에 종속 항목이 요구된다면 해당 타입에 대해서도 마찬가지로 종속 항목 대상임을 Hilt에게 알려야 한다. 참고로 Hilt가 알고 있는 종속 항목 인스턴스를 타입에 따라 적절히 제공하는 방법을 결합(bindings)이라 부른다.
class DateFormatter @Inject constructor() { ... }
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) { ... }
//LoggerLocalDataSource가 종속 항목임을 Hilt에게 알리는데, 파라미터에 또 다른 종속 항목이 요구된다.
@AndroidEntryPoint
class LogsFragment : Fragment() {
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var dateFormatter: DateFormatter
...
}
@Singleton - 클래스에 지정하면 애플리케이션 컨테이너에서 항상 같은 인스턴스를 제공하는데, 이를 인스턴스 범위를 컨테이너로 지정이라고 한다. 추가로 결합을 특정 범위로 지정하여 해당 범위에서 동일한 인스턴스를 참조하게 만들 수도 있다.
@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
...
}
Hilt Module - 인터페이스나 프로젝트에 포함되지 않은 클래스와 같이 생성자에 @Inject 어노테이션을 직접 삽입할 수 없는 종속 항목을 추가하기 위해 정의하는 클래스로, 모듈 이름은 제공하는 종속 항목 유형을 설명할 수 있어야 하고 적절한 컨테이너를 설정해야 한다.
@Module - 해당 어노테이션이 붙은 클래스가 모듈임을 Hilt에게 알린다.
@InstallIn - 해당 어노테이션을 사용하여 모듈을 설치할 안드로이드 클래스를 지정하는데, 이렇게 지정된 범위 내에서 해당 모듈이 제공하는 종속 항목 인스턴스를 사용할 수 있게 된다.
@Binds - 종속 항목으로 인터페이스의 객체를 제공해야 할 때 사용할 수 있는 방법으로, 추상 함수를 정의하면서 파라미터로는 제공할 인터페이스를 구현한 객체를, 반환 타입으로는 해당 인터페이스 타입을 명시하여 Hilt에게 해당 종속 항목 타입에 대해 알린다.
interface AnalyticsService {
fun analyticsMethods()
}
// Constructor-injected, because Hilt needs to know how to
// provide instances of AnalyticsServiceImpl, too.
class AnalyticsServiceImpl @Inject constructor(
...
) : AnalyticsService { ... }
@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {
@Binds
abstract fun bindAnalyticsService(
analyticsServiceImpl: AnalyticsServiceImpl
): AnalyticsService
}
@Provides - 빌더 패턴으로 생성해야 하는 객체나 클래스가 외부 라이브러리로부터 제공되어 생성자에 @Inject 어노테이션을 직접 삽입할 수 없는 객체를 제공해야 할 때 사용할 수 있는 방법으로, 함수를 정의하면서 파라미터로는 제공할 객체를 생성하기 위해 필요한 종속 항목을, 반환 타입으로는 제공할 객체의 타입을 그리고 본문으로는 해당 객체를 제공하기 위한 방법을 명시하여 Hilt에게 해당 종속 항목 타입에 대해 알린다. 추가로 이 함수는 해당 타입의 객체가 요구될 때마다 실행된다.
@InstallIn(SingletonComponent::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()
}
}
@Qualifier - 동일한 타입에서 여러 종속 항목을 제공해야 할 때 사용할 수 있는 방법으로, 해당 어노테이션을 사용하여 식별을 위한 한정자를 정의하고 이렇게 만들어진 한정자를 모듈의 종속 항목을 제공하는 함수에 각각 명시하여 구분한다. 마지막으로 종속 항목을 제공받을 프로퍼티에 특정 한정자를 명시하면 해당하는 종속 항목을 제공받을 수 있게 된다. 추가적으로 타입에 한정자를 사용한다면 해당 타입의 모든 종속 항목에 한정자를 사용해야만 오류가 발생하지 않는다.
@Qualifier
annotation class InMemoryLogger
@Qualifier
annotation class DatabaseLogger
@InstallIn(SingletonComponent::class)
@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
}
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
@DatabaseLogger
@Inject lateinit var logger: LoggerDataSource
//...생략
}
@AndroidEntryPoint
class LogsFragment : Fragment() {
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
//...생략
}
*@ApplicationContext - 애플리케이션 컨텍스트를 가져오는 한정자.
*@ActivityContext - 액티비티 컨텍스트를 가져오는 한정자.
@InstallIn으로 모듈을 설치할 안드로이드 클래스를 지정하는 데 사용할 수 있는 구성 요소가 존재한다. 예를 들어 위의 코드에서는 모듈에 @InstallIn(SingletonComponent::class)를 설정하여 앱 전체에서 종속 항목에 접근할 수 있도록 설정하였다.
모듈의 생성과 소멸 시기가 존재한다. 예를 들어 @InstallIn(SingletonComponent::class)이 설정된 모듈은 앱이 시작할 때 생성되고, 앱이 종료될 때 소멸된다.
종속 항목 객체에 범위를 지정하여 특정 범위 내에서는 새로운 객체를 생성하지 않고 동일한 객체를 공유하게 설정할 수 있다. 예를 들어 모듈에 @InstallIn(FragmentComponent::class)을 설정하고 종속 항목을 반환하는 함수에 @FragmentScoped를 설정한다고 했을 때(결합의 범위는 결합이 설치된 구성 요소의 범위와 일치해야 한다), Fragment에서는 해당 종속 항목을 요구할 때마다 동일한 객체를 공유하게 된다. 추가로 범위를 지정한 종속 항목은 동일한 객체를 공유하기 위해 해당 범위가 소멸될 때까지 메모리에 남아 있기 때문에 생성하는데 비용이 많이 들고, 동기화가 필요한 종속 항목에서 사용하는 것이 권고된다.
안드로이드 클래스의 상위 계층에서 설치한 모듈은 하위 계층에서도 접근할 수 있다. 예를 들어 애플리케이션 클래스에 정의한 모듈은 앱 전체에서 접근이 가능하다.
모듈의 종속 항목 결합 함수를 정의할 때 기본 결합 객체들을 Hilt로부터 제공받아 정의할 수 있다. 제공되는 기본 결합 객체들은 모듈이 설치되는 범위에 따라 달라지며, 일반적인 액티비티, 프래그먼트 타입들만 사용할 수 있다.
@EntryPoint - Hilt가 지원하는 클래스(안드로이드 클래스)가 아닌 클래스에 종속 항목을 삽입하고 싶을 때 사용할 수 있는 방법으로, 해당 어노테이션이 지정된 타입은 Hilt가 관리하는 객체 그래프의 진입점이 된다. 모듈과 마찬가지로 @InstallIn을 사용하여 진입점을 설치할 범위를 지정해야만 한다. EntryPointerAccessors의 Static메서드를 사용하여 진입점에 접근할 수 있는데, 진입점이 설치된 범위에 따라 사용하는 Static메서드가 달라진다. 해당 메서드의 파라미터로는 종속 항목 객체 혹은 종속 항목 객체를 포함하고 있는 @AndroidEntryPoint 객체를 전달하면 된다.
class ExampleContentProvider : ContentProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ExampleContentProviderEntryPoint {
fun analyticsService(): AnalyticsService
}
//...생략
override fun query(...): Cursor {
val appContext = context?.applicationContext ?: throw IllegalStateException()
val hiltEntryPoint =
EntryPointAccessors.fromApplication(appContext, ExampleContentProviderEntryPoint::class.java)
val analyticsService = hiltEntryPoint.analyticsService()
}
}
Hilt는 ViewModel 객체의 종속 항목 삽입을 지원한다.
@HiltViewModel
class ExampleViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val repository: ExampleRepository
) : ViewModel() {
...
}
@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() {
private val exampleViewModel: ExampleViewModel by viewModels()
...
}
Hilt는 WorkManager 객체의 종속 항목 삽입을 지원한다.
@HiltWorker
class ExampleWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
workerDependency: WorkerDependency
) : Worker(appContext, workerParams) { ... }
@HiltAndroidApp
class ExampleApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override fun getWorkManagerConfiguration() =
Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
Hilt의 UI 테스트는 종속 항목 삽입을 지원해준다.
테스트에 사용할 Application을 지정하기 위한 테스트 실행기 생성. ( 필요시 )
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
이렇게 만든 테스트 생성기를 계측 테스트에 사용할 수 있도록 프로젝트에게 알림. ( 필요시 )
android {
...
defaultConfig {
...
testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
}
...
}
종속 항목 생성을 담당하는 @HiltAndroidTest와 관리와 삽입을 담당하는 HiltAndroidRule를 사용.
@HiltAndroidTest
class SettingsActivityTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
// UI tests here.
}
테스트에 @Inject 어노테이션을 사용하여 종속 항목 삽입.
@HiltAndroidTest
class SettingsActivityTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var analyticsAdapter: AnalyticsAdapter
@Before
fun init() {
hiltRule.inject()
}
@Test
fun `happy path`() {
// Can already use analyticsAdapter here.
}
}
test 혹은 androidTest 폴더에 새로운 모듈을 정의하고 @TestInstallIn 어노테이션을 붙이면, 모든 테스트에서 기존의 모듈을 새롭게 정의한 모듈로 대체할 수 있다. 단, 새롭게 정의한 모듈에 없는 종속 항목은 기존의 종속 항목을 참조한다.
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [AnalyticsModule::class]
)
abstract class FakeAnalyticsModule {
@Singleton
@Binds
abstract fun bindAnalyticsService(
fakeAnalyticsService: FakeAnalyticsService
): AnalyticsService
}
테스트 클래스에 @UninstallModules 어노테이션을 삽입하고 내부에 대체 하길 원하는 모듈을 정의하면, 해당 클래스에서만 기존의 모듈을 제거하고 내부에 정의한 모듈을 참조할 수 있다. 단, @InstallIn으로 정의된 모듈만 제거가 가능하다.
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
@Module
@InstallIn(SingletonComponent::class)
abstract class TestModule {
@Singleton
@Binds
abstract fun bindAnalyticsService(
fakeAnalyticsService: FakeAnalyticsService
): AnalyticsService
}
...
}
필드에 @BindValue 어노테이션을 삽입하면, 해당 필드의 타입을 가진 종속 항목은 해당 필드에 저장된 객체로 대체될 수 있다.
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
@BindValue @JvmField
val analyticsService: AnalyticsService = FakeAnalyticsService()
...
}
참고 :
'IT > Android' 카테고리의 다른 글
Android - Firebase Realtime Database Read and Write (0) | 2024.05.07 |
---|---|
Android - Firebase Realtime Database Structure (0) | 2024.05.07 |
XML - ViewBinding, Customised Button In Android (0) | 2024.04.11 |
XML - LinearLayout, DatePickerDialog (0) | 2024.04.09 |
XML - View, ViewGroup (0) | 2024.04.08 |