대외활동/SOPT

SOPT 35th 안드로이드파트 미미나 - Navigation과 Stack 관리

chattymin 2024. 10. 12. 03:41
728x90

1. 왜 Navigation을 쓸까? with SAA

doorip 코드 일부 발췌

XML의 Activity기반 뷰에서는 위와 같이 Intent로 화면을 이동했어요.

 

 

하지만, 2018 Google I/O에서 Single Activity라는 개념을 언급했어요

Single activity: Why, when, and how (Android Dev Summit '18)

 

Single Activity라는 개념은 기존의 액티비티 기반의 화면 구성이 아닌, 소수의 액티비티와 다수의 프래그먼트를 이용해 화면을 구성하는 구조로 jetpack navigation과 함께 소개되었어요.

 

 

근데 지금까지 Activity와 Fragment 기반으로 잘 사용해왔는데 왜 굳이 바꾸는걸까요?

 

제일 큰 이유는 Activity보다 Fragment가 더 가벼워요.

 

공식문서에 나와있듯 Activity를 전환하는데 드는 리소스가 프래그먼트를 전환하는데 소모하는 리소스보다 훨씬 커요.

그래서 Single Activity Architecture(SAA)를 구현하기 위해 xml에서는 Activity에서 NavHostFragment를 만들어주고 navigation들을 연결시켜주는 작업을 통해 구현했었어요

과거 스터디 코드 일부 발췌

 

이러한 Jetpack Navigation을 이어받아 Compose에서도 Navigation을 사용해서 SAA구조로 화면 전환을 하고 있어요.

 

 

2. 어떻게 Navigation을 쓸까?

 

시작하기 전에!!! 용어를 짚어보고 갑시다.

Host

현재 탐색 대상이 포함된 UI 요소입니다. 즉, 사용자가 앱을 탐색할 때 앱은 기본적으로 탐색 호스트 안팎으로 대상을 전환합니다.

쉽게 말하면 모든 경로는 NavHost에 선언되어있어야 하고, 여기 내부에서만 탐색을 할거에요.

 

즉, 내가 이동할 수 있는 모든 경로의 명세서 라고 생각해도 돼요.

Compose에서는 NavHost로 사용해요

 

Graph

앱 내의 모든 탐색 대상과 연결 방법을 정의하는 데이터 구조입니다.

목적지를 배열의 형태로 관리해서 앱 내의 모든 경로가 연결되는 방식을 정의하는 데이터 구조에요.

 

쉽게 말하면 모든 경로가 연결되어있는 구조를 의미해요

Compose에서는 NavGraph로 사용해요

 

Controller

대상 간 탐색을 관리하는 중앙 코디네이터입니다. 컨트롤러는 대상 간 탐색, 딥 링크 처리, 백 스택 관리 등의 작업을 위한 메서드를 제공합니다.

 

쉽게 말하면 화면을 이동시켜주는 역할을 해요. 목적지들을 연결하고, 스택을 관리해줘요

Compose에서는 NavController로 사용해요

 

Destination

탐색 그래프의 노드입니다. 사용자가 이 노드로 이동하면 호스트가 콘텐츠를 표시합니다.

 

Graph의 노드들을 의미해요. 즉, 목적지들을 의미해요.

Compose에서는 NavDestination로 사용해요

 

Route

대상과 필요한 데이터를 고유하게 식별합니다.

경로를 사용하여 탐색할 수 있습니다. 경로를 통해 목적지로 이동할 수 있습니다.

직렬화가 가능한 모든 데이터면 가능해요

 

 

그래서 저것들 관계가 어떻게 되는데??

 

작은것부터 타고 올라가볼까요?

 

route : 장소를 식별하는 단위에요. 장소의 이름을 나타내요.

destination : 도착하고 싶은 장소에요

controller : 화면을 이동시켜주고, 데이터를 가져다주는 도구에요

graph : 장소들의 연결 방법을 정의하는 구조에요. 모든 경로를 설정해둬요

host : 제일 큰 구조로 그래프와 그 외적인 부분들을 가지고 있어요

 

여전히 무슨소린지 잘 이해가 안돼요. 그럼 조금 더 쉽게 가볼까요?

 

자 35기 안드 여러분들이 다같이 배낭여행을 간다고 생각해볼게요!

제가 살고있는 수원에 다같이 모여서 여행을 갈거에요.

수원에서 대전, 대구, 부산을 갔다가 서울에 올라와서 여행을 종료할거에요.

여행을 할때는 KTX를 타고 다닐거구요

우리가 여행갈 장소들을 하나의 메모장에 지도로 그려서 정리해뒀어요

 

어때요? 어색한것 없고 자연스럽죠?

 

그럼 이걸 다시 위의 용어로 바꿔볼까요?

저희가 여행할 수원, 대전, 대구, 부산, 서울은 여행 장소를 나타내는 Route에요

우리가 타고다닐 KTX, 즉 우리를 이동시켜줄 친구는 Controller에요

우리가 여행갈 장소를 적어둔 지도는 Graph에요

 

이걸 다시 앱으로 비유해볼까요?

여러분이 1주차 과제에서 이동했던 회원가입, 로그인, 마이페이지는 Route에요

여러분이 1주차 과제때 화면을 이동시켰던 Intent의 역할은 Controller가 이제부터 할거에요
AndroidManifest에 보시면 Activity들을 전부 적어두셨죠? 그게 Graph라고 생각하셔도 돼요.

 

 

 

어? 그럼 Route와 Destination은 뭐가 달라요?


우리가 여행갈때 수원여행가자! 라고 하면 모두가 알아듣죠?

즉, 우리가 여행가고싶은 장소를 나타낼때 위도경도로 나타내지 않고 해당 장소를 이름을 붙여서 나타내죠?

해당 장소에 이름을 붙여 고유하게 나타낸게 Route라고 생각하면 돼요.

그것처럼 해당 장소를 고유하게 나타내주는게 Route에요. 그 위도경도를 통해 나타낸 장소가 Destination!

 

우리나라에 수원이 2개 이상 있나요? 없죠!

고유해야 이동하는데 혼동이 생기지 않아요. 그렇기 때문에 고유하다고 강조하는거에요!

 

고유하지 않으면 어떻게 되나요?

뭐… 어떡하긴요.. 에러나는거죠…

 

 

이제 이론을 배웠으니 진짜 해볼까요?

https://github.com/chattymin/AND-SOPT-Navigation-Compose

미리 만들어온 간단한 화면이동 예시에요!

 

Dependency

// libs.version.toml
[versions]
kotlin = "2.0.0"
androidxComposeNavigation = "2.8.2"
kotlinxSerializationJson = "1.7.3" // kotlin 2.0 이상 기준

[libraries]
androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

 

NavController

val navController = rememberNavController()

우선은 이렇게 하나 선언하면 돼요. 자세한 부분은 아래에서 설명하도록 하겠습니다!

 

NavHost

@Composable
public fun NavHost(
    navController: NavHostController,
    startDestination: String,
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    route: String? = null,
    enterTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
        {
            fadeIn(animationSpec = tween(700))
        },
    exitTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
        {
            fadeOut(animationSpec = tween(700))
        },
    popEnterTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
        enterTransition,
    popExitTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
        exitTransition,
    sizeTransform:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> SizeTransform?)? =
        null,
    builder: NavGraphBuilder.() -> Unit
)

