Developing Myself Everyday
article thumbnail

 

사진: UnsplashDan Chung

 

 

드디어 Compose Navigation에서 Type Safety를 지원합니다!

 

이전에 Compose를 해보지 않으셨다면 모르시겠지만, 이전의 Compose Navigation을 해보셨다면 해당 방식이 마음에 안드셨던 분들이 많았을 겁니다. 

 

불만이 많으셨던 분들을 위해 Type Safety Compose Navigation에 대해 간략히 살펴보고 제가 작성한 코드를 마이그레이션 해보고자 합니다.

 

필요한 Gradle은 아래와 같습니다.

[versions]
...
kotlinxSerializationJson = "1.6.3"
kotlinxSerialization = "1.9.0"
navigationCompose = "2.8.0-alpha08"
  
[libraries]
...
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
  
  
[plugins]
...
jetbrains-kotlin-serialization = { id ="org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerialization"}

 

 

 

근데 왜 지금까지는 그러지 못했나요?


[Before] Compose 이전의 Navigation

Compose 이전의 Navigation을 위한 Navigation Graph를 구축하는 방법은 여러 가지가 있지만, 그 중 XML을 사용한 방식은 정적으로 그래프를 구축했습니다. 

 

그렇기에 Type Safe 했습니다.

 

 

[Now] Compose Navigation

 Compose는 XML에서 벗어나고자 개발된 방식입니다. 그렇기 때문에 기존에 XML 방식을 채택할 수는 없었고 `Kotlin DSL`을 사용해 Navigation Graph를 그리는 방법으로 전환했습니다.

 

하지만, 방식이 전환되면서 컴파일 타임에 구축했던 그래프가 런타임에 생성되는 방식으로 바뀌어 버렸습니다. 

 

Navigation은 여전히 명확한 타입과 인수를 요구하지만, 개발자들은 이를 보장할 수가 없어졌고 String으로된 경로를 탐색하고, 화면 전환간 전달되는 인수에 대한 Type Safety를 보장할 수가 없었습니다.

 

아래는 제가 이전에 작성했던 Navigation 방식입니다. 

fun NavController.navigateMovieDetail(id: String) {
    navigate(MovieDetailRoute.detailRoute(id))
}

fun NavGraphBuilder.movieDetailNavGraph(
    onBackClick: () -> Unit
) {
    composable(
        route = MovieDetailRoute.detailRoute("{id}"),
        arguments = listOf(
            navArgument("id") {
                type = NavType.StringType
            }
        )
    ) { navBackStackEntry ->
        val id = navBackStackEntry.arguments?.getString("id")?.toInt() ?: 0


        MovieDetailScreen(
            id = id, onBackClick = onBackClick
        )
    }
}

object MovieDetailRoute {
    const val route = "movie_detail"

    fun detailRoute(id: String) = "$route/$id"
}

 

위의 코드에서는 화면 전환 과정에서 영화에 대한 ID를 전달하여야 합니다. 이 과정은 "$route/$id"와 같은 문자열의 정보를 추출하는 방식으로 이뤄졌습니다. 

 

이를 통해 Kotlin DSL에 전달된 문자열을 찾을 수 있었지만, 상수 문자열이 아닌경우 원하는 정보를 얻는 것이 어려웠습니다.

 

 

 

[Better Now] Type Safety Compose Navigation

이러한 이유 때문에 Kotlin DSL 코드가 신뢰할만한 소스가 되지 못했습니다. 

 

안드로이드 개발자들은 `Kotlin Serialization`을 사용해서 Navigation간에 컴파일 타임에 신뢰성을 확보하고자 했습니다. 

// 인수를 받지 않는 홈 목적지를 정의
@Serializable
data object Home

// ID를 받는 프로필 목적지를 정의
@Serializable
data class Profile(val id: String)

 

기존의 String 경로를 인수로 받아 특정 목적지로 Navigation하는 방식에서, `Serializable` 객체를 인수로 받아 경로와 인수를 처리할 수 있게 새로운 기능을 추가했습니다.

 

이렇게 함으로써 경로와 인수를 정의하는 것이 더 Type Safe하고 유지보수가 쉬워지게 됐습니다.

 

 

 

 

Type Safety를 지원하는 Compose Navigation으로 이전하기


가장 먼저 해야할 일은 경로를 정의하는 것입니다. 기존에 String으로 정의했던 경로들을 한 곳에 모아서 아래와 같이 정의합니다.

 

 

경로 정의

제가 진행하는 프로젝트에선 2개의 바텀 탭과 아이템을 누르면 이동하는 상세 화면, 총 3가지의 화면이 있습니다. 이 화면의 경로를 아래처럼 `@Serializable`을 사용해 정의하고 인수가 필요한 경우 `data class`의 매개변수로 정의합니다.

import kotlinx.serialization.Serializable

sealed interface Route {
    @Serializable
    data class MovieDetail(val movieId: Int) : Route
}

sealed interface MainTabRoute : Route {
    @Serializable
    data object Home : MainTabRoute

    @Serializable
    data object MyMovie : MainTabRoute
}

 

 

바텀탭의 아이템의 경로를 새로 정의했으므로 이를 위에서 정의한 객체로 바뀌줍니다.

