최근 Jetpack Navigation3 가 Stable 되어 정식 출시되었다.
기존의 불편했던 Navigation2 방식과 어떠한 차이가 있는지 우선 알아보고, 사용법을 알아보자.
Nav2 의 구조는 명령 기반 구조이다.
@Composable
fun Main(){
NavHost(
navController = navHostController,
startDestination = Screen.Splash.route,
enterTransition = { fadeIn(animationSpec = tween(450)) },
exitTransition = { fadeOut(animationSpec = tween(450)) },
) {
composable(Screen.Splash.route){ SplashScreen() }
composable(Screen.Login.route){ LoginScreen() }
composable(Screen.Detail.route){ DetailScreen() }
}
navHostController.navigate(Screen.Detail.route)
...
navHostController.popBackStack()
}

이 방식은 NavController 에 숨겨진 BackStack 을 조작한다.
즉, 라이브러리가 BackStack 을 소유하고 제어하는, 캡슐화가 적용되어 있기 때문에 개발자의 접근이 어려워 관리가 어렵다.
Nav3 의 경우 이와 다르게 상태 기반 구조이다.
@Composable
fun Main(){
val backstack = rememberNavBackStack(Screen.MainScreen)
NavDisplay(
backStack = backstack,
entryProvider = { key ->
NavEntry(key){ MainScreen{} }
}
)
}
새로운 화면으로 이동하기위해서는 개발자가 SnapshotStateList 로 구성된 NavBackStack 에 key 를 add 해주고, 뒤로가기를 진행하기 위해서는 removeLast 를 진행해주면 된다.

NavDisplay 는 rememberNavBackStack 으로 구현된 snapshotList 를 옵저빙하기에, backStack 에 key 가 추가되면 그에 맞게 UI 를 반영한다.
이전 Navigation2 방식에서는 화면 이동을 위해서는 다음과 같은 방식을 따라야했다.
- NavHost 에서 composable 함수를 통해 화면 정의
- 각 화면에 맞는
route정의- 만약 화면에 Parameter 가 존재하면,
NavArgument를 추가하고 각Argument Type지정 및Route수정navController.navigate(route)를 통해 화면 이동
코드로 보면 다음과 같았다.
composable(
route = "nextScreen/{Name}/{Count}",
arguments = listOf(
navArgument("Name") {
type = NavType.StringType
},
navArgument("Count") {
type = NavType.IntType
}
)
)
확장 함수를 사용하지 않는 이상 휴먼에러가 발생하기 쉽고, 화면에 새 Parameter 가 추가, 수정될 때마다 Argument, Argument Type, Route 를 수정해줘야했다.
이러한 불편함은 Navigation3 에서 완벽히 해소되었다.
Navigation3 에서는 kotlinx.serialziation.Serializable 을 클래스에 붙여주기만 하면 이를 자동적으로 직렬화해주어 간편하게 인자를 전달할 수 있다.
sealed interface Screen : NavKey {
@Serializable data object MainScreen: Screen
@Serializable data class SubScreen(val id: Int, val user: TestUser): Screen
}
@Serializable
data class TestUser(
val name: String,
val age: Int
)
class Activity: ComponentActivity() {
override fun onCreate(savedInstance: Bundle?){
setContent {
val backstack = rememberNavBackStack(Screen.MainScreen)
NavDisplay(
backStack = backstack,
entryProvider = { key ->
when (key) {
is Screen.MainScreen -> NavEntry(key) {
MainScreen(navigateSubScreen = { id, testUser ->
backstack.add(Screen.SubScreen(id, testUser))
})
}
is Screen.SubScreen -> NavEntry(key){
SubScreen(count = key.id, key.user)}
else -> NavEntry(key){}
}
}
)
}
}
}
@Composable
fun MainScreen(navigateSubScreen: (Int, TestUser) -> Unit){
var count by rememberSaveable { mutableStateOf(0) }
Column {
Button(onClick = { navigateSubScreen(count, TestUser("14", 1))}) {
Text(text = "다음 화면 이동 ")
}
}
}
@Composable
fun SubScreen(
count: Int,
testUser: TestUser
){
Column {
Text(text = "카운트 : ${count}")
Text(text = "유저 : ${testUser}")
}
}
이 코드를 살펴보자.
MainScreen 에서 버튼을 클릭하여 SubScreen 으로 이동할 때, count 와 TestUser 를 전달하도록 구현하였다.
기존 Navigation2 였다면 이 과정에서 NavType 을 지정해주고, 다시 entry 에서 꺼내어 매개변수로 사용하고 등등 복잡한 과정이 있었겠지만 이제는 그러한 과정없이 data class 의 프로퍼티를 그대로 가져다 쓰면 된다 !

