Jetpack Navigation 기초 부분을 공부하고 싶으시다면 해당 링크를 참고해주세요!
Nested Graph은 한마디로 중첩된 그래프 구조를 가진 형태를 뜻합니다. 지난번 포스팅인 Jetpack Navigation 소개 포스팅 처럼 GraphBuilder.composable() 함수를 활용하면 Navigation 내의 destination(목적지)를 추가하여 Graph를 구성할 수 있게 됩니다.
중첩 그래프는 이러한 destination(목적지)의 집합체를 한번 더 Graph로 구성하여 추상화(공통적인 특성을 가진 요소를 상위 Type으로 wrapping했다) 시켰다 라고 이해해주시면 쉬우실겁니다.
간단한 예제를 보시죠
아래 사진과 같이 기존 그래프의 경우에는 각각의 destination 별로 Graph를 구성했습니다. 즉 모두 같은 계층의 Graph에 존재하며 이동하는데에도 큰 제약은 없죠.
그렇기에 간단한 화면 구조에서는 오히려 효과적으로 화면 이동의 흐름을 파악할 수 있습니다.
다만 복잡한 화면 구조에서는 stack을 관리하거나 화면별로 공통적인 data를 관리하는 구조가 더욱 복잡해집니다. 또한 반복되는 화면의 Flow를 재사용할 수 없는 구조라는 점이 가장 큰 단점이 되는 구조입니다.
아래 사진이 중첩 그래프를 적용했을 때의 화면 흐름입니다.
유저 인증과 관련된 화면은 Login, 회원가입, 비밀번호 변경 화면으로 Auth Graph내부에 중첩되어 구현되어 있습니다.
Main 기능 관련된 화면은 홈, Playground, My page로 화면은 Main Graph내에 중첩되있는 구조입니다.
1. 모듈화 및 재사용성
2. 캡슐화
3. 흐름 관리 간소화
4. 코드 분리 및 가독성
5. 복잡한 플로우 처리에 유용
일반적인 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()
}
}
}
일반적인 Navigation Graph와 동일하게 Navigation Controller 를 사용해주시면 되는데요, 다만 한가지 차이점이 있습니다.
바로 Nested Graphs에서는 일반적인 방법으로는 외부 destination(목적지)이 Nested Graph 내부의 특정 destination에 직접 접근할 수 없다는 점입니다.
대신, Nested Graph 자체를 대상으로 navigate()하여 내부 흐름으로 진입해야하죠.
위에서 설명한 것 처럼 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()하면 됩니다.
그러나 네비게이션 그래프 구조를 설계하는 방법에 따라 특정 중첩 그래프 내부의 경로에 직접 접근할 수 있게 만드는 것이 가능합니다!!
공식 문서에서는 중첩된 네비게이션 그래프의 캡슐화를 강조하며, 외부에서 중첩 그래프 내부 목적지에 직접 접근하지 못하고 대신 중첩 그래프 전체에 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에서 주로 화면 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는 ViewModel을 저장하고 관리하는 컨테이너 역할을 하는 객체입니다. ViewModel을 제공하는 ViewModelProvider는 ViewModelStoreOwner를 통해 ViewModel을 관리하고 액세스합니다.
Activity, Fragment, NavBackStackEntry로 등에서 관리되는 ViewModelStore는 해당 스코프가 파괴될 때 clear() 메서드를 통해 정리됩니다.
예를 들어, Activity가 완전히 종료되거나 NavBackStackEntry가 제거될 때 clear()를 호출하여 ViewModel 인스턴스를 메모리에서 해제합니다.
Navigation Component에서 각 Navigation Destination은 BackStackEntry로 관리됩니다. NavBackStackEntry는 각 Entry별로 ViewModelStoreOwner 가지므로 목적지별로 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을 사용할 수 있겠죠??
쉽게 얘기해서 공유하는 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 에 대해서 포스팅해보겠습니다.