드디어 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
...
Kotlin Serialization으로 복잡한 인수를 전달하기
만약 `Parcelize` 어노테이션을 사용하시는게 싫으시다면 Serialization의 Json을 사용하는 방법도 있습니다.
@Serializable
data class Book(val id: Int, val title: String)
조금 더 편하게 제네릭을 사용하여 아래와 같이 커스텀 NavType을 반환해주는 inline 함수를 정의하고
inline fun <reified T : Any> serializableType(
isNullableAllowed: Boolean = false,
json: Json = Json,
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
override fun get(bundle: Bundle, key: String) =
bundle.getString(key)?.let<String, T>(json::decodeFromString)
override fun parseValue(value: String): T = json.decodeFromString(value)
override fun serializeAsValue(value: T): String = json.encodeToString(value)
override fun put(bundle: Bundle, key: String, value: T) {
bundle.putString(key, json.encodeToString(value))
}
}
이를 동일하게 아래와 같이 사용할 수 있습니다.
composable<BookDetail>(
typeMap = mapOf(typeOf<Book>() to serializableType<Book>())
) {
...
마무리
이렇게 해서 Type Safety를 지원하는 방식을 알아보고 전환을 완료했습니다. 개발하면서도 풀편한 점이 많았는데 정말 좋은 업데이트인것 같습니다.
Reference
'Android > Compose' 카테고리의 다른 글
ViewModel 톺아보기 (1) - ViewModel의 생성과 관리 (0) | 2024.11.18 |
---|---|
SavedStateHandle을 통해 Compose Navigation간 데이터 전달하기 (3) | 2024.09.23 |
Compose의 WindowInsets (0) | 2024.04.23 |
Compose의 SnapShot 시스템 (0) | 2024.03.29 |
Compose의 Remember, RememberSaverable 정복하기 (1) | 2024.03.06 |