기존에는 뒤로가기 이벤트를 구현하려고 하면 화면에서 LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher 를 통해 구현해야했기에, 관심사에 따라 분리된 화면에 맞춰 이벤트를 처리하기가 복잡했고 추후 유지보수도 어려웠다.
현재 Navigation3 의 경우 공식적으로 뒤로가기 이벤트 처리를 지원해주기에 특정 backStack 일때 뒤로가기 처리를 커스텀 구현하기 쉬워져 이러한 불편함이 사라졌다.
@Composable
public fun <T : Any> NavDisplay(
backStack: List<T>,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
onBack: () -> Unit = {
if (backStack is MutableList<T>) {
backStack.removeLastOrNull()
}
},
...
}
Nav3 에서 화면 표시를 위해 필수적으로 사용하는 NavDisplay 에는 entryDecorators 와 sceneStrategy 라는 파라미터가 존재한다.
entryDecorators 는 는 각 화면에 맞는 상태나 동작을 주입하는 도구이다.
기본적으로는 rememberSaveableStateHolderNavEntryDecorator() 라는 Composable 을 사용하는데, 이는 rememberSaevable 을 각 화면별로 저장하고 복원하는 기능을 의미한다.
sceneStrategy 는 화면 배치 전략이다.

기존 Navigation 을 사용할때는 Single pane Layout 만을 사용할 수 있었지만, 이제는 태블릿이나 폴더블 폰에서 왼쪽 및 오른쪽 화면을 각각 두는 등의 전략을 취할 수 있다.
지금까지 Navigation3 의 장점에 대해 알아보았으니, 이를 구현하고 테스트를 진행해보자.
navigation3 = "1.0.0"
lifecycleViewmodelNav3 = "2.10.0"
kotlin = "2.2.21"
[library]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
# 각 화면별 독립적으로 ViewModel 사용이 필요한경우 or hiltViewModel 사용시
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
[plugin]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
// 필요한 경우
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
Navigation3에서는Key라는 참조만 저장한다. 그렇기에 key 는 직렬화 가능한data class로 구성된다.
sealed interface Screen : NavKey {
@Serializable data object MainScreen: Screen
@Serializable data class SubScreen(val id: Int, val user: TestUser): Screen
}
@Serializable
data class TestUser(
val name: String,
val age: Int
)
val backstack = rememberNavBackStack(Screen.MainScreen)
EntryProvider 는 NavDisplay 가 NavKey<T> 를 읽을 수 있도록NavEntry<T> 로 반환해주는 작업을 진행한다.
이 EntryProvider 를 구현하는 방식은 다음과 같다.
각 키의 분기가 있는 when 문을 활용하여 구현한다.
@Composable
fun Nav() {
val backstack = rememberNavBackStack(Screen.MainScreen)
NavDisplay(
backStack = backstack,
entryProvider = { key ->
when (key) {
is Screen.MainScreen -> NavEntry(key) {
MainScreen(navigateSubScreen = { id, testUser ->
backstack.add(Screen.SubScreen(id, testUser))
})
}
is Screen.SubScreen -> NavEntry(key) {
SubScreen(count = key.id, key.user)
}
else -> NavEntry(key) {}
}
}
)
}
DSL 문법을 활용하여 구현도 가능하다.
@Composable
fun Nav() {
val entryProvider = entryProvider {
entry<Screen.MainScreen> { Text("Main Screen") }
entry<Screen.SubScreen>(
metadata = mapOf("extraDataKey" to "extraDataValue")
) { key -> Text("SubScreen ${key.id} ") }
}
}

