본문 바로가기

Android/Tech

[Jetpack Compose] HiltViewModel() 을 통해 주입된 ViewModel 의 생애 알아보기

동기

현대적인 구조로 앱을 개발하다 보면, ViewModel 의 사용을 피할 수 없습니다. 다양한 요구 사항 및 유저 플로우에 의해 변경되거나 유실될 수 있는 UI 데이터를 안정적으로 유지하여야 하기 때문입니다.

Android Jetpack Library 중 하나인 Hilt 는 Jetpack Compose 를 사용하는 환경에서 ViewModel 의 사용을 보다 간편하게 해주는 기능을 제공하고 있으며, 이 것이 바로 포스트의 주제인 hiltViewModel() 입니다. 

오늘은 이에 대해 학습한 것을 정리하고자 합니다.


hiltViewModel()

hiltViewModel() 메서드에 작성된 주석은 다음과 같습니다.

Returns an existing HiltViewModel  -annotated ViewModel or creates a new one 
scoped to the current navigation graph present on the {@link NavController} back stack.
If no navigation graph is currently present then the current scope will be used, 
usually, a fragment or an activity.

 

@HiltViewModel 어노테이션이 달린 ViewModel 을 반환한다는 내용입니다. hiltViewModel() 메서드는 

androidx.hilt.navigation.compose 에 속한 메서드이기 때문에, 기본적으로 Navigation 을 사용하는 것에 주안점을 두고 있는 듯하나, Navigation 을 사용하지 않는다고 해서 사용할 수 없는 건 아닙니다. Actvity 와 Fragment 에 대한 지원도 존재하기 때문입니다.

 

명세를 살펴보면 다음과 같습니다.

@Composable
inline fun <reified VM : ViewModel> hiltViewModel(
    viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    },
    key: String? = null
): VM {
    val factory = createHiltViewModelFactory(viewModelStoreOwner)
    return viewModel(viewModelStoreOwner, key, factory = factory)
}

 

메서드가 굉장히 직관적으로 작성되어 있습니다. ViewModelStoreOwner 에 접근하는 CompositionLocal 을 통해 ViewModel 을 획득하는 방식입니다. 내부에서는 createHiltViewModelFactory() 메서드를 호출하고 있는데, 해당 메서드는 바로 아래에 작성되어 있습니다.

 

@Composable
@PublishedApi
internal fun createHiltViewModelFactory(
    viewModelStoreOwner: ViewModelStoreOwner
): ViewModelProvider.Factory? = if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
    HiltViewModelFactory(
        context = LocalContext.current,
        delegateFactory = viewModelStoreOwner.defaultViewModelProviderFactory
    )
} else {
    // Use the default factory provided by the ViewModelStoreOwner
    // and assume it is an @AndroidEntryPoint annotated fragment or activity
    null
}

 

해당 메서드는 HiltViewModelFactory() 함수를 실행하는데, HiltViewModelFactory() 에는 delegateFactory 라는 파라미터가 존재하고, 해당 파라미터는 HiltViewModelFactory.createInternal() 메서드를 호출하여 

ViewModelProvider.Factory 를 반환합니다. 

 

public HiltViewModelFactory(
    @NonNull Set<String> hiltViewModelKeys,
    @NonNull ViewModelProvider.Factory delegateFactory,
    @NonNull ViewModelComponentBuilder viewModelComponentBuilder) {
  this.hiltViewModelKeys = hiltViewModelKeys;
  this.delegateFactory = delegateFactory;
  this.hiltViewModelFactory =
      new AbstractSavedStateViewModelFactory() {
        @NonNull
        @Override
        @SuppressWarnings("unchecked")
        protected <T extends ViewModel> T create(
            @NonNull String key, @NonNull Class<T> modelClass, @NonNull SavedStateHandle handle) {
          RetainedLifecycleImpl lifecycle = new RetainedLifecycleImpl();
          ViewModelComponent component = viewModelComponentBuilder
              .savedStateHandle(handle)
              .viewModelLifecycle(lifecycle)
              .build();
          Provider<? extends ViewModel> provider =
              EntryPoints.get(component, ViewModelFactoriesEntryPoint.class)
                  .getHiltViewModelMap()
                  .get(modelClass.getName());
          if (provider == null) {
            throw new IllegalStateException(
                "Expected the @HiltViewModel-annotated class '"
                    + modelClass.getName()
                    + "' to be available in the multi-binding of "
                    + "@HiltViewModelMap but none was found.");
          }
          ViewModel viewModel = provider.get();
          viewModel.addCloseable(lifecycle::dispatchOnCleared);
          return (T) viewModel;
        }
      };
}

 

이후엔 Hilt 가 ViewModel 을 획득하는 방식으로 연결됩니다.

Hilt 가 ViewModel 을 어떻게 생성하고 주입하는지에 대해서는 다음 포스트에 자세히 설명해두었습니다.

 

 

[Dagger-Hilt] Hilt 는 어떻게 ViewModel 을 생성하고 주입하는가?

동기 DND 9기에 운영진으로 참여했는데, 코드 리뷰 요청이 들어왔습니다. 제가 리뷰해야하는 화면은 두 개였는데, 각 화면은 Screen-Level-Composable 로 구현되어 있고, 두 화면을 포함하는 Activity 가 존

