동기
현대적인 구조로 앱을 개발하다 보면, 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 은 어떻게 제거되는가?
ViewModel 은 Activity 나 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 를 철저하게 제거해주는 것이 중요하겠습니다.
'Android > Tech' 카테고리의 다른 글
[Jetpack Compose] CompositionLocal 로 이벤트 처리하기 (0) | 2024.02.29 |
---|---|
[Jetpack Compose] 불필요한 Recomposition 을 줄여 앱 퍼포먼스 개선하기 (1) | 2024.02.25 |
OkHttp3 Interceptor 를 통해 표준화된 응답의 에러 처리하기 (0) | 2024.02.01 |
ParentFragmentManager, ChildFragmentManager (0) | 2024.01.21 |
Composable 파라미터 주의 사항 (feat.Recomposition) (0) | 2024.01.17 |