본문 바로가기
안드로이드

[Android] Compose 중첩 내비게이션의 문제와 해결 (How To handle a problem about nested navigation in Jetpack Compose)

by algosketch 2024. 5. 3.

복잡한 프로젝트 요구사항을 만족시키기 위해서는 중첩된 내비게이션을 필요로 하게 된다. 이 글에서는 중첩 내비게이션을 사용할 때 발생할 수 있는 문제와 해결 방법에 대해 살펴보고자 한다. 이 문제는 불티 프로젝트를 진행하면서 실제로 발생한 문제이며, 간소화된 예제 코드는 아래 리포지토리에서 확인할 수 있다.

https://github.com/HamBP/nested-navigation-example

 

GitHub - HamBP/nested-navigation-example: Compose에서 중첩 내비게이션을 구현할 때 발생할 수 있는 문제를

Compose에서 중첩 내비게이션을 구현할 때 발생할 수 있는 문제를 해결한 예시. Contribute to HamBP/nested-navigation-example development by creating an account on GitHub.

github.com

샘플 코드는 A(목록) → B(상세화면)  → C(더 상세 화면) 구조로 구성되어 있으며, 아래 영상에서 확인할 수 있다.

 

1. 중첩 내비게이션에서 발생하는 문제

@Composable
fun MainNavHost() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "home",
    ) {
        composable(route = "home") {
            HomeScreen(
                navigateToDetail = { navController.navigate("detail/$it") }
            )
        }

        navigation(
            route = "detail/{id}",
            startDestination = "summary",
            arguments = listOf(
                navArgument("id") { type = NavType.IntType }
            )
        ) {
            composable(route = "summary") { entry ->
                val parentEntry = remember(entry) { navController.getBackStackEntry("detail/{id}") }
                val id = parentEntry.arguments!!.getInt("id")
                DetailScreen(
                    id = id,
                    navigateToDescription = { navController.navigate("description") },
                )
            }

            composable(route = "description") { entry ->
                val parentEntry = remember(entry) { navController.getBackStackEntry("detail/{id}") }
                val id = parentEntry.arguments!!.getInt("id")
                DetailContentScreen(id)
            }
        }
    }
}
@Composable
fun DetailScreen(
    id: Int,
    navigateToDescription: () -> Unit,
) {
    Column {
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .clickable(onClick = navigateToDescription),
            text = "id ${id}에 대한 상세 : 상세 화면에서는 이렇게 내용이 적... 더보기",
        )
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(300.dp)
                .background(Color.Gray)
        ) {
            Text("장식용 이미지")
        }
    }
}

위 코드는 최상위 NavHost와 navigation으로 중첩된 내비게이션을 구현하는 간단한 코드이다. (참고로 summary에서는 parent entry가 아닌 자신의 entry에서 직접 id를 가져올 수 있다.)

NavHost, navigation, composable와 같은 함수들은 모두 NavDestination으로 이루어진 내비게이션 트리를 만든다. 그중 NavHost와 navigation은 자식 노드를 가질 수 있는 NavGraph를 만들고, composable이나 dialog는 단일 노드를 만든다. (ComposeNavigator.Destination, NavGraph 등이 NavDestination을 상속한다.)

내비게이션 트리

안타깝게도 이 코드는 DetailScreen 화면에서 다음 두 가지 이벤트를 동시에 발생시킬 경우 크래시가 발생한다.

  1. 뒤로가기를 누른다. (HomeScreen으로 이동)
  2. 더보기를 누른다. (DetailContentScreen으로 이동)

결론부터 말하자면 arguments에서 꺼낸 id 값이 null이어서 발생한 문제이다. navigate 함수는 detail에 대한 NavGraph가 백스택에 없어도 하위 노드인 summary와 description을 찾을 수 있다. 즉 detail에 대한 NavGraph가 제거된 상태에서 description 화면을 찾아 생긴 문제다. 이는 home 화면에서 description 화면으로 이동을 시도한 것과 같다.