종합적으로 살펴보면 Navigation3 는 위와 같은 흐름을 가진다.
HiltViewModel 을 사용하는 경우 각 Composable 마다 고유한 ViewModel 을 가질 수 있다.
이것은 Navigation2 의 BackStackEntry 에서 ViewModelStoreOwner 를 제공해주기 때문인데, Navigation3 부터는 이를 기본 제공하지 않는다.
그래서 여러 화면에서 같은 뷰모델을 사용하는 경우 다음과 같은 상황이 벌어질 수 있다.
@Composable
fun MainScreen(navigateSubScreen: () -> Unit){
val viewModel = hiltViewModel<MainViewModel>()
val count by viewModel.count.collectAsStateWithLifecycle()
Column {
Button(onClick = { navigateSubScreen()}) {
Text(text = "다음 화면 이동 ")
}
Button(onClick = { viewModel.increase() }) {
Text(text = "뷰모델 카운트 증가, 현재 카운트 - $count")
}
}
}
@Composable
fun SubScreen(){
val viewModel = hiltViewModel<MainViewModel>()
val count by viewModel.count.collectAsStateWithLifecycle()
Column {
Text(text = "뷰모델 현재 카운트: $count")
}
}
@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel() {
private val _count = MutableStateFlow(0)
val count = _count.asStateFlow()
fun increase(){ _count.getAndUpdate { it + 1 } }
}

위 영상을 보면 뷰모델 데이터가 공유되는 문제를 확인할 수 있다.
이 문제를 해결하기 위해서는 entryDecorators 에 rememberViewModelStoreNavEntryDecorator() 라는 함수를 추가해줘야 하는데, 이는 viewmodel.navigation3 라이브러리로 제공해준다.
lifecycleViewmodelNav3 = "2.10.0"
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
@Composable
fun Nav(){
NavDisplay(
backStack = backstack,
entryProvider = { key ->
when (key) {
is Screen.MainScreen -> NavEntry(key) {
MainScreen(navigateSubScreen = { id, testUser ->
backstack.add(Screen.SubScreen(id, testUser))
})
}
is Screen.SubScreen -> NavEntry(key){
SubScreen(count = key.id, key.user)}
else -> NavEntry(key){}
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
// 뷰모델을 Composable 마다 독립적으로 사용하려는 경우
rememberViewModelStoreNavEntryDecorator()
)
)
}
이를 활용하여 각 Composable 화면마다 독립적인 ViewModel 을 사용할 수 있게 된다.

나는 과거 Navigation2 에서 화면 이동시 정확히 이동했는지 로그를 확인하였었다. (이유는 기억이 안남 ...)
그래서 이번에도 비슷한 기능을 구현해보려고 한다.
화면 이동시마다 체크를 하기 위해 NavEntryDecorator<T> 를 구현하여 onPop, decorate 를 커스텀해보자 !
// 이후 NavDisplay 의 decorator List 에 추가 !
class LoggingDecorator<T : Any>: NavEntryDecorator<T>(
onPop = { Log.d("화면 뒤로가기", "BackStack Remove Screen - ${it}") },
decorate = { entry ->
entry.Content()
DisposableEffect(Unit) {
Log.d("화면 이동","화면 진입 - ${entry.contentKey} : ${entry.metadata}")
onDispose {
Log.d("화면 이동","화면 이탈 - ${entry.contentKey} : ${entry.metadata}")
}
}
}
)

잘 작동하는 것을 확인할 수 있다.
좋은 글 감사합니다~