[Jetpack Compose] Stable 된 Navigation3 사용해보기

오규성·2일 전
2

최근 Jetpack Navigation3 가 Stable 되어 정식 출시되었다.
기존의 불편했던 Navigation2 방식과 어떠한 차이가 있는지 우선 알아보고, 사용법을 알아보자.


1. 상태 기반 네비게이션 구조 및 백스택 관리 가능

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 로 구성된 NavBackStackkeyadd 해주고, 뒤로가기를 진행하기 위해서는 removeLast 를 진행해주면 된다.

NavDisplayrememberNavBackStack 으로 구현된 snapshotList 를 옵저빙하기에, backStack 에 key 가 추가되면 그에 맞게 UI 를 반영한다.

2. Route 지정 없음, Argument 전달 시의 간편함

이전 Navigation2 방식에서는 화면 이동을 위해서는 다음과 같은 방식을 따라야했다.

  1. NavHost 에서 composable 함수를 통해 화면 정의
  2. 각 화면에 맞는 route 정의
  3. 만약 화면에 Parameter 가 존재하면, NavArgument 를 추가하고 각 Argument Type 지정 및 Route 수정
  4. 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 에서 완벽히 해소되었다.

kotlinx.serialziation.Serializable 을 통한 파라미터 전달

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 의 프로퍼티를 그대로 가져다 쓰면 된다 !

3. BackPressed 이벤트 처리 가능

OnBack()

기존에는 뒤로가기 이벤트를 구현하려고 하면 화면에서 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()
        }
    },
    ...
}

4. 각각의 화면에 특별한 설정 가능 및 화면 배치 전략 가능.

Nav3 에서 화면 표시를 위해 필수적으로 사용하는 NavDisplay 에는 entryDecoratorssceneStrategy 라는 파라미터가 존재한다.

entryDecorators

entryDecorators 는 는 각 화면에 맞는 상태나 동작을 주입하는 도구이다.

기본적으로는 rememberSaveableStateHolderNavEntryDecorator() 라는 Composable 을 사용하는데, 이는 rememberSaevable 을 각 화면별로 저장하고 복원하는 기능을 의미한다.

sceneStrategy

sceneStrategy 는 화면 배치 전략이다.

기존 Navigation 을 사용할때는 Single pane Layout 만을 사용할 수 있었지만, 이제는 태블릿이나 폴더블 폰에서 왼쪽 및 오른쪽 화면을 각각 두는 등의 전략을 취할 수 있다.


지금까지 Navigation3 의 장점에 대해 알아보았으니, 이를 구현하고 테스트를 진행해보자.

1. 라이브러리 의존성 추가

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)

2. NavKey 구현 및 NavBackStack 구현

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)

3. EntryProvider 구현

EntryProviderNavDisplayNavKey<T> 를 읽을 수 있도록NavEntry<T> 로 반환해주는 작업을 진행한다.

이 EntryProvider 를 구현하는 방식은 다음과 같다.

1. 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) {}
            }
        }
    )
}

2. DSL 문법을 활용하여 구현

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 을 사용하는 경우, 추가해야하는 Decorator

HiltViewModel 을 사용하는 경우 각 Composable 마다 고유한 ViewModel 을 가질 수 있다.

이것은 Navigation2BackStackEntry 에서 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 } }
}

위 영상을 보면 뷰모델 데이터가 공유되는 문제를 확인할 수 있다.
이 문제를 해결하기 위해서는 entryDecoratorsrememberViewModelStoreNavEntryDecorator() 라는 함수를 추가해줘야 하는데, 이는 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 을 사용할 수 있게 된다.


커스텀 Decorator 구현

나는 과거 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}")
            }
        }
    }
)

잘 작동하는 것을 확인할 수 있다.


참고 자료

profile
안드로이드 개발자 Gyu 의 개발 블로그 !

2개의 댓글

comment-user-thumbnail
2일 전

좋은 글 감사합니다~

1개의 답글