이 문제를 해결하기 위해 약 8가지 방법을 고안해 냈다. 크게 나누면 세 가지 방안이다. 근본적인 해결은 뒤로가기가 실행될 때 다른 이벤트를 막는 것이겠으나, 이는 쉽지 않다. 두 가지 이벤트가 동시에 발생 가능한 이유는 화면을 전환해도 이전 화면에 잠깐동안 살아 있어서인데, 나는 화면 전환시 발생하는 0.7초의 애니메이션에 집중했다. 애니메이션이 적용되는 시간동안 사라질 화면의 이벤트가 살아 있었기 때문에, 애니메이션을 제거하면 이벤트가 동시에 발생할 수 없다고 가정했다. 마지막은 detail이 아닌 summary에서 id를 전달하는 방법이다. 지금부터 하나씩 살펴보자.

 

2. 애니메이션을 없애보자

NavHost는 기본 값으로 0.7초짜리 화면 전환 애니메이션을 사용하고 있다. 화면 전환 시간을 없애고자 애니메이션을 제거해 보았다.

NavHost(
    navController = navController,
    startDestination = "home",
    exitTransition = { ExitTransition.None },
) { /* ... */ }

기존 코드에서 한 줄만 추가하면 되는 간단한 해결 방법이다. 아래는 테스트 영상이다.

화면 전환 시간이 줄어 크래시 재현 가능성은 줄어 들었지만, 여전히 두 가지 이벤트의 타이밍을 잘 맞추면 크래시가 발생한다. 사실 이정도만 되어도 실사용 경험에서 발생 가능성은 거의 없어 보인다. 하지만 애니메이션을 사용하지 못 하며 근본적인 해결이 아니다.

 

3. Summary에서 id를 전달해 보자

현재는 detail(navigation)에서 id를 받고 summary와 description에 id를 전달하고 있는데, 대신 summary(composable)에서 description에 id를 전달하면 description은 확정적으로 id를 얻을 수 있다.

NavHost(
    navController = navController,
    startDestination = "home",
) {
	// ...
    navigation(
        route = "detail/{id}",
        startDestination = "summary",
        arguments = listOf(
            navArgument("id") { type = NavType.IntType }
        )
    ) {
        composable(route = "summary") { entry ->
            val parentEntry = remember(entry) { navController.getBackStackEntry("detail/{id}") }
            val id = parentEntry.arguments!!.getInt("id")
            DetailScreen(
                id = id,
                navigateToDescription = { id -> navController.navigate("description/$id") }, // 파라미터를 받는 함수로 변경
            )
        }

        composable(
            route = "description/{id}",
            arguments = listOf( 
                navArgument("id") { type = NavType.IntType } // arguments 추가
            ),
        ) { entry ->
            val id = entry.arguments!!.getInt("id")
            DetailContentScreen(id)
        }
    }
}
@Composable
fun DetailScreen(
    id: Int,
    navigateToDescription: (Int) -> Unit,
) {
    Column {
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .clickable { navigateToDescription(id) }, // DetailScreen에서 받은 id를 넘겨준다.
            text = "id ${id}에 대한 상세 : 상세 화면에서는 이렇게 내용이 적... 더보기",
        )
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(300.dp)
                .background(Color.Gray)
        ) {
            Text("장식용 이미지")
        }
    }
}

DetailScreen에서 받은 id를 넘겨주기 때문에, DetailScreen에 무사히 도달했다면 id 값이 null일 수 없다. 따라서 크래시도 발생하지 않는다. 실제 프로젝트에서는 더 복잡한 컴포저블 구조로, navigateToDescription 함수를 여러 번 전달해야할 수도 있다. 아래는 테스트 영상이다.

