최근에 compose navigation 과 관련된 글을 작성하였는데, 그 글에서 가볍게 언급만 하고 넘어갔던 내용에 대해, 그 이유를 자세하게 설명하는 글을 작성하고자 한다.(오랜만에 딥다이브)
우선 compose 에서 hiltViewModel 을 사용하기 위해선 다음과 같은 의존성을 추가해야 하는 것은 다들 잘 알 것 이다.
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidx-hilt-navigation-compose" }
여기서 의문이 생기는 부분은 다음과 같다.
왜 hiltViewModel 을 쓰려고 추가한 라이브러리에 navigation 이라는 이름이 붙어있지?
정답부터 말하자면, hiltViewModel 과 androidx-navigation 이 밀접한 관련이 있기 때문이다.
navigation argument 가 viewModel 의 savedStateHandle 로 전달되는 과정을 설명하면서, viewModel 과 androidx-navigation 의 연관성도 알아보도록 하자.
androidx-navigation 2.8.3 버전,
androidx-hilt-navigation-compose 1.2.0 버전을 기준으로 작성되었습니다.
fun NavController.navigateToDetail(
lectureName: String,
studentGradeList: List<Int>,
lecture: Lecture,
studentList: List<Student>,
) {
navigate(Route.Detail(lectureName, studentGradeList, lecture, studentList))
}
해당 navigate 함수를 타고 들어가다 보면 다음과 같은 코드를 확인 할 수 있다. (navigate 함수가 내부에도 navigate 함수가 존재하는데 2번 정도 타고 들어가면 된다.)
// NavController.kt
// NavBackStackEntity 객체 생성(이번 글의 핵심 키워드)
val backStackEntry =
NavBackStackEntry.create(
context,
node,
finalArgs,
hostLifecycleState,
viewModel // <- viewModel 등장
)
val navigator =
_navigatorProvider.getNavigator<Navigator<NavDestination>>(node.navigatorName)
navigator.navigateInternal(listOf(backStackEntry), navOptions, navigatorExtras) {
navigated = true
addEntryToBackStack(node, finalArgs, it)
}
NavBackStackEntry 에 대한 공식문서의 설명은 다음과 같다.
쉽게 풀어 설명하면, NavController 의 백스택 내에 각각의 화면들을 나타내며 그 화면들이 백스택에 존재하는 동안의 모든 상태와 정보들을 관리하는 객체 이다.
모든 상태와 정보에는 화면 자체의 정보(destination), 전달 데이터(arguments), 생명주기(lifecycle), 상태 저장/복원(savedState), 화면과 관련된 뷰모델(viewModel), 각 화면 고유 식별자(id)...etc 를 포함한다.
NavBackStackEntry 클래스의 전체 코드는 300줄 남짓이라 전체 코드를 확인하는데에는 어렵지 않다. NavController(2900줄)에 비하면...
NavBackStackEntry 의 전체 코드 중에 이번 글과 관련된 코드만 간략하게 살펴보면 다음과 같다.
// LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner 인터페이스 구현
class NavBackStackEntry :
// Lifecycle 관리
LifecycleOwner,
// ViewModel 관리
ViewModelStoreOwner,
// SavedState 관리
SavedStateRegistryOwner {
// arguments 는 immutableArgs 로 저장되어 보관됨
private val immutableArgs: Bundle?
// arguments 접근을 위한 getter
public val arguments: Bundle?
get() =
if (immutableArgs == null) {
null
} else {
Bundle(immutableArgs)
}
}
정리하자면,
과정 1) 에선 NavBackStackEntry 객체를 생성(lifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner 인터페이스를 구현)하고 인자로 넘긴 arguments 를 Bundle 에 저장하였다.(NavBackStackEntry 클래스 내부에서 관리)
NavBackStackEntry 가 생성될 때, SavedStateRegistryOwner 인터페이스를 구현한다고 위에서 언급하였다.
따라서 NavBackStackEntry 는 상태 저장/복원(savedState) 를 지원한다.
NavBackStackEntry 의 클래스에 savedStateHandle 관련 내부 코드를 확인해보도록 하자.
// LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner 인터페이스 구현
class NavBackStackEntry :
// Lifecycle 관리
LifecycleOwner,
// ViewModel 관리
ViewModelStoreOwner,
// SavedState 관리
SavedStateRegistryOwner {
// arguments 를 Bundle 에 보관
private val immutableArgs: Bundle?
// SavedStateHandle 생성
public val savedStateHandle: SavedStateHandle by lazy {
ViewModelProvider(this, NavResultSavedStateFactory(this))
.get(SavedStateViewModel::class.java)
.handle
}
// SavedStateViewModelFactory 생성
private val defaultFactory by lazy {
SavedStateViewModelFactory(
(context?.applicationContext as? Application),
this, // NavBackStackEntry 가 SavedStateRegistryOwner 인터페이스를 구현하고 있기 때문에, NavBackStackEntry 를 SavedStateRegistryOwner 타입으로 전달
arguments // <-- navigate 함수를 호출하여 전달된 arguments!
)
}
override val defaultViewModelCreationExtras: CreationExtras
get() {
val extras = MutableCreationExtras()
(context?.applicationContext as? Application)?.let { application ->
extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] = application
}
extras[SAVED_STATE_REGISTRY_OWNER_KEY] = this
extras[VIEW_MODEL_STORE_OWNER_KEY] = this
arguments?.let { args -> extras[DEFAULT_ARGS_KEY] = args }
return extras
}
// SavedStateHandle 생성을 위한 Factory
private class NavResultSavedStateFactory(owner: SavedStateRegistryOwner) :
AbstractSavedStateViewModelFactory(owner, null) {
override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
): T {
return SavedStateViewModel(handle) as T
}
}
// SavedStateHandle을 보관할 ViewModel
private class SavedStateViewModel(val handle: SavedStateHandle) : ViewModel()
}
savedStateHandle 은 SavedStateViewModel 을 통해 생성된다.
SavedStateViewModel 은 SavedStateViewModelFactory를 통해 만들어지는데,
SavedStateViewModelFactory 생성시, SavedStateRegistryOwner 와 arguments 를 함께 전달하고, SavedStateViewModelFactory 는 전달받은 arguments 를 savedStateHandle 초기 데이터로 사용하는 것을 확인할 수 있다.
정리하자면,
과정 2) 에선 savedStateHandle 의 생성과정에서 navigate 함수 호출시 전달받은 arguments 이 savedStateHandle 의 초기 데이터로 사용된다.
따라서 navigate 함수를 통해 전달되는 모든 인자들은 NavBackStackEntry 내에 savedStateHandle 에 저장된다. 어떻게 보면 이번 글의 제목에 대한 답변이라고 할 수 있겠다.
그런데 내가 궁금한건 NavBackStackEntry 의 savedStateHandle 이 아닌, 이동 하는 화면의 viewModel 의 savedStateHandle 로 전달 받는 것이기에, 조금만 더 살펴보도록 하자.
@Composable
fun DetailRoute(
innerPadding: PaddingValues,
popBackStack: () -> Unit,
viewModel: DetailViewModel = hiltViewModel(), // <-- hiltViewModel 생성
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DetailScreen(
innerPadding = innerPadding,
uiState = uiState,
popBackStack = popBackStack,
)
}
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val _uiState = MutableStateFlow(DetailUiState())
val uiState: StateFlow<DetailUiState> = _uiState.asStateFlow()
private val name: String = savedStateHandle.toRoute<Detail>(Detail.typeMap).lectureName
private val studentGradeList: List<Int> = savedStateHandle.toRoute<Detail>(Detail.typeMap).studentGradeList
private val lecture: Lecture = savedStateHandle.toRoute<Detail>(Detail.typeMap).lecture
private val studentList: List<Student> = savedStateHandle.toRoute<Detail>(Detail.typeMap).studentList
...
}
Compose 에서 hiltViewModel() 과 viewModel()의 차이와 관련한 설명은 하단 블로그글에 명료하게 기술되어 있어 생략하도록 하겠다.
[Compose] hiltViewModel()과 viewModel() 차이
HiltViewModel 의 생성 과정은 다음과 같다.
1. hiltViewModel() 함수 호출
@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)
}
@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
}
hiltViewModel() 함수를 보면 파라미터로 viewModelStoreOwner 를 필요로 하는 것을 알 수 있는데, 이를 생략하면 default 값으로 LocalViewModelStoreOwner.current 이 들어간다.
여기서 주입되는 기본 viewModelStoreOwner 가 NavBackStackEntry 이다! (Navigation 을 사용하는 경우)
왜냐하면 Compose Navigation 이 각 화면마다 NavBackStackEntry 객체를 생성하고, 이를 LocalViewModelStoreOwner 를 통해 제공하기 때문이다.
이 부분이 hiltViewModel 과 androidx-navigation 의 연관 지점이다.
Compose Navigation 이 LocalViewModelOwner 로 NavBackStackEntry 를 제공하고, hiltViewModel() 은 이를 ViewModelStoreOwner 타입으로 받아 사용한다.(느슨한 결합)
위 내용을 코드 레벨로 확인하려면 NavHost 의 내부 구현 코드를 보면 된다.
// NavHost.kt
@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier
) {
val lifecycleOwner = LocalLifecycleOwner.current
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
}
...
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner <-- 주석으로 설명되어있음
currentEntry?.LocalOwnersProvider(saveableStateHolder) {
(currentEntry.destination as ComposeNavigator.Destination).content(
this,
currentEntry
)
}
// NavBackStackEntryProvider
@Composable
public fun NavBackStackEntry.LocalOwnersProvider(
saveableStateHolder: SaveableStateHolder,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides this,
LocalLifecycleOwner provides this,
LocalSavedStateRegistryOwner provides this
) {
saveableStateHolder.SaveableStateProvider(content)
}
}
결론적으로, viewModelStoreOwner는 NavBackStackEntry 타입이므로 인자로 context 와 NavBackStackEntry 를 전달한다.
2. createHiltViewModelFactory 함수를 호출하여 HiltViewModelFactory 를 생성
NavBackStackEntry 클래스가 생성될때 viewModelStoreOwner 인터페이스를 구현하였다.
또한 NavBackStackEntry 는 HasDefaultViewModelProviderFactory 를 구현하여, savedState 를 지원하는 ViewModelFactory 를 제공하며, hiltViewModel() 은 이를 기반으로 HiltViewModelFactory 를 생성한다.
// hiltNavBackStackEntry.kt
@JvmName("create")
public fun HiltViewModelFactory(
context: Context,
delegateFactory: ViewModelProvider.Factory
): ViewModelProvider.Factory {
val activity = context.let {
var ctx = it
while (ctx is ContextWrapper) {
// Hilt can only be used with ComponentActivity
if (ctx is ComponentActivity) {
return@let ctx
}
ctx = ctx.baseContext
}
throw IllegalStateException(
"Expected an activity context for creating a HiltViewModelFactory " +
"but instead found: $ctx"
)
}
return HiltViewModelFactory.createInternal(
/* activity = */ activity,
/* delegateFactory = */ delegateFactory
)
}
전달 받은 context 와 NavBackStackEntry 는 HiltViewModelFactory 함수에서 사용하여 ViewModelProvider.Factory 를 생성하기 위해 사용된다.
정리하자면,
과정 3) 에선 이동하는 화면의 ViewModel이 생성되고, 그 과정에서, ViewModelStoreOwner 의 타입이 NavBackStackEntry 일 경우, HiltViewModelFactory() 함수를 호출하고 인자를 전달한다.
ViewModel 이 생성이 완료되고, 과정 2)에서 생성된 savedStateHandle 이 ViewModel 에 주입된다.
이로써 compose navigation 에서 argument 로 전달한 데이터를 이동하는 화면의 ViewModel 의 savedStateHandle 을 통해 바로 전달 받을 수 있는 이유를 알 수 있었다.
끝!
어떤 기술에 대한 딥다이브를 하는 글은 작성하기 너무 어렵고 힘들다는 것을 다시 한번 느꼈다.
진행 과정을 서술함에 있어, 모든 과정을 하나도 빠짐 없이, 코드와 함께 설명하게 될 경우, 글이 너무 길어지게 되고 글을 읽은 독자 입장에서도 읽다 지치게 된다.
하지만, 그와 반대로 진행 과정을 과도하게 추상화하고 스킵하게 될 경우, 논리 자체가 스킵될 수 있어, 글의 흐름이 자연스럽지 못하고, 글의 신뢰성 또한 떨어질 수 있다.
양 극단의 중간 지점을 맞추도록 노력해야겠다. 이는 딥다이브 글을 많이 써봐야 알 수 있을듯 하다.
https://developer.android.com/reference/androidx/navigation/NavBackStackEntry
https://everyday-develop-myself.tistory.com/m/3x65
https://pluu.github.io/blog/android/2020/03/15/savedstate-flow/
저도 궁금하던 부분이였는데.. 잘 읽었습니다.