Compose Navigation 살펴보기

hongbeom
hongbeomi dev
Published in
15 min readAug 2, 2024

Compose Navigation의 동작 원리

Photo by Alexander Nrjwolf on Unsplash

Navigation

Google이 제공하는 jetpack Navigation 라이브러리는 유저가 앱 내에서 탐색하거나 탐색 후 다시 되돌아오는 상호작용을 구현할 수 있도록 도와줍니다. 간단한 버튼 클릭 부터 특정 데이터 전달 같은 사용 사례들을 처리할 수 있다고 소개하고 있는데 Navigation 라이브러리가 Compose 환경에서 어떻게 동작하고 어떤 컴포넌트들을 통해 이 작업이 동작되는지 살펴보겠습니다.

컴포넌트

Navigation 라이브러리에서 기본적으로 소개하는 컴포넌트는 아래와 같습니다.

  • Host : 목적지를 포함하고 있는 UI 요소입니다. Compose에서는 NavHost 클래스가 이를 관장하고 있습니다.
  • Graph : 목적지(destination)를 배열(SparseArrayCompat) 형태로 관리하며 앱 내의 모든 탐색 목적지와 목적지가 서로 연결되는 방식을 정의하는 데이터 구조입니다. NavGraph 클래스로 구현되어 있습니다.
  • Controller : NavHost 내에서 앱목적지 간의 탐색, 백스택, 딥링크 처리를 관리합니다. Compose에선 NavController 클래스를 상속받는 NavHostController로 구현되어 있습니다.
  • Destination : nav graph의 노드입니다. host가 해당 노드의 콘텐츠를 표시합니다. NavDestination 클래스로 구현되어 있습니다.
  • Route : 목적지와 목적지에 필요한 데이터를 고유하게 식별하며 Route를 통해 탐색이 가능합니다.

해당 컴포넌트들은 Navigator, NavigationProvider, NavXXXBuilder 클래스들과 상호작용하여 탐색을 지원하는데 이 클래스들은 탐색 흐름을 보며 함께 살펴보겠습니다.

동작 원리

우리가 Compose Navigation을 일반적으로 사용하게 된다면 아래처럼rememberNavController() 함수를 사용하여 NavController를 생성한 후, NavHost 컴포저블의 파라미터로 넣어줄 것 같습니다.

// 앱의 시작 스크린
@Composable
fun XXXScreen(
val controller = rememberNavController()
) {
...
NavHost(navController = controller, startDestination = XXX) {
composable<XXXRoute.XXX> {
...
}
composable<XXXRoute.XXX> {
...
}
}
}

혹은 커스텀한 Navigator 클래스를 만들어서 Controller를 소유하도록 사용할 수도 있을 것 같은데요. 우선 rememberNavController() 함수 내부적으로 어떤 일이 발생하는지 살펴보겠습니다.

우선 우리가 사용한 rememberNavController 함수 안에서 createNavController(context)를 통해 NavHostController가 생성되는 것을 확인할 수 있습니다. 그리고 파라미터를 통해 우리가 추가로 Custom한 Navigator를 구현하여 추가할 수도 있다는 것을 알 수 있네요.

createNavController()함수를 따라가 보면, 생성된 NavHostController클래스가 자체적으로 보유하고 있는 navigatorProvider 프로퍼티를 통해 navigator를 추가하고 있는 것을 볼 수 있습니다. 이 부분은 추후 자주 등장하니, 우선 이렇게 여러가지 Navigator들이 생성되고 추가된다 정도로 확인하고 넘어가겠습니다.

다음으로 생성된 navControllerNavhost의 파라미터로 넘어가는 부분인데요, NavHost 컴포저블이 생성되면서 graph가 controller의 createGraph 함수에 의해 만들어지고 있습니다. 이때 화면이 전환될 때 사용되는 transition들도 지정되고 있네요.

NavController.kt
NavGraphBuilder.kt