동시 이벤트가 발생하더라도 크래시가 발생하지 않음을 확인할 수 있었다. 하지만 description에서 뒤로가기를 누르면 summary가 아닌 바로 home으로 넘어간다. 즉, 중간 화면이 사라졌다. 이 문제는 중첩 내비게이션이 아니더라도 발생할 수 있고, 드로이드 나이츠 앱에서도 이 문제를 확인했다.

크래시를 막은 것에 만족할 수 있겠지만, 이는 의도되지 않은 백스택 구조이다. 사용자 입장에서도 뒤로가기를 누른 뒤 다른 화면으로의 이동을 기대하지는 않을 것이다.

 

4. ViewModel로 이벤트 위임

마지막 방법으로 ViewModel로 이벤트 위임하는 방법을 선택했다. ViewModel에서는 코루틴을 이용해 이벤트를 channel로 관리하고, DetailScreen에서 이벤트를 처리한다. 만약 뒤로가기 이벤트가 발생하면 channel을 취소시켜 다른 이벤트가 발생하지 않도록 막았다.

sealed interface DetailEvent {
    data object NavigateUp : DetailEvent
    data object NavigateToDescription : DetailEvent
}

class DetailViewModel : ViewModel() {
    private val _events = Channel<DetailEvent>()
    val events: Flow<DetailEvent> = _events.receiveAsFlow()

    fun sendEvent(event: DetailEvent) = viewModelScope.launch {
        _events.send(event)
    }

    fun preventEvents() {
        _events.cancel()
    }
}
@Composable
fun DetailScreen(
    id: Int,
    navigateToDescription: () -> Unit,
    popBackstack: () -> Unit,
    viewModel: DetailViewModel = viewModel(),
) {
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                DetailEvent.NavigateUp -> {
                    viewModel.preventEvents()
                    popBackstack()
                }

                DetailEvent.NavigateToDescription -> navigateToDescription()
            }
        }
    }

    BackHandler {
        // 여기서 prevent를 호출할 수도 있지만, 일관된 처리를 위해 이벤트를 경유한다.
        // 실제로는 시스템 백버튼 뿐만 아니라 뒤로가기 버튼도 처리할 가능성이 높다.
        viewModel.sendEvent(DetailEvent.NavigateUp)
    }
    
    // ...
}

이벤트 stream은 일반적으로 SharedFlow 혹은 channel을 사용한다. SharedFlow는 취소가 불가능하여 여기서는 Channel을 사용했다. 이로써 처음에 의도했던 동작을 완전하게 구현했다.

 

5. 결론

이 글에서는 중첩 내비게이션에서 발생할 수 있는 문제를 알아보았다. 그리고 생각할 수 있는 3가지 방안과 적용했을 때의 결과 또한 알아보았다. 동작은 완전하게 구현되었지만, 지금의 해결 방안에는 생각해볼 만한 문제가 있어 이에 대해 언급하고자 한다.

1) 보일러 플레이트

이벤트 처리를 위해 ViewModel및 이벤트 처리를 위한 추가 코드를 작성했다. 하지만 실제 프로젝트에서는 관련 기반 코드가 이미 구현되어 있을 가능성이 높으니 예시만큼의 보일러 플레이트는 발생하지 않을 수 있다.

2) UDF 위반

이 코드는 UDF를 위반한다. UDF대로라면 ViewModel은 UI로 상태만을 내려주며, UI로부터 이벤트를 받아야 한다. 하지만 UI로 상태 뿐만 아니라 이벤트도 내려주고 있다. 이는 UDF를 위반한다. 공식문서에서는 이벤트를 상태로 모델링하라고 안내되어 있으나 이에 대해서는 공식문서를 비판하는 쪽의 의견이 우세한 듯하다. 그럼에도 생각해볼 문제는 UI에서 발생하고 UI에서 처리해야 하는 이벤트를 굳이 ViewModel을 경유했다는 점이다. 따라서 이 글의 해결 방법은 개선의 여지가 있다고 생각하며, 다른 해결 방법에 대한 의견을 환영한다.