navController : 화면을 이동시키는 컨트롤러

startDestination : NavHost가 제일 처음 띄워줄 화면

builder : navigation으로 움직일 곳들(graph)들을 설정하는 곳. 앞서 말했든 NavGraphBuilder를 통해 NavGraph를 만들어주고 있어요!

 

그 외 속성들은 지금은 몰라도 돼요! 나중에 공부하시며 하나씩 알아가는 것도 재미있을거에요 :)

 

Destination은 어떻게 설정 해요?

@Serializable // 앞서 말했듯 직렬화를 해주고
data object A // 경로의 이름을 지어요

// A로 이동시 띄울 화면을 선언해요
@Composable
fun AScreen(
    paddingValues: PaddingValues,
    navigateToB: (name: String) -> Unit,
) {

}

@Serializable
data class B( // 파라미터가 필요하다면 이렇게 넣어줘요
    val name: String
)

// B로 이동시 띄울 화면을 선언해요
@Composable
fun BScreen(
    paddingValues: PaddingValues,
    name: String,
    navigateToC: (id: String, password: String) -> Unit,
) {

}

@Serializable
data class C(
    val id: String,
    val password: String
)

@Composable
fun CScreen(
    paddingValues: PaddingValues,
    c: C // 이렇게 묶어서도 파라미터로 넣어줄 수 있어요!
) {

}

