[Compose Navigation] Compose Navigation으로 안전하게 인자 전달하기

Android/Android · 2024. 5. 15. 00:46
반응형

compose navigation으로 값을 전달할 때 query 문이나 json 변환 등 다양한 방법을 사용할 수 있다.

그러나 이러한 방법들은 매우 귀찮고 헷갈리며, 필요없는 코드가 너무 많아지기 때문에 선뜻 접근하기가 쉽지 않았는데...

 

이번에 새로운 Compose Navigation 버전이 나왔다!

 

전달할 데이터를 kotlinx-serialization으로 직렬화하여 저장하고, 사용할 때 다시 역직렬화하여 Screen에 인자를 전달하는 방법이다.

사용법은 다음과 같다.

 

1. compose-navigation:2.8.0-alpha08, kotlinx-serialization 추가

 

[versions]
kotlin = "1.9.21"
kotlinSerialization = "1.6.0"
androidx-navigation-compose = "2.8.0-alpha08"

[libraries]
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" }  
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation-compose" }

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

 

 

2. build.gradle에 추가

 

plugins {
    alias(libs.plugins.kotlinSerialization)
}

dependencies {
    implementation(libs.kotlin.serialization)
    implementation(libs.kotlin.androidxNavigationCompose)
}

 

 

3. Screen 타입 생성

 

// 직렬화 annotation 필수
sealed interface ScreenType {
    @Serializable
    data object ScreenA : ScreenType
    
    @Serializable
    data class ScreenB(
    	val a: String = "",
        val b: Int = 0,
        val c: Float = 0f
    ) : ScreenType
}

 

 

4. NavHost, NavGraph 생성

 

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            TestNavigationTheme {
                NavHost(
                    navController = navController,
                    startDestination = ScreenType.ScreenA
                ) {
                    composable<ScreenType.ScreenA> {
                        ScreenA(
                            // ScreenA에서 ScreenB로 가는 버튼을 누르면
                            // ScreenB data class의 객체를 만들어 전달한다.
                            onClickScreenBButton = { screenType: ScreenType.ScreenB ->
                                // 이 때, 내부에서 직렬화를 수행하여 argument를 저장한다.
                                navController.navigate(route = screenType)
                            },
                        )
                    }

                    // 전달할 데이터가 있을 경우 backStackEntry를 통해
                    composable<ScreenType.ScreenB> { navBackStackEntry ->
                        // data class를 역직렬화하여 객체를 가져온다.
                        val args = navBackStackEntry.toRoute<ScreenType.ScreenB>()
                        ScreenB(
                            args = args,
                            onClickScreenAButton = {
                                navController.navigate(
                                    route = ScreenType.ScreenA,
                                    // navOptions를 통해 backStack 제거
                                    navOptions = navOptions {
                                        popUpTo(route = ScreenType.ScreenA) {
                                            inclusive = true
                                        }
                                    }
                                )
                            }
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun ScreenA(
    onClickScreenBButton: (ScreenType.ScreenB) -> Unit,
) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Button(
            onClick = {
                onClickScreenBButton(
                    // ScreenB에 넘겨줄 데이터를 만들어서 전달
                    ScreenType.ScreenB(
                        a = "from ScreenA",
                        b = 1,
                        c = 2f
                    )
                )
            }
        ) {
            Text(text = "To ScreenB")
        }
    }
}

@Composable
fun ScreenB(
    args: ScreenType.ScreenB,
    onClickScreenAButton: () -> Unit
) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(text = args.a)
            Text(text = "${args.b}")
            Text(text = "${args.c}")
            Button(
                onClick = onClickScreenAButton
            ) {
                Text("To ScreenA")
            }
        }
    }
}

 

 

실행 결과



 

 

해당 navigate 함수를 어떻게 직렬화하는지 내부를 살펴보면,

 

@MainThread
@JvmOverloads
public fun <T : Any> navigate(
    route: T,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null
) {
    // finalRoute에 직렬화하여 저장한다.
    val finalRoute = generateRouteFilled(route)
    // 직렬화된 코드를 DeepLink로 만들어서 navigate한다
    navigate(
        NavDeepLinkRequest.Builder.fromUri(NavDestination.createRoute(finalRoute).toUri()).build(), navOptions,
        navigatorExtras
    )
}

// 직렬화 저장 코드
@OptIn(InternalSerializationApi::class)
private fun <T : Any> generateRouteFilled(route: T): String {
    val id = route::class.serializer().hashCode()
    val destination = findDestinationFromRoot(id)
    // throw immediately if destination is not found within the graph
    requireNotNull(destination) {
        "Destination with route ${route::class.simpleName} cannot be found " +
                "in navigation graph $_graph"
    }
    return route.generateRouteWithArgs(
        // get argument typeMap
        destination.arguments.mapValues { it.value.type }
    )
}

 

결국엔 해당 클래스를 직렬화하여 DeepLink로 만들고,  navigate() 메서드에 딥링크를 전달하여 navigate한다.

 

 

또한, 다음과 같은 코드로 navArgument를 가져오는데

val args = backStackEntry.toRoute<ScreenType.ScreenB>()

 

내부를 살펴보면,

public inline fun <reified T> NavBackStackEntry.toRoute(): T {
    val bundle = arguments ?: Bundle()
    val typeMap = destination.arguments.mapValues {
        it.value.type
    }
    // 역직렬화해서 가져온다.
    return serializer<T>().decodeArguments(bundle, typeMap)
}

// 역직렬화 코드
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun <T> KSerializer<T>.decodeArguments(
    bundle: Bundle,
    typeMap: Map<String, NavType<*>>
): T = androidx.navigation.serialization.RouteDecoder(bundle, typeMap).decodeSerializableValue(this)

 

 

역직렬화해서 가져올 수 있다.

 

 

이번에 최신 Compose Navigation을 사용하면서 느낀점은 일단 굉장히 많은 코드와 복잡성을 줄일 수 있다는 것이었다. 

 

예전에 query문을 사용하면서 argument를 넘겼을 때 경로를 작성하는 것이 매우 까다로웠으며, 특히 해당 경로를 String 타입으로 판별하는 것이 매우 맘에 들지 않았다. 또한, argument를 가지고 올 때 nullable하게 가져오는 것도 상당히 별로였다.

 

그러나 아직 alpha 버전이지만, 이번에 나온 Compose Navigation을 사용하면서 위의 불만사항들을 싹 해소시켜주었다. 정식버전이 나오면 또 어떻게 변할진 모르겠지만, 이전에 사용했던 것보단 더욱 좋은 방식으로 개선되었으면 한다.

 

 

 

[추가]

 

다음은 composable {} 할 때 backStackEntry를 사용하여 argument를 계속 추가하는게 귀찮은 분들을 위해(바로 나..) 작성해봤다.

 

inline fun <reified T : Any> NavGraphBuilder.composableWithArgument(
    crossinline screen: @Composable (T) -> Unit,
) {
    composable<T> { backStackEntry ->
        val decodeArgument = backStackEntry.toRoute<T>()
        screen(decodeArgument)
    }
}

// 사용법
composableWithArgument<ScreenType.ScreenA> {
    ScreenA(
        onClickScreenBButton = { screenType: ScreenType.ScreenB ->
            navController.navigate(route = screenType)
        },
    )
}

composableWithArgument<ScreenType.ScreenB> { args ->
    ScreenB(
        args = args,
        onClickScreenAButton = {
            navController.navigate(
                route = ScreenType.ScreenA,
                navOptions = navOptions {
                    popUpTo(route = ScreenType.ScreenA) {
                        inclusive = true
                    }
                }
            )
        }
    )
}
반응형