Jetpack Compose 탐색 아키텍처

Mar 23 2023
예제 코드를 사용하여 Compose의 탐색 구성요소를 살펴보고 탐색 구성요소에 대한 UI 테스트를 작성해 보겠습니다. Compose의 내비게이션은 기존의 뷰 베이스 내비게이션과 동일한 핵심 부분을 가지고 있습니다. 기본 설명을 볼 수 있으며 경로가 무엇인지 아래에서 자세히 살펴보겠습니다. route는 navController를 사용하여 탐색할 수 있는 각 구성 가능한 대상을 정의하는 데 사용되는 키입니다. 이를 구성 가능 항목에 대한 딥 링크로 생각할 수 있습니다.
Unsplash에 있는 Joseph Barrientos의 사진

예제 코드를 사용하여 Compose의 탐색 구성요소를 살펴보고 탐색 구성요소에 대한 UI 테스트를 작성해 보겠습니다.

Compose의 내비게이션은 기존의 뷰 기본 내비게이션과 동일한 핵심 부분을 가지고 있으며 기본 설명을 볼 수 있으며 자세한 내용은 아래에서 설명합니다.

  • 탐색 그래프: 사용자가 탐색할 수 있는 구성 가능한 대상 을 지정하고 각 구성 가능한 대상은 경로 와 연결되며 경로는 각 구성 가능한 대상을 정의하는 고유 키입니다.
  • NavHost: 구성 가능 키워드를 사용하여 구성 가능한 모든 대상을 내부에 지정하는 구성 가능 항목입니다 . 사용자가 서로 다른 구성 가능 대상 사이를 탐색하면 NavHost 콘텐츠가 재구성되어 해당 구성 가능 항목을 표시합니다.
  • NavigationController: NavHost 내부의 모든 내비게이션을 관리합니다 . 개별 대상으로 이동하고 해당하는 구성 가능한 화면을 표시합니다. 상태 저장 및 탐색 및 상태에 사용되는 퇴비화 가능 백 스택을 추적합니다.

경로란 무엇입니까?

route 는 navController 를 사용하여 탐색할 수 있는 각 구성 가능한 대상을 정의하는 데 사용되는 키입니다 . 이를 구성 가능 항목에 대한 딥 링크로 생각할 수 있습니다. 구성 가능한 각 대상을 고유하게 식별해야 합니다.

코드 예제를 살펴보겠습니다.

예

예를 들어 노래 화면, 즐겨찾기 화면 및 검색 화면의 세 가지 화면이 있는 음악 앱의 샘플 프로젝트를 사용합니다.

내비게이션 사용법을 보여주기 위해 간단하게 유지하고 위에서 언급한 각 화면으로 이동할 수 있는 3개의 버튼이 있는 홈 화면을 만듭니다.

HomeScreen은 내비게이션을 표시하기 위해 생성됩니다. 실제 프로젝트에서는 필요에 따라 하단 내비게이션 또는 서랍 내비게이션을 사용합니다.

의존

implementation "androidx.navigation:navigation-compose:2.5.3"

NavHost는 rememberNavController() api 에서 생성된 NavigationController 와 시작 목적지의 경로를 매개변수로 취하는 구성 가능한 함수입니다 . NavHost는 구성 가능한 키워드를 사용하여 가능한 모든 대상을 제공하는 데 사용하는 람다를 노출합니다.

이 예에서는 구성 가능한 키워드를 사용하여 네 개의 구성 대상이 있는 NavHost를 만들고 탐색할 각 대상 경로 와 구성 가능한 항목을 지정합니다. NavHost 의 startDestination을 "홈" 경로 로 전달하고 HomeScreen 컴포저블이 첫 번째 화면으로 표시됩니다.

@Composable
fun MainNavigation(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") { HomeScreen() }
        composable("songs") { SongsScreen() }
        composable("favourites") { FavouritesScreen() }
        composable("search") { SearchScreen()}
    }

}

setContent {
    ComposeNavigationTheme {
        // A surface container using the 'background' color from the theme
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colors.background
        ) {
            MainNavigation()
        }
    }
}

NavHost는 "home" 이 NavHost 의 시작 대상으로 설정되어 현재 HomeScreen을 표시하는 컨테이너 역할을 합니다 .

탐색하는 방법?

Navigation Grpah에서 구성 가능한 대상으로 이동하려면 navController 에서 탐색 방법을 사용하고 탐색하려는 대상의 경로를 전달합니다.

navController.navigate("songs")