createGraph 함수는 navigatorProvidernavigation 함수를 호출하는데, 이 때 NavGraphBuilder 클래스를 통해 초기 NavGraph가 생성되는 것을 확인할 수 있습니다.

// graph를 파라미터로 받는 NavHost 컴포저블 함수
@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
...
) {
val lifecycleOwner = LocalLifecycleOwner.current
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
}
navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
...
DisposableEffect(lifecycleOwner) {
// Setup the navController with proper owners
navController.setLifecycleOwner(lifecycleOwner)
onDispose {}
}
...
}

다시 돌아가서, NavHost 컴포저블 함수를 한 번 더 진입해보면 graph 뿐 아니라 lifecycleOwnerviewModelStore를 controller에 주입해주는 작업도 함께 진행되고 있는데 viewModelStore를 주입해줌으로써 화면 간 ViewModel 공유를 가능하게 하며, lifecycleOwner도 지정되어 lifecycle 이벤트에 대한 대응이 가능해진다는 것을 알 수 있습니다.

@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
...
) {
...
val composeNavigator = navController
.navigatorProvider
.get<Navigator<out NavDestination>>(
ComposeNavigator.NAME
) as? ComposeNavigator ?: return
...

val dialogNavigator = navController
.navigatorProvider
.get<Navigator<out NavDestination>>(
DialogNavigator.NAME
) as? DialogNavigator ?: return

DialogHost(dialogNavigator)
...
}

그리고 이전에createNavController함수에서 navigatorProvider에 추가됐었던 ComposeNavigator를 검색하여 현재 표시되어야 할 화면을 표시하고, 올바른 순서로 겹쳐지도록 하는 등의 작업을 수행합니다. 또한 DialogNavigator도 검색하여 다이얼로그 탐색에 필요한 작업을 수행하는 DialogHost를 구성합니다.

그런데 이 Navigator들은 무엇이고 어떤 역할을 담당하고 있을까요?

모든 XXXNavigator클래스의 상위 클래스인 Navigator는 추상클래스로 이루어져 있으며 각각 구현체들이 이를 구현하고 있습니다. Navigator는 NavigatorState 타입의 state 객체를 보유하고 있으며 이 state는 backStack 리스트를 가지고 있는 상태를 나타냅니다. 이 상태를 통해 적절하게 화면을 백스택에 push하거나, pop하는 등의 작업을 수행할 수 있게 되는 것입니다.

모든 하위 클래스들은 createDestination 함수를 override 해야 하며, 각자 자신만의 Destination 클래스를 내부적으로 보유하고 있습니다.

  • ActivityNavigator 클래스는 context를 보유하고 있으며 Activity 타입의 NavDestination을 상속받는 Destination 클래스를 내부적으로 구현하고 있습니다.
  • ComposeNavigator, DialogNavigator는 동일하게 Composable 타입의 NavDestination을 상속받는 Destination 클래스를 내부적으로 구현하고 있습니다. 허나 ComposeNavigator는 커스텀한 transition 동작을 가질 수 있도록 설계되어 있으며 popBackStack 동작에서 서로 약간의 차이가 존재합니다.

그런데 이중 NavGraphNavigator 클래스만 open으로 구현되어 있는데요, 해당 클래스는 NavGraph 요소를 위해 특별히 제작된 Navigator입니다.

해당 클래스는 다른 Navigator와 다르게 createDestination 함수에서 Destination 클래스를 생성하지 않고 NavGraph 클래스를 생성합니다.

이전에 위에서 살펴본 createNavContoller() 함수의 구현을 다시 한 번 살펴보면, NavGraphNavigator 클래스를 상속받는 ComposeNavGraphNavigator 클래스가 생성될 땐 다른 Navigator 클래스와 다르게 navigatorProvider를 주입받는 것을 확인할 수 있는데요, 이 때 주입받은 provider를 통해 타 Navigator를 검색하여 내부적으로 navigation 동작을 수행할 수 있습니다.

