Android Compsoe Jetpack Navigation Nested Graph와 Shared ViewModel

곽의진·2024년 5월 9일
0

Android

목록 보기
15/15
post-thumbnail

Jetpack Navigation 기초 부분을 공부하고 싶으시다면 해당 링크를 참고해주세요!

Jetpack Navigation이란?

Nested Graph

Nested Graph란?

Nested Graph은 한마디로 중첩된 그래프 구조를 가진 형태를 뜻합니다. 지난번 포스팅인 Jetpack Navigation 소개 포스팅 처럼 GraphBuilder.composable() 함수를 활용하면 Navigation 내의 destination(목적지)를 추가하여 Graph를 구성할 수 있게 됩니다.

중첩 그래프는 이러한 destination(목적지)의 집합체를 한번 더 Graph로 구성하여 추상화(공통적인 특성을 가진 요소를 상위 Type으로 wrapping했다) 시켰다 라고 이해해주시면 쉬우실겁니다.

간단한 예제를 보시죠

기존 Graph 예제

아래 사진과 같이 기존 그래프의 경우에는 각각의 destination 별로 Graph를 구성했습니다. 즉 모두 같은 계층의 Graph에 존재하며 이동하는데에도 큰 제약은 없죠.

그렇기에 간단한 화면 구조에서는 오히려 효과적으로 화면 이동의 흐름을 파악할 수 있습니다.

다만 복잡한 화면 구조에서는 stack을 관리하거나 화면별로 공통적인 data를 관리하는 구조가 더욱 복잡해집니다. 또한 반복되는 화면의 Flow를 재사용할 수 없는 구조라는 점이 가장 큰 단점이 되는 구조입니다.

Nested Graph 예제

아래 사진이 중첩 그래프를 적용했을 때의 화면 흐름입니다.

유저 인증과 관련된 화면은 Login, 회원가입, 비밀번호 변경 화면으로 Auth Graph내부에 중첩되어 구현되어 있습니다.
Main 기능 관련된 화면은 홈, Playground, My page로 화면은 Main Graph내에 중첩되있는 구조입니다.

Nested Graph(중첩 그래프)의 장점

1. 모듈화 및 재사용성

  • 플로우의 모듈화: 각 기능 플로우를 별도의 그래프로 나누어 관리함으로써 논리적 단위로 모듈화가 가능합니다. (예: Auth 플로우, Main 플로우 등)
  • 재사용 가능: 중첩된 그래프는 다른 곳에서도 동일한 로직으로 재사용할 수 있습니다. (예: Auth 플로우를 앱의 여러 지점에서 재사용)

2. 캡슐화

  • 내부 흐름의 숨김: 중첩 그래프의 내부 목적지는 외부에서 직접 접근할 수 없고, 중첩 그래프 자체를 통해서만 접근 가능합니다.
  • 이를 통해 내부 흐름을 감추고 외부에서 접근 가능한 진입점만 노출할 수 있습니다.
  • 내부 흐름은 변경하더라도 외부에 영향을 주지 않기 때문에 유지보수성이 향상됩니다.

3. 흐름 관리 간소화

  • 독립적인 스타트 목적지: 각 중첩 그래프는 자신의 startDestination을 가질 수 있어 독립적인 흐름을 관리할 수 있습니다. (예: Auth 플로우에서는 첫 번째 로그인 화면이, Main 플로우에서는 Homt 화면이 startDestination으로 설정 가능)
  • 흐름 추적 용이성: 중첩 그래프를 통해 플로우를 그룹화하면 전체 네비게이션 구조가 더 간결해져 흐름을 파악하기 쉬워집니다.

4. 코드 분리 및 가독성

  • 코드 분리: 각 중첩 그래프를 별도의 확장 함수나 NavGraphBuilder로 분리하면 네비게이션 그래프의 코드가 훨씬 깔끔해집니다.
  • 이를 통해 각 그래프에 특화된 확장 함수를 만들어 사용 가능하며, 네비게이션 그래프의 코드 복잡도를 줄일 수 있습니다.

