ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Compose와 Hilt 의존성 주입
    Android 2024. 5. 8. 19:37

    이 글을 아래 포스팅을 기반으로 필요한 내용만 골라서 작성하였습니다. 

    자세한 내용은 아래 포스팅을 참고해주세요.

     

    Dependency Injection in Compose

    Learn how Hilt provides and scopes dependencies in a traditional Android app and Compose changes our approach.

    medium.com


     

    Composable은 클래스가 아닌 간단한 함수로 구성되어 있어, 클래스 기반 의존성 주입 패턴을 사용할 수 없습니다. Composable은 인스턴스화되지 않고 상태를 저장하지 않으며(Stateless), 주로 재사용 가능한 UI 로직을 제공합니다. 그러므로 Composable은 생성자 주입이나 멤버 주입을 사용할 수 없고, 오직 함수의 파라미터를 통해서만 의존성을 받을 수 있습니다.

     

    또한, Composable의 수명은 Activity나 Fragment의 Lifecycle만큼 명확하게 정의되어 있지 않습니다. Composable은 Composition에 빈번하게 진입하고 나오며, Composition 내의 여러 위치에서, 계층의 다양한 깊이에서 존재할 수 있습니다. 그러므로 Composable에 의존성 Component를 연결하는 것은 직관적이지 않고, Hilt는 Composable 사용을 위한 Scope나 Component를 정의하거나 포함하지 않습니다.

    Use ViewModel and Compose Navigation

    ViewModel에 의존성을 주입하는 것은 객체를 UI 계층에 연결하는 효과적인 방법입니다. 

    ViewModel은 특정 Composable에 종속되지 않으며, 정해진 Lifecycle을 따르고, 필요할 때 다음과 같은 방법으로 얻을 수 있습니다.

    @HiltViewModel
    class CheckoutViewModel @Inject constructor(
        private val savedStateHandle: SavedStateHandle,
        private val paymentApi: PaymentApi
    ) : ViewModel() {
    
        fun submitPayment(...) {
            paymentApi.submitPayment(...)
        }
    }
    
    @Composable
    fun CheckoutScreen(
        viewModel: CheckoutViewModel = hiltViewModel()
    ) {
        Button(
            onClick = { viewModel.submitPayment(...) }
        ) {
            Text("Submit")
        }
    }

     

    `hiltViewModel()`은 ViewModel을 유지하는 가장 가까운 적합한 Owner를 찾는 역할을 하며, 이는 보통 Activity나 Fragment입니다. 그러나 Compose를 사용하여 만들어진 앱들은 종종 SingleActivity 구조를 채택하고 Fragment를 사용하지 않는 추세입니다. 이 경우, 하나의 Activity가 유일한 Owner가 되며, 모든 ViewModel(및 주입된 객체들)은 해당 Activity가 살아 있는 동안 유지됩니다. 이는 특정 UI 부분만을 위한 ViewModel에게는 과도하게 긴 생명주기가 될 수 있습니다.

     

    Compose Navigation 라이브러리를 사용하면 이 문제를 완화할 수 있습니다.
    이 라이브러리를 활용하면, `hiltViewModel()`은 자동으로 Navigation Destination의 Backstack 항목을 ViewModel의 소유자로 설정합니다. 이로 인해 ViewModel은 Navigation Backstack에 해당 Destination이 존재하는 동안에만 유지되며, Destination이 필요로 하는 정확한 리소스만을 제공합니다. Destination이 Backstack에서 제거되면, ViewModel도 함께 정리되어 효율적인 리소스 관리가 가능합니다.

    Use an enclosing class with constructor injection

    ViewModel에 직접 주입할 수 없는 의존성이 있을 경우, 해당 의존성을 사용하는 콘텐츠와 논리적으로 그룹화하기 위해 Composable을 포함하는 클래스를 생성하고, 그 클래스에 의존성을 주입할 수 있습니다. 이후, 필요한 위치의 Activity나 Fragment에 이 클래스를 주입하고, 해당 위치에서 Composable을 호출합니다. 이러한 포괄 클래스는 주입된 의존성 외에 다른 상태를 가지지 않으며, 어떠한 Scope annotation도 사용하지 않아야 합니다.

    // Activity 의존성을 필요로 하므로, ViewModel에서는 주입할 수 없음
    class PaymentApi @Inject constructor(
        private val activity: Activity,
    ) { ... }
    
    // PaymentApi를 사용하는 Composable을 포함
    class CheckoutScreenFactory @Inject constructor(
        private val paymentApi: PaymentApi,
        // ... more dependencies
    ) {
        @Composable
        fun Content(...) {
            // paymentApi can be accessed here
        }
    }
    
    @AndroidEntryPoint
    class ShoppingActivity : ComponentActivity {
    
        @Inject
        lateinit var checkoutScreenFactory: CheckoutScreenFactory
    
        fun onCreate(savedInstanceState: Bundle) {
            super.onCreate(savedInstanceState)
            setContent {
                // somewhere down in the composition hierarchy
                checkoutScreenFactory.Content(...)
            }
        }
    }

     

    이 패턴은 Composable을 테스트할 때 PaymentApi의 테스트 더블을 사용하여 CheckoutScreenFactory를 구성함으로써, 실제 Activity를 사용하지 않고도 Composable의 Content 동작을 테스트할 수 있습니다.

    Avoid storing dependencies in CompositionLocal

    `@Inject`로 주입된 객체를 CompositionLocal에 저장하여 Composable이 이를 획득하게 하는 방법은, 일련의 Composable에 객체를 파라미터로 전달하는 것보다 겉보기에 더 단순할 수 있습니다.

     

    그러나, 이 방식은 Hilt가 제공하는 안전한 다양한 기능을 포기하게 되어, 런타임 오류가 발생할 위험이 있습니다. 필요한 객체가 없거나, 개발자가 모르는 사이에 다른 Composable에 의해 대체될 수 있습니다. 또한, 주의하지 않으면 잘못된 Scope에 속하는 객체를 사용하게 될 수 있습니다.

    Use Entry Points

    Hilt는 `@EntryPoint`를 이용해 의존성 그래프에서 객체를 얻는 방법을 제공합니다. 이 방법은 객체를 '주입'하기보다는 '요청'하는 것 같은 느낌을 줍니다. 하지만 CompositionLocal과는 달리, EntryPoint를 사용하면 받는 객체가 올바른 타입이고 현재 Scope에 맞다는 것이 보장됩니다. 예를 들어, 다음 코드는 CheckoutScreen Composable에서 PaymentApi를 가져옵니다.

    @EntryPoint
    @InstallIn(ActivityComponent::class)
    interface PaymentApiEntryPoint {
        fun getPaymentApi(): PaymentApi
    }
    
    @Composable
    fun CheckoutScreen() {
        val activity = LocalContext.current as Activity
        val paymentApi = remember {
            EntryPointAccessors.fromActivity(
                activity,
                PaymentApiEntryPoint::class.java
            ).getPaymentApi()
        }
        // ...
    }

     

    필요한 단계는 원하는 객체가 포함된 '의존성 컴포넌트'를 파악하고, `@InstallIn`을 사용하여 EntryPoint와 연결하는 것입니다. PaymentApi를 ActivityComponent에서 얻기 때문에, `EntryPointAccessors.fromActivity()`를 사용하여 요청합니다.

     

    remember는 CheckoutScreen이 ReComposition될 때마다 의존성 그래프에 접근하는 것을 방지하기 위해 중요합니다. CheckoutScreen이 Composition을 벗어날 때마다 paymentApi가 초기화되기 때문에, 이러한 패턴은 사용자가 앱의 다른 부분으로 전환하거나 다른 중대한 UI 변경이 일어날 때까지 유지되는 고수준의 Composable에 적용되어야 합니다.

     

    또 한 가지 주목할 점은 사용하고 있는 '의존성 컴포넌트'는 여전히 Hilt에 속해 있으며, Composable보다 Lifecycle이 길다는 것입니다. 이는 대부분의 사용 사례에서 충분하지만, Lifecycle이 특정 Composable과 완전히 일치하는 의존성 컴포넌트를 원한다면 다음을 보세요.

    Use a custom dependency Component

    Hilt의 '의존성 컴포넌트'는 변경 불가능한 Lifecycle을 가지므로 새 컴포넌트를 생성할 수 있습니다.
    새로 생성된 컴포넌트는 Hilt에 포함되지 않기 때문에, Hilt는 해당 컴포넌트의 생성 위치와 유지 기간을 알 수 없습니다. 따라서 이 부분은 직접 구현해야 합니다. PaymentApi를 포함할 PaymentComponent를 정의해 보겠습니다.

    @DefineComponent(parent = ActivityComponent::class)
    interface PaymentComponent {
    
        @DefineComponent.Builder
        interface PaymentComponentBuilder {
            fun build(): PaymentComponent
        }
    }
    
    @EntryPoint
    @InstallIn(ActivityComponent::class)
    interface PaymentComponentBuilderEntryPoint {
        fun paymentComponentBuilder(): Provider<PaymentComponentBuilder>
    }
    
    @EntryPoint
    @InstallIn(PaymentComponent::class)
    interface PaymentApiEntryPoint {
        fun paymentApi(): PaymentApi
    }

     

    '커스텀 컴포넌트'를 만드는 데는 두 가지가 필요합니다. 이 예제에서는 ActivityComponent인 부모 컴포넌트와 '커스텀 컴포넌트'를 구성하기 위한 Builder 인터페이스입니다. '커스텀 컴포넌트'의 Builder에 접근할 수 있도록 PaymentComponentBuilderEntryPoint라는 새로운 EntryPoint를 추가하고, PaymentComponent에 설치하여 PaymentApiEntryPoint를 변경합니다.

     

    CheckoutScreen Composable로 돌아가서, PaymentComponentBuilder를 통해 빌드를 진행한 후 생성된 PaymentComponent로부터 PaymentApi를 얻을 수 있습니다. 또한 이전과 동일하게, ReComposition이 발생할 때마다 이러한 과정이 반복되지 않도록 remember를 활용합니다.

    @Composable
    fun CheckoutScreen() {
        val activity = LocalContext.current as Activity
        val paymentApi = remember {
            val paymentComponent = EntryPointAccessors.fromActivity(
                activity,
                PaymentComponentBuilderEntryPoint::class.java
            ).paymentComponentBuilder().get().build()
            EntryPoints.get(
                paymentComponent,
                PaymentApiEntryPoint::class.java
            ).paymentApi()
        }
        // ...
    }

     

    CheckoutScreen은 이제 PaymentComponent의 Lifecycle을 관리합니다.
    이 Lifecycle은 CheckoutScreen이 처음 Composition될 때 시작되며, PaymentComponent는 remember 블록 내에서 구성됩니다. 그리고 CheckoutScreen이 Composition을 벗어날 때 remember 연산이 해제되면서 Lifecycle이 종료됩니다.

    이는 앞서 Use Entry Points에서 언급한 것과 같이, 오랜 시간 동안 Composition에 남아 있도록 설계된 고수준 Composable에만 사용되어야 합니다.

    'Android' 카테고리의 다른 글

    MediaSessionService - Foreground 음악 재생  (0) 2024.05.23
    Compose - Strong skipping mode  (0) 2024.04.26
    Android Compose - Composable Component API Guideline  (1) 2024.02.29
    Android Compose - API GuideLine  (0) 2024.02.28
    Android Standby Bucket  (0) 2022.07.01
Designed by Tistory.