enum class MainBottomNavItem(
    val title: String, 
    val icon: ImageVector, 
    // val route: String 타입 변경
    val route: MainTabRoute
) {
    HOME(
        title = "Home",
        icon = Icons.Default.Home,
        // HomeRoute.route 교체
        MainTabRoute.Home
    ),
    MY_MOVIE(
        title = "My Movie",
        icon = Icons.Default.Movie,
        // MyMovieRoute.route, 교체
        MainTabRoute.MyMovie
    );

    companion object {
        @Composable
        fun find(predicate: @Composable (MainTabRoute) -> Boolean): MainBottomNavItem? {
            return entries.find { predicate(it.route) }
        }

        @Composable
        fun contains(predicate: @Composable (Route) -> Boolean): Boolean {
            return entries.map { it.route }.any { predicate(it) }
        }
    }
}

 

 

경로로 이동

Before

먼저 예전에 바텀탭의 Navigation을 정의한 코드를 보겠습니다. 해당 모듈에 의존성이 있는 모듈에서만 접근할 수 있게 String으로 경로를 여기서 정의하였고, 화면 이동을 위해서는 `composable(route = HomeRoute.route`와 같이 정의해야 했습니다.

fun NavController.navigateHome() {
    navigate(HomeRoute.route)
}

fun NavGraphBuilder.homeNavGraph(
    showMovieDetail: (Int) -> Unit
) {
    composable(route = HomeRoute.route) {
        HomeRoute(
            showMovieDetail
        )
    }
}

object HomeRoute {
    const val route = "home"
}

 

After

이젠 위에서 정의했던 경로 객체를 넣어주기만 하면 됩니다.

fun NavController.navigateHome() {
    navigate(MainTabRoute.Home)
}

fun NavGraphBuilder.homeNavGraph(
    showMovieDetail: (Int) -> Unit
) {
    composable<MainTabRoute.Home> {
        HomeRoute(
            showMovieDetail
        )
    }
}

 

 

인수와 함께 경로로 이동

Before

기존에는 인수가 필요한 경우에는 여러 과정을 거쳐야 했습니다. 그렇게 얻은 인수도 Type Safe하지 못했습니다. 

fun NavController.navigateMovieDetail(id: String) {
    navigate(MovieDetailRoute.detailRoute(id))
}

fun NavGraphBuilder.movieDetailNavGraph(
    onBackClick: () -> Unit
) {
    composable(
        route = MovieDetailRoute.detailRoute("{id}"),
        arguments = listOf(
            navArgument("id") {
                type = NavType.StringType
            }
        )
    ) { navBackStackEntry ->
        val id = navBackStackEntry.arguments?.getString("id")?.toInt() ?: 0


        MovieDetailScreen(
            id = id, onBackClick = onBackClick
        )
    }
}

object MovieDetailRoute {
    const val route = "movie_detail"

    fun detailRoute(id: String) = "$route/$id"
}

 

After

이제는 너무나도 간단하게 인수를 전달할 수 있고, 경로 객체에 인수에 대한 타입 정보가 남아 있기 때문에 별도의 과정을 거치지 않고도 인수의 Type Safety를 보장할 수 있게 되었습니다.

fun NavController.navigateMovieDetail(movieId: Int) {
    navigate(Route.MovieDetail(movieId))
}

fun NavGraphBuilder.movieDetailNavGraph(
    onBackClick: () -> Unit
) {
    composable<Route.MovieDetail> { navBackStackEntry ->
        val movieId = navBackStackEntry.toRoute<Route.MovieDetail>().movieId

        MovieDetailScreen(
            id = movieId, onBackClick = onBackClick
        )
    }
}

 

 

 

복잡한 인수를 전달하기


개발을 하다 보면 복잡한 타입(Data Class 등)을 인수로 전달해야 할 경우가 있습니다. 이런 경우에는`Parcelize` 어노테이션으로 객체를 정의하고, 커스텀한 `NavType`을 정의해야 합니다. 

@Serializable
@Parcelize
data class Book(val id: Int, val title: String): Parcelable

 

 

Gradle도 아래와 같이 추가해 주세요

kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }

 

 

그리고 아래와 같이 커스텀 NavType을 정의합니다.

val BookType = object : NavType<Book>(
    isNullableAllowed = false
) {
    override fun get(bundle: Bundle, key: String): Book? {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            bundle.getParcelable(key, Book::class.java)
        } else {
            @Suppress("DEPRECATION")
            bundle.getParcelable(key)
        }
    }

    override fun parseValue(value: String): Book {
        return Json.decodeFromString<Book>(value)
    }

    override fun serializeAsValue(value: Book): String {
        return Json.encodeToString(value)
    }

    override fun put(bundle: Bundle, key: String, value: Book) {
        bundle.putParcelable(key, value)
    }
}

 

 

마지막으로 아래와 같이 커스텀 BavType을 `typeMap`에 추가해주면 복잡한 인수도 Type Safe하게 사용할 수 있습니다.

composable<BookDetail>(
    typeMap = mapOf(typeOf<Book>() to BookType)
) { backStackEntry ->
    val book = backStackEntry.toRoute<BookDetail>().book

   ...

 

 

 

마무리


이렇게 해서 Type Safety를 지원하는 방식을 알아보고 전환을 완료했습니다. 개발하면서도 풀편한 점이 많았는데 정말 좋은 업데이트인것 같습니다. 

 

 

 

 

 

Reference

 

Kotlin DSL 및 Navigation Compose의 유형 안전성  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Kotlin DSL 및 Navigation Compose의 유형 안전성 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 기본 제공되

developer.android.com

 

 

Navigation Compose meet Type Safety

Bringing Safe Args to Navigation Compose in Navigation 2.8.0-alpha08

medium.com

 

 

Type Safety in Navigation Compose

Taking Navigation to the next level

medium.com

 

 

profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!