5. 복잡한 플로우 처리에 유용

  • 조건부 플로우 처리: 예를 들어 첫 로그인 시 온보딩 플로우를 실행하는 경우나, 게임이 끝났을 때 매치로 이동할지 등록 플로우로 이동할지 등 복잡한 조건을 쉽게 관리할 수 있습니다.

Nested Graph 구현

Nested Graph를 Compose로 구현해보자

일반적인 Navigation Graph를 compose에서 구현하기 위해서는 NavGraphBuilder.composable() 함수를 썼습니다.

하지만 Nested Graph를 구현하려면 조금 다른 방법을 사용해야하는데요 !!
바로 NavGraphBuilder.navigation() 함수 내부에 NavGraphBuilder.composalbe()을 구현하면 됩니다.

Nested Graph 전체 코드 예제

@Composable
fun Navigation(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = AuthScreen.Auth.route
    ) {
        navigation(
            startDestination = AuthScreen.Login.route,
            route = AuthScreen.Auth.route
        ) {
            composable(AuthScreen.Login.route) {
                LoginScreen(
                    loginAction = {
                        navController.navigate(MainScreen.Home.route) {
                            popUpTo(AuthScreen.Auth.route) {
                                inclusive = true
                            }
                        }
                    }
                )
            }
            composable(AuthScreen.SignUp.route) {
                SignUpScreen()
            }
        }
        navigation(
            startDestination = MainScreen.Home.route,
            route = MainScreen.Main.route
        ) {
            composable(MainScreen.Home.route) {
                HomeScreen(
                    navigateToDetail = { navController.navigate(DetailScreen.Detail.route) },
                    navigateToPlayGround = { navController.navigate(MainScreen.PlayGround.route) }
                )
            }
            composable(MainScreen.MyPage.route) {
                DetailScreen(
                    navigateToHome = { navController.navigate(MainScreen.Home.route) },
                    navigateToPlayGround = { navController.navigate(MainScreen.PlayGround.route) }
                )
            }
            composable(MainScreen.PlayGround.route) {
                PlayGroundScreen(
                    navigateToHome = {
                        navController.navigate(MainScreen.Home.route) {
                            popUpTo(MainScreen.Home.route) {
                                inclusive = true
                            }
                        }
                    },
                    navigateToDetail = { navController.navigate(DetailScreen.Detail.route) }
                )
            }
        }

        composable(DetailScreen.Detail.route) {
            DetailScreen()
        }
    }
}

Nested Graph에서 화면 이동하기

일반적인 Navigation Graph와 동일하게 Navigation Controller 를 사용해주시면 되는데요, 다만 한가지 차이점이 있습니다.

바로 Nested Graphs에서는 일반적인 방법으로는 외부 destination(목적지)이 Nested Graph 내부의 특정 destination에 직접 접근할 수 없다는 점입니다.

대신, Nested Graph 자체를 대상으로 navigate()하여 내부 흐름으로 진입해야하죠.

최상위 그래프에서 Nested Graph로 직접 이동해야 할 때

위에서 설명한 것 처럼 Nested Graph의 내부 목적지에 직접 접근하지 못하기 때문에, Nested Graph 자체의 route를 사용해 접근해야합니다.

navigation(
        startDestination = AuthScreen.Login.route,
        route = AuthScreen.Auth.route
    )
...

composable(MainScreen.Home.route) {
        HomeScreen(
            navigateToLogin = { 
                navController.navigate(AuthScreen.Auth.route) 
            }
        }
    
    

해당 예제 코드처럼 navigation 내에도 route를 AuthScreen.Auth.route 으로 설정해둔 것을 보실 수 있습니다.

이러한 구조에서 navController를 활용하여 AuthScreen.Auth.route 로 navigate 한다면 Auth Graph의 startDestination인 Login 화면으로 이동할 것 입니다.

내부 목적지가 추가되거나 변경될 수 있으며, 최상위 그래프는 이를 알 필요 없이 중첩 그래프 자체에만 navigate()하면 됩니다.

최상위 그래프에서 Nested Graph 내부의 특정 목적지로 이동해야 할 경우

그러나 네비게이션 그래프 구조를 설계하는 방법에 따라 특정 중첩 그래프 내부의 경로에 직접 접근할 수 있게 만드는 것이 가능합니다!!

공식 문서에서는 중첩된 네비게이션 그래프의 캡슐화를 강조하며, 외부에서 중첩 그래프 내부 목적지에 직접 접근하지 못하고 대신 중첩 그래프 전체에 navigate()할 것을 권장하고 있습니다.

하지만 실제로는 navigate(route/path) 의 방식으로 직접 Nested Graph 내부 특정 화면으로 이동할 수 있습니다.

	composable(MainScreen.PlayGround.route) {
                PlayGroundScreen(
                    navigateToSignUp = {
                        val route = AuthScreen.Auth.route/AuthScreen.SignUp.route
                        navController.navigate(route) 
                    }
                )
            }
        }