@Serializable
data class D(
    val id: String,
    val password: String,
)
@Composable
fun DScreen(
    paddingValues: PaddingValues,
    viewModel: DViewModel = viewModel(), // 이번에는 뷰모델을 활용해봅시다!
) {

}

 

NavHost에 경로를 연결해봐요

NavHost(
    navController = navController,
    startDestination = A
) {
    composable<A> {
        AScreen(
            paddingValues = innerPadding,
            navigateToB = { name ->
                navController.navigate(B(name))
            }
        )
    }

    composable<B> { backStackEntry ->
        val item = backStackEntry.toRoute<B>()
        BScreen(
            paddingValues = innerPadding,
            name = item.name,
            navigateToC = { id, password ->
                navController.navigate(C(id, password))
            }
        )
    }

    composable<C> { backStackEntry ->
        val item = backStackEntry.toRoute<C>()
        CScreen(
            paddingValues = innerPadding,
            c = item
        )
    }

    composable<D> {
        DScreen(
            paddingValues = innerPadding
        )
    }
}

위에서 만들었던 navController를 연결해주고, startDestination을 연결해요.

앞서 만들었던 Destination중 하나로 연결하면 돼요.

 

composable 키워드를 통해 경로를 연결해요

<>안에 Destination을 넣어주면 돼요.

 

B의 경우 파라미터를 받아오고 있기 때문에 backStackEntry를 이용해서 사용해요.

value-parameter backStackEntry: NavBackStackEntry

 

backStackEntry에서 과거에는 .argument를 이용해서 값을 구했어요
val items = backStackEntry.arguments?.getString("items")

 

하지만, 이 방법의 경우 안전성이 떨어져요. items라는 string값이 safety하지 않기 때문에 불안정했어요.

이제는 그래서 toRoute를 이용해서 안전하게 값을 받아와요.

 

 

viewModel을 활용한 방법도 알아볼까요?

class DViewModel(
    savedStateHandle: SavedStateHandle, // 이 친구가 핵심이에요!
): ViewModel() {
    val profile = savedStateHandle.toRoute<D>()
}

viewModel에서 savedStateHandle을 이용한다면 바로 값에 접근 할 수 있어요 :)

쉽게 말하면 화면에 전달된 데이터를 가져올 수 있어요

 

그래서 바로 profile에 저장하고, screen에서 viewModel을 선언해서 가져다 쓸 수 있어요!

그 후 화면에서 viewModel에 접근하여 값을 사용하면 됩니다

@Composable
fun DScreen(
    paddingValues: PaddingValues,
    viewModel: DViewModel = viewModel(),
) {
    val profile = remember { viewModel.profile }
    Column(
        modifier = Modifier
            .padding(paddingValues)
            .fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Spacer(modifier = Modifier.weight(1f))
        Text(text = "Hello, my ID is ${profile.id}!\nmy password is ${profile.password}")
        Spacer(modifier = Modifier.weight(1f))
    }
}

 

 

NavController로 화면을 이동해봅시다!

사실 위에 적혀있어요

val navController = rememberNavController()

이렇게 선언해둔 navController에 .을 찍어보시면 navigate라는게 있을거에요

navigator.navigate(경로) 이렇게 하면 해당 경로로 이동이 가능합니다 :)

 

참 쉽죠?

 

3. 스택이 뭐고 어떻게 관리할까요?

스택이 뭔가요??

 

여러분들이 자료구조를 공부하셨다면 들어보셨을 그 Stack 맞아요!

 

처음 보셨다고 하셔도 정말 간단하니 문제없어요 :)

 

Stack은 후입선출의 특징을 가진 자료구조에요

 

늦게 들어온게 먼저 나가는 특징을 가졌어요. 먼저들어온 것을 먼저 내보내는 것은 불가능해요!

 

위 그림처럼 1번이 2번보다 먼저 들어왔기 때문에 2보다 1이 먼저 나가는건 불가능해요.

프링글스라고 생각하면 됩니다! 위에있는 감자칩을 먼저 먹어야 밑에 있는걸 먹을 수 있으니까요 :)

 

 

근데 이게 갑자기 네비게이션 얘기하다가 왜 나오냐구요?

네비게이션에서 이동한 모든 경로를 Stack 을 통해서 관리합니다!

 