// NavGraphNavigator.kt

...
override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?
) {
for (entry in entries) {
navigate(entry, navOptions, navigatorExtras)
}
}

private fun navigate(
entry: NavBackStackEntry,
navOptions: NavOptions?,
navigatorExtras: Extras?
) {
...
val navigator =
navigatorProvider.getNavigator<Navigator<NavDestination>>(
startDestination.navigatorName
)
...
navigator.navigate(
listOf(startDestinationEntry),
navOptions,
navigatorExtras
)
}

XXXGraphNavigator 클래스는 위 구현처럼 navigate 함수가 호출되면 입력받은 entry 정보를 토대로 navigator를 검색하고 navigator의 navigate 함수를 트리거 합니다.

이제 우리가 composable로 추가한 목적지를 어떻게 인지하여 탐색할 수 있는지 살펴보겠습니다.

// 앱의 시작 스크린
@Composable
fun XXXScreen(
val controller = rememberNavController()
) {
...
NavHost(navController = controller, startDestination = XXX) {
composable<XXXRoute.XXX> {
...
}
composable<XXXRoute.XXX> {
...
}
}
}

우리는 처음에 NavGraphBuilder.composable 함수를 이용하여 목적지를 추가할 수 있었습니다.

NavGraphBuilder.kt

우리가 composable 함수를 작성한 부분은 builder 파라미터로 이전에 보았던 NavGraphBuilder가 생성될 때 이 부분이 실행되게 됩니다.

public inline fun <reified T : Any> NavGraphBuilder.composable(
...
) {
destination(
ComposeNavigatorDestinationBuilder(
provider[ComposeNavigator::class],
...
content,
)
...
}

어떤 파라미터를 통해 호출하는가에 따라 약간의 차이가 있긴하지만 composable 함수의 공통적인 구현부를 보면 ComposeNavigatorDestinationBuilder를 통해 Destination이 생성되고, 이 때 provider(NavigationProvider)가 ComposeNavigator를 제공하는 것을 볼 수 있습니다.

ComposeNavigatorDestinationBuilderComposeNavigator.Destination을 생성하는 동시에 제공받은 navigator와 우리가 composable의 파라미터로 넣어주는 content 컴포저블 파라미터를 Destination에 주입합니다.

ComposeNavigatorDestinationBuilder.kt
ComposeNavigatorDestinationBuilder.kt의 상위 클래스인 NavDestinationBuilder.kt

이 때 argument, deeplink, action과 같은 값들도 생성된 Destination에 입력해주고 있습니다.

이렇게 생성된 destination은 NavGraphBuilderbuild될 때 내부적으로 보유하고 있는 destination 리스트에 추가되는데, NavGraph도 이 리스트를 참조하고 있기 때문에 우리의 목적지 정보가 노드 데이터 구조 형태로 추가되게 됩니다.

@MainThread
public open fun navigate(
request: NavDeepLinkRequest,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
) {
...
val deepLinkMatch = _graph!!.matchDeepLink(request)
...
val node = deepLinkMatch.destination // NavDestination Type
navigate(node, args, navOptions, navigatorExtras)
...
}

이후 우리가 NavController.navigate 함수를 호출하여 목적지로 탐색하려고 하면 @idRes 값을 이용하는 경우를 빼고 모두 navigate 함수의 파라미터로 입력한 값을 NavDeepLinkRequest 형태(uri 값 변환)로 변환하고 graph에서 매치되는 노드를 찾은 후 옵션에 따라 백스택에서 탐색 동작을 실행합니다.

navigate 함수의 @IdRes 값을 사용하는 경우는 변환 없이 graph에서 일치하는 id를 검색합니다.

Compose Navigation의 동작 원리를 가볍게 살펴보았습니다. Navigation 사용 시 예상치 못한 동작이 발생하거나 문제가 발생하는 경우 내부 구현 부분을 따라가보면 해결에 도움이 될 것 같습니다.

--

--