해당 예제를 보시면 AuthScreen.Auth.route/AuthScreen.SignUp.route의 route/path의 구조를 활용하여 Main Graph의 내부 destination인 Playgroun에서 Auth Graph의 SignUp destination으로 바로 이동이 가능합니다.

다만 권장사항을 따르면 네비게이션 구조를 더욱 깔끔하고 안전하게 유지할 수 있다는 점 꼭꼭 참고하여 개발해주세요!!

ViewModel

Jetpack Compose에서 ViewModel 사용 방법 및 원칙

ViewModel은 Jetpack Compose에서 주로 화면 UI 상태를 Composable에 노출하는 수단으로 사용됩니다.

기존에는 액티비티나 프래그먼트에서 UI 컨트롤러 역할을 수행하며 재사용 가능한 UI를 만드는 것이 복잡하고 어려웠으나, Jetpack Compose와 ViewModel의 조합을 통해 더 간단하고 직관적으로 UI를 만들 수 있습니다.

문제점 발생?

하지만 심각한 문제가 하나 있죠... 바로 ViewModel을 Compose에서 사용할 때 가장 중요한 점은 Composable 자체에 ViewModel의 생명주기를 맞출 수 없다는 것입니다.

그렇기 때문에 ViewModel을 생성한 후 Composable에서 사용하고자 한다면 Screen Composable이 파괴되었음에도 계속 이전 데이터를 가지고 있는 ViewModel 인스턴스를 참조하게 될 것 입니다.

이유는?

Composable은 ViewModelStoreOwner가 아니기 때문입니다.

ViewModelStoreOwner는 ComponentActivity, Fragment, NavBackStackEntry 만의 subClass이기 때문입니다.

ViewModelStoreOwner는 뭔데?

ViewModelStoreOwner는 ViewModel을 저장하고 관리하는 컨테이너 역할을 하는 객체입니다. ViewModel을 제공하는 ViewModelProvider는 ViewModelStoreOwner를 통해 ViewModel을 관리하고 액세스합니다.

Activity, Fragment, NavBackStackEntry로 등에서 관리되는 ViewModelStore는 해당 스코프가 파괴될 때 clear() 메서드를 통해 정리됩니다.
예를 들어, Activity가 완전히 종료되거나 NavBackStackEntry가 제거될 때 clear()를 호출하여 ViewModel 인스턴스를 메모리에서 해제합니다.

Navigation Component에서 각 Navigation Destination은 BackStackEntry로 관리됩니다. NavBackStackEntry는 각 Entry별로 ViewModelStoreOwner 가지므로 목적지별로 ViewModel을 관리할 수 있습니다.

Compose viewModel() 내부 작동 원리

  • CompositionLocal 제공
    • Compose에서 NavBackStackEntry의 ViewModelStoreOwner를 CompositionLocal을 통해 사용하여 현재 BackStackEntry에 대한 ViewModel을 찾습니다.
    • LocalViewModelStoreOwner는 현재 BackStackEntry의 ViewModelStoreOwner를 제공합니다.
  • viewModel() 함수 구현
    • viewModel() 함수는 LocalViewModelStoreOwner에서 가져온 ViewModelStoreOwner를 사용해 ViewModelProvider를 통해 ViewModel을 가져옵니다.
  • BackStackEntry와 ViewModel의 연동
    • ViewModel의 생명주기는 NavBackStackEntry에 연결됩니다.
      BackStackEntry가 제거되면 해당 Entry와 연관된 ViewModel이 자동으로 정리됩니다.

viewModel 내부 코드