제일 쉬운 예시는 뒤로가기에요 :)

뒤로가기를 누르면 바로 이전 페이지가 나온다는건 가장 최신에 들어간게 먼저 나오는거죠

 

 

그럼 스택이 왜 중요해요?

일반적인 상황에서 뒤로가기를 한다면, 뒤로 이동되는게 정말 좋은 상태에요!

그런데 이런 상황은 어떨까요??

 

 

당근의 예시에요.

 

바텀네비를 이용해서 왔다갔다 많이 하는데 이때 뒤로가기를 계속 눌러야만 앱을 종료할 수 있다면?

뒤로가기를 계속 눌러서 바텀네비에 있는 뷰들이 이동된다면??

 

상당히 불편하겠죠.

그래서 스택을 쌓기만 하는게 아니라, 관리도 해줘야 합니다.

 

그리고 또 다른 예시로 여러분이 로그인을 했어요.

그래서 메인 뷰로 갔는데 메인뷰에서 뒤로가기를 눌렀더니 로그인뷰로 다시 이동이 되는거에요.

 

그러면 상당히 곤란하겠죠?

 

로그인이야 다시 하면 된다고 생각하실지 몰라도 만약 결제뷰라면?

 

이런 상황을 막기 위해서 Stack을 관리해야 합니다.

 

 

어떻게 스택을 관리할 수 있을까요??

우리가 화면을 이동할 때 사용했던 navigator를 이용해서 관리할 수 있어요!

navigate 함수 내부는 이렇게 생겼습니다.

@MainThread
@JvmOverloads
public fun <T : Any> navigate(
    route: T,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null
) {
    navigate(generateRouteFilled(route), navOptions, navigatorExtras)
}

여기서 navOption을 이용해서 스택을 관리할 수 있어요

navOption 내부를 전부다 까보기엔 너무 많아서 주로 사용하는 것만 이야기해볼게요!

 

 

popUpTo

navigateToB = { name ->
    val navOptions = navOptions {
        popUpTo(A)
    }

    navController.navigate(C(id, pw), navOptions)
}

C로 이동하기 전, 백스텍에서 A까지의 스택을 꺼냅니다.

 

A → B → C로 이동했다고 가정해볼게요!

백스텍에서 A까지의 스택을 꺼냈다면 A 와 C사이에 있는 B가 사라지게 됩니다!

 

그래서 C에 도착했을 때의 스택은 A → C가 되는거죠

여기서 popUpTo에 설정을 추가할 수 있습니다

 

 

inclusive

navigateToB = { name ->
    val navOptions = navOptions {
        popUpTo<B> {
            inclusive = true
        }
    }

    navController.navigate(D(id, pw), navOptions)
}

D로 이동하기전, 백스텍에서 B까지의 스택을 꺼냅니다. 이번에는 B도 포함해서요!

inclusive가 true라면 본인도 포함해서 스택에서 꺼내요.

즉, A→B→C→D였다면 popUpTo이후에는 A → D가 되는거에요 :)

 

 

saveState & restoreState

navigateToB = { name ->
    val navOptions = navOptions {
        popUpTo<B> {
            saveState = true
        }

        restoreState = true
    }

    navController.navigate(D(id, pw), navOptions)
}

saveState를 사용한다면 현재 페이지의 상태를 저장할 수 있어요

restoreState를 사용한다면 저장한 페이지의 정보를 불러올 수 있구요!

 

 

이렇게 스크롤하고 다른 페이지로 이동했다 돌아와도 해당 상태가 남아있게 됩니다 :)

스크롤 외에도 다른 상태들도 유지되구요!

 

 

launchSingleTop

navigateToB = { name ->
    val navOptions = navOptions {
        launchSingleTop = true
    }

    navController.navigate(C(id, pw), navOptions)
}

이 기능은 단순합니다!

현재 C 위치에 존재한다면, C로 이동하지 않아요.

이게 무슨 소리인가 싶다면, 바텀시트를 여러번 눌러봅시다.

home에서 home으로 이동하기 버튼을 눌렀다면 이론상 stack에 home → home으로 저장이 되어야 합니다.

하지만 이 기능을 사용한다면 현재 home이고, home으로 이동하려 하기 때문에 stack에 쌓지 않고 home 하나만 존재하게 됩니다.

728x90