이 예시에서 HomeScreen 컴포저블은 SongsScreen , FavouritesScreen 및 SearchScreen 컴포저블로 이동합니다 .

컴포저블 내에서 어떻게 탐색하나요?

단일 정보 소스를 준수 하고 상태 호이스팅을 유지하려면 NavController를 호이스트하는 구성 가능 함수 또는 NavController를 사용하는 구성 가능 기능 만 탐색을 수행해야 합니다. 계층 구조의 하위 컴포저블에서 탐색을 수행하면 커플링이 발생하고 향후 코드를 확장할 수 없으므로 탐색을 수행하지 않아야 합니다.

이것을 달성하는 방법? navController를 계층 구조에서 매개변수로 전달하는 대신 Lambda 함수를 노출하여 이를 달성할 수 있습니다 . 아래 참조…

이 예시에서는 HomeScreen 컴포저블에서 다른 3개의 컴포저블로 탐색하려고 하므로 HomeScreen 컴포저블 onNavigateToSongs, onNavigateToFavourites, onNavigateToSearch에서 3개의 람다 메서드를 노출하고 각 버튼을 누를 때마다 해당하는 람다를 호출하여 결국 특정 구성 가능한 대상으로 이동합니다. , 아래 코드를 참조하십시오.

위에서 설명한 대로 HomeScreen 컴포저블의 변경 사항을 확인하세요.

@Composable
fun HomeScreen(
    onNavigateToSongs: () -> Unit,
    onNavigateToFavourites: () -> Unit,
    onNavigateToSearch: () -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Button( onClick = { onNavigateToSongs() }) {
            Text(text = "Go to Songs")
        }
        Spacer(modifier = Modifier.size(8.dp))
        Button(onClick = { onNavigateToFavourites() }) {
            Text(text = "Go to Favourites")
        }
        Spacer(modifier = Modifier.size(8.dp))
        Button(onClick = { onNavigateToSearch() }) {
            Text(text = "Search Song")
        }

    }

}

@Composable
fun MainNavigation(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") { HomeScreen(
            onNavigateToSongs = { navController.navigate("songs") },
            onNavigateToFavourites = { navController.navigate("favourites") },
            onNavigateToSearch = {navController.navigate("search") }
        ) }
        composable("songs") { SongsScreen() }
        composable("favourites") { FavouritesScreen() }
        composable("search") { SearchScreen()}
    }

}

앱을 실행하면 내비게이션이 작동하는 것을 볼 수 있고 장치 뒤로 버튼도 자동으로 작동합니다. 장치 뒤로 버튼을 누르면 NavController 에 의해 내부적으로 관리되는 백 스택에 컴포저블이 다시 나타납니다 .

다음 계층 구조에서 탐색하는 방법은 무엇입니까?

SearchScreen 컴포저블에서 다시 SongsScreen 으로 이동하고 싶다고 가정해 보겠습니다 . SearchScreen 에 버튼을 추가 하고 해당 버튼을 클릭하면 호출될 람다 함수를 노출하면 SongsScreen 으로의 탐색이 MainNavigation 컴포저블 내부의 상위 수준에서 발생합니다 .

코드 예시를 통해 살펴보겠습니다. 먼저 새로운 SearchScreen 컴포저블이 어떻게 보이는지 확인하세요.

@Composable
fun SearchScreen(
    onNavigateToSongs: () -> Unit,
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Search Screen")
        Spacer(modifier = Modifier.size(8.dp))
        Button(onClick = onNavigateToSongs) {
            Text(text = "Go to Songs")
        }
    }
}

@Composable
fun MainNavigation(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") { HomeScreen(
            onNavigateToSongs = { navController.navigate("songs") },
            onNavigateToFavourites = { navController.navigate("favourites") },
            onNavigateToSearch = { navController.navigate("search") }
        ) }
        composable("songs") { SongsScreen() }
        composable("favourites") { FavouritesScreen() }
        composable("search") { SearchScreen(
            onNavigateToSongs = { navController.navigate("songs") }
        ) }
    }
}

그러나 한 가지 문제가 있습니다! SearchScreen 에서 SongsScreen 으로 이동 하고 장치 뒤로 버튼을 누르면 SearchScreen이 다시 표시됩니다 . 일반적으로 대부분의 사용 사례에서 이 중간 화면이 다시 표시되는 것을 원하지 않고 오히려 HomeScreen 으로 돌아갑니다.

이것을 달성하는 방법? NavController 의 탐색 메서드 에서 람다 함수를 구현하고 SongsSceen 으로 이동하기 전에 백 스택에서 홈 대상 까지 모든 항목을 팝업하여 이를 달성할 수 있습니다( 아래 코드 참조).