@Composable
inline fun <reified VM : ViewModel> viewModel(
    key: String? = null,
    factory: ViewModelProvider.Factory? = null
): VM {
    val owner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    }
    return if (key != null) {
        ViewModelProvider(owner, factory ?: defaultViewModelProviderFactory(owner)).get(key, VM::class.java)
    } else {
        ViewModelProvider(owner, factory ?: defaultViewModelProviderFactory(owner)).get(VM::class.java)
    }
}

이러한 이유로 Composable은 자체적으로 ViewModel을 관리하지 못하는데요, 따라서 두 개의 동일한 Composable이 Composition에서 동시에 존재하거나, 같은 ViewModelStoreOwner(액티비티, 프래그먼트, Navigation Destination 등)를 공유하는 경우에는 같은 ViewModel 인스턴스를 받게 됩니다.

해결책

바로 위에서 설명한 Compose Navigation을 활용하는 것입니다.

Jetpack Compose의 Navigation 라이브러리를 사용해 NavHost를 구성한 후 Navigation 목적지(destination) 내에서 viewModel()을 사용해 ViewModel을 제공합니다.

Compose ViewModel With Navigation 예제


@Composable
fun Navigation(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = AuthScreen.Auth.route
    ) {
    	composable(MainScreen.Home.route) {
            HomeScreen()
      }
  }

@Composable
fun HomeScreen(
    viewModel: HomeViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 30.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text = "Home")
        Spacer(modifier = Modifier.height(8.dp))
    }
}

Navigation destination 또는 그래프내에서 ViewModel을 구현함으로써 각 Composable에서 고유한 ViewModel 인스턴스를 제공할 수 있습니다.

이를 통해 화면 전환 시에 NavBackStackEntry에 따라 ViewModel의 생명주기가 관리됩니다.

반대로 생각해보면 어떨까?

그렇다면 반대로 비슷한 행위를 하는 혹은 Composable별로 데이터를 공유하는게 더 효율적인 경우에는 ViewModel을 하나의 인스턴스로 사용하는게 좋겠죠??

하지만 우리는 Navigation을 사용하기 때문에 오히려 하나의 인스턴스의 ViewModel을 만들기가 쉽지 않습니다.

그러나 위에서 배운 Navigation의 Nested Graph를 활용한다면 동일한 NavBackStackEntry를 활용하여 Auth Screen에서는 AuthViewModel을 사용할 수 있겠죠??

SharedViewModel?

쉽게 얘기해서 공유하는 ViewModel이라는 뜻인데 정식 명칭은 아닙니다. 그냥 제가 ViewModel을 공유한다는 차원에서 이렇게 부를거에요.

구현 방법은 다음과 같습니다.
NavBackStackEntry를 통해 parent의 route(중첩으로 감싼 주체 Graph 루트 예시로 MainScreen.Main)를 구하고 navController.getBackStackEntry를 통해 parentEntry를 구하여 ViewModel을 만들게 되면 상위 계층의 BackStackEntry를 참조하게 되기 때문에 내부의 Home, MyPage에서 동일한 ViewModel을 공유할 수 있는 것 입니다.


@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(navController: NavController): T {
    val navGraphRoute = destination.parent?.route ?: return viewModel()
    val parentEntry = remember(this) {
        navController.getBackStackEntry(navGraphRoute)
    }
    return viewModel(parentEntry)
}

@Composable
NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = AuthScreen.Auth.route
    ) {
        navigation(
            startDestination = MainScreen.Home.route,
            route = MainScreen.Main.route
        ) {
            composable(MainScreen.Home.route) {
                val viewModel = it.sharedViewModel<HomeViewModel>(navController)
                HomeScreen(viewModel)
            }
            composable(MainScreen.MyPage.route) {
                val viewModel = it.sharedViewModel<HomeViewModel>(navController)
                DetailScreen(viewModel)
            }
    }
}

오늘은 Nasted Graph(중첩 그래프) 부터 ViewModel 인스턴스를 Navigation Destination에 위치한 Screen Composable별로 공유하는 방법까지 알아보았습니다!

다음은 Compose Navigation SafeArgs 에 대해서 포스팅해보겠습니다.

profile
Android Developer

0개의 댓글