blothhundr.tistory.com


주입된 ViewModel 은 어떻게 제거되는가?

ViewModelActivity Fragment 가 재시작되지 않고 파괴될 때 입니다. 그러나 Composable 메서드에는 기본적으로 Context 가 존재하지 않기에(참조할 수는 있겠지만) 제거되는 시점을 파악하기가 쉽지 않습니다.

이를 파악하기 위해 브레이크 포인트를 활용하였고, 아주 간단하게 답을 얻을 수 있었습니다.

 

Navigation 에서의 화면 전환은 Transition 이라는 명칭으로 취급되고, 이 Transition 의 시작과 끝에 다양한 처리가 수행됩니다.  

 

LaunchedEffect(transition.currentState, transition.targetState) {
    if (transition.currentState == transition.targetState) {
        visibleEntries.forEach { entry ->
            composeNavigator.onTransitionComplete(entry)
        }
        zIndices
            .filter { it.key != transition.targetState.id }
            .forEach { zIndices.remove(it.key) }
    }
}

 

Transition 의 상태를 비교하고, 현재 상태와 목표 상태가 일치하게 되면 해당 Transition 이 완료된 것으로 마크합니다. 이 마크하는 과정에서 ViewModel 의 clear() 메서드를 호출하게끔 합니다.

여기서 활용되는 entry 는 NavBackStackEntry 를 의미하며, NavBackStackEntry 는 NavController 의 

BackStack 에 있는 항목을 말합니다. NavBackStack 객체를 통해 제공되는 Lifecycle, ViewModelStore, SavedStateRegistry 등은 BackStack 에서 해당 대상이 pop() 되면 해제되며, ViewModel 도 여기서 삭제됩니다. 

 

해당 내용은 NavBackStackEntry 에 주석으로 작성되어 있습니다.

Representation of an entry in the back stack of a androidx.navigation.NavController. 
The Lifecycle, ViewModelStore, and SavedStateRegistry provided via this object are valid for 
the lifetime of this destination on the back stack: when this destination is 
popped off the back stack, the lifecycle will be destroyed, state will no longer be saved, 
and ViewModels will be cleared.

 

즉, hiltViewModel() 을 사용하는 환경에서 ViewModel 은 NavBackStack 에서 해당 Composable 이 pop() 될 때 제거됩니다.

 

조금 더 자세히 알아봅니다.

 

override fun markTransitionComplete(entry: NavBackStackEntry) {
    val savedState = entrySavedState[entry] == true
    super.markTransitionComplete(entry)
    entrySavedState.remove(entry)
    if (!backQueue.contains(entry)) {
        unlinkChildFromParent(entry)
        // If the entry is no longer part of the backStack, we need to manually move
        // it to DESTROYED, and clear its view model
        if (entry.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
            entry.maxLifecycle = Lifecycle.State.DESTROYED
        }
        if (backQueue.none { it.id == entry.id } && !savedState) {
            viewModel?.clear(entry.id)
        }
        updateBackStackLifecycle()
        // Nothing in backQueue changed, so unlike other places where
        // we change visibleEntries, we don't need to emit a new
        // currentBackStack
        _visibleEntries.tryEmit(populateVisibleEntries())
    } else if (!this@NavControllerNavigatorState.isNavigating) {
        updateBackStackLifecycle()
        _currentBackStack.tryEmit(backQueue.toMutableList())
        _visibleEntries.tryEmit(populateVisibleEntries())
    }
    // else, updateBackStackLifecycle() will be called after any ongoing navigate() call
    // completes
}

 

savedState 변수가 선언되는데, Transition 이 재시작에 의해 종료되므로 State 를 저장해야 하는지에 대한 플래그입니다. 만약 해당 변수에 false 가 할당되고, backQueue(BackStack) 에 제거되는 NavBackStackEntry 객체가 없다면 ViewModel 의 clear() 메서드를 호출합니다. 다만, clear() 메서드는 원래 ViewModel 의 clear() 메서드와 조금 명세가 다른데요. 다음과 같습니다.

 

fun clear(backStackEntryId: String) {
    // Clear and remove the NavGraph's ViewModelStore
    val viewModelStore = viewModelStores.remove(backStackEntryId)
    viewModelStore?.clear()
}

 

NavBackStackEntry 에 의존하는 id 값을 넘겨 Map<K, T> 에서 값을 참조하여 이를 해제하는 방식으로 진행됩니다. 이후에는 BackStack 을 업데이트 해주며 끝이납니다.


Navigation 을 사용하지 않는다면?

이 경우엔 사실 간단한데, Navigation 을 사용하지 않으므로 BackStack 자체가 존재하지 않습니다. 그러므로, ViewModel 이 존재하는 Activity 또는 Fragment 가 재시작 없이 파괴될 때 함께 제거됩니다.


뭔가 신경써야 하나? 라는 궁금증에 찾아 보게 되었습니다. 크게 신경 쓸 것은 없었고요. 다만, 불필요한 객체의 지속에 대한 위험을 제거하기 위해서는 사용하지 않는 NavBackStackEntry 를 철저하게 제거해주는 것이 중요하겠습니다.