composable("search") { SearchScreen(
    onNavigateToSongs = { navController.navigate("songs") {
        // pop up everything to the "home" destination before navigating
        // to "songs" destination 
        popUpTo("home")
    }}
)}

@Composable
fun MainNavigation(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") { HomeScreen(
            onNavigateToSongs = { navController.navigate("songs") },
            onNavigateToFavourites = { navController.navigate("favourites") },
            onNavigateToSearch = { navController.navigate("search") }
        )}
        composable("songs") { SongsScreen() }
        composable("favourites") { FavouritesScreen() }
        composable("search") { SearchScreen(
            onNavigateToSongs = { navController.navigate("songs") {
                // pop up everything to the "home" destination before navigating
                // to "songs" destination
                popUpTo("home")
            }}
        )}
    }
}

탐색에는 몇 가지 일반적인 사용 사례가 있으며 이를 달성하는 방법을 살펴보겠습니다.

해당 경로를 포함하여 popUpTo 경로를 지정하려는 사용 사례 : 이를 달성하려면 popUpTo 메서드를 지정하는 추가 람다 함수를 구현해야 합니다.{ inclusive = true }

navController.navigate("navigateToRoot") {
    // pop up everything including "popupRoot" destination before navigating
    // to "navigateToRoot" destination
    popUpTo("popupRoot") { inclusive = true }
}

그것을 피하는 방법? launchSingleTop = true탐색 방법 람다 함수에서 지정해야 합니다. 아래 코드 예제를 참조하세요.

navController.navigate("songs") {
    launchSingleTop = true
}

내비게이션의 모범 사례를 다시 요약합니다.

다른 컴포저블로 이동하려는 컴포저블을 위해 탐색 로직을 계층 구조로 유지하여 람다 함수를 노출합니다. 구성 가능한 함수 서명을 오버로드할 수 있지만 아래 설명된 이점이 있습니다.

  • 컴포저블에서 호출자에게 이벤트를 노출하면 상태 호이스팅 개념을 활용하고 하위 컴포저블을 상태 비저장으로 유지합니다.
  • 내비게이션 로직이 단일 정보 소스로 작동하는 방식에 대해 더 높은 수준에서 가시성을 제공합니다.
  • 구성 가능한 각 기능, 구성 가능한 기능의 가시성을 극대화하여 구성 가능한 기능을 한 눈에 볼 수 있습니다.
  • 테스트 가능성: 매개변수에 Nav 컨트롤러를 전달할 필요가 없고 탐색을 위해 전달되는 람다는 테스트를 작성하는 동안 빈 {}으로 설정할 수 있으므로 컴포저블을 개별적으로 테스트할 수 있습니다.

경로 키 문자열이 여러 위치에서 사용되는 문제가 있습니다 . 하나는 NavHost에서 대상 컴포저블을 지정할 때이고 다른 하나는 특정 대상으로 이동하는 동안입니다. 동일한 키 문자열이 여러 위치에서 사용되기 때문에 좋지 않으며 변경해야 하는 경우 지정된 모든 곳에서 변경되고 더 어려워지고 코드가 커지면 문제가 발생할 수 있습니다.

이를 개선하기 위해 모든 경로 경로 키를 추적하는 문자열 키로 경로 를 사용하는 봉인된 클래스를 만들 수 있습니다.

sealed class Screen(val route: String) {
    object Home: Screen("home")
    object Songs: Screen("songs")
    object Favourites: Screen("favourites")
    object Search: Screen("search")
}

@Composable
fun MainNavigation(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) { HomeScreen(
            onNavigateToSongs = { navController.navigate(Screen.Songs.route) {
                launchSingleTop = true
            } },
            onNavigateToFavourites = { navController.navigate(Screen.Favourites.route) },
            onNavigateToSearch = { navController.navigate(Screen.Search.route) }
        )}
        composable(Screen.Songs.route) { SongsScreen() }
        composable(Screen.Favourites.route) { FavouritesScreen() }
        composable(Screen.Search.route) { SearchScreen(
            onNavigateToSongs = { navController.navigate(Screen.Songs.route) {
                // pop up everything to the "home" destination before navigating
                // to "songs" destination
                popUpTo(Screen.Home.route)
            }}
        )}
    }

UI 탐색 테스트

여기에서 동일한 예제를 확장하는 탐색 구성 요소 테스트에 대한 또 다른 이야기를 작성했습니다.

레포가 여기 있습니다

팔로우 잊지 마시고 마음에 드신다면 :)