
컴포즈에서도 Navigation 2.8.0 부터 type safety한 navigation을 사용 할 수 있게 되었다.
컴포즈 네비게이션과 기존 xml 방식과의 차이는 kotlin dsl을 활용하여 Navigation graph를 구축한다는 점이다.
또한 컴파일 방식에 구축했던 그래프가 런타임에 생성되는 방식으로 변경되었다.
그러나 Navigation의 경우
하지만 이전 버전의 불편한 점은 route와 argment를 결합해서 경로에 보내야하는 단점이 있다.
String-based Routes: 경로(route)를 문자열로 정의하여 화면 간 이동을 할 수 있었다.
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable("profile/{userId}") { backStackEntry ->
ProfileScreen(userId = backStackEntry.arguments?.getString("userId"))
}
}
여기서 각 화면 간에 userId와 같은 값을 전달할 때는 경로 문자열을 통해 데이터를 전달했습니다. 즉, "profile/{userId}"와 같은 방식으로 파라미터를 경로에 포함시켜 데이터를 전달했다. 하지만 이 방식은 다음과 같은 문제점이 있다.
하지만 이 방식은 다음과 같은 문제가 있다.
"profile/{useid}"로 오타를 입력해도 컴파일 타임에는 오류가 발생하지 않고 런타임에 오류가 발생. → 런타임시 앱 크래시 및 강제 종료를 야기NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable("profile/{userId}") { backStackEntry ->
// 경로로 전달된 데이터는 기본적으로 String이므로 Int로 변환해야 함
val userIdString = backStackEntry.arguments?.getString("userId")
val userId = userIdString?.toIntOrNull() ?: 0 // 안전한 변환(Int로 변환할 수 없으면 기본값 0)
ProfileScreen(userId = userId)
}
} 특히 custom data class의 경우 객체를 Json문자열로 직렬화 후 url로 인코딩 한 후 받는 쪽에서 다시 해당 문자열을 역 직렬화 하여 객체화를 시켜야 했다. 이는 복잡성이 매우 높았다. 위의 이유로 인해 개발자들에게 있어 kotlin dsl 방식이 효율적이지 않았다.
그래서 Kotlin Serialization 플러그인을 사용해서 컴파일시에 안정성을 갖고자 한다
[libraries]
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.6.3" }
[plugins]
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Compose에서 유형 안전 경로를 사용하려면 먼저 직렬화 가능 여부를 정의해야 합니다. 경로를 나타내는 클래스 또는 객체입니다.
KClass<T>: 클래스와 같은 인수를 전달할 필요가 없는 경우 사용합니다. 매개변수가 없는 클래스 또는 모든 매개변수에 기본값이 있는 클래스 Profile::class예를들면 이런식이다
// Define a home route that doesn't take any arguments
@Serializable
data object Home
// Define a profile route that takes an ID
@Serializable
data class Profile(val id: String)
다음으로 탐색 그래프를 정의해야 합니다. composable() 사용 함수를 사용하여 구성 가능한 함수를 탐색 그래프에서 대상으로 정의할 수 있습니다.
NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(onNavigateToProfile = { id ->
navController.navigate(Profile(id))
})
}
composable<Profile> { backStackEntry ->
val args = backStackEntry.toRoute<Profile>()
ProfileScreen(args.id)
}
}
기존의 문자열(String)경로를 인수로 받아 특정 목적지로 Navigation하는 방식에서, Serializable 객체를 인수로 받아 경로와 인수를 처리할 수 있게 새로운 기능을 추가했습니다.
이렇게 함으로써 경로와 인수를 정의하는 것이 더 Type Safe하고 유지보수가 쉬워지게 됐습니다.
![]() |
![]() |
![]() |
![]() |
마지막 화면에서 모든 인자를 data class로 만들어 직렬화 하여 서버에 보내야하는 구조 였다.
해당 data class의 구조는 이렇게 생겼다
data class MemberInfoEditModel(
val nickname: String
val isAlarmAllowed: Boolean
val memberIntro: String
val isPushAlarmAllowed: Boolean
val fcmToken: String
val memberLckYears: Int
val memberFanTeam: String
val memberDefaultProfileImage: String
)
보내야 할 인자가 많다.
만약 각 화면 별로 Route에 인자를 넣어서 보내게 되면 각 Route마다 해당 인자를 받고 또 넘기는 작업을 매번 해야 한다. 또한 Route의 모양 또한 복잡해질 것이다.
sealed interface Route {
@Serializable
data class FirstLckWatch(
val nickname: String? = null,
val isAlarmAllowed: Boolean? = null,
val memberIntro: String? = null,
val isPushAlarmAllowed: Boolean? = null,
val fcmToken: String? = null,
val memberLckYears: Int? = null,
val memberFanTeam: String? = null,
val memberDefaultProfileImage: String? = null,
) : Route
@Serializable
data class SelectLckTeam(
val nickname: String? = null,
val isAlarmAllowed: Boolean? = null,
val memberIntro: String? = null,
val isPushAlarmAllowed: Boolean? = null,
val fcmToken: String? = null,
val memberLckYears: Int? = null,
val memberFanTeam: String? = null,
val memberDefaultProfileImage: String? = null,
) : Route
}
아마 이런식으로 보내야 하지 않았을까..
따라서 처음에는 원시 타입인 List 타입을 Route의 파라미터로 넘겼다
@Serializable
data class SelectLckTeam(val userList: List<String?>) : Route
@Serializable
data class Profile(val userList: List<String?>) : Route
이 방법 또한 효율적이기는 했으나 마지막에 약관동의 화면에서 모든 인자들을 리스트에 꺼내 data class의 각 필드에 매핑 시킨 이후 직렬화 해 서버와 통신하는 방식 이였다.
→ 매 화면 List를 초기화 해야하고 마지막 화면에서 dto매핑하는 model에 각 필드를 할당 해야하는 점이 비효율적이라 판단하였다.
sealed interface Route {
@Serializable
data object Splash : Route
@Serializable
data object Login : Route
@Serializable
data object FirstLckWatch : Route
@Serializable
data class SelectLckTeam(val memberInfoEditModel: MemberInfoEditModel) : Route
@Serializable
data class Profile(val memberInfoEditModel: MemberInfoEditModel) : Route
@Serializable
data class AgreeTerms(val memberInfoEditModel: MemberInfoEditModel, val profileUri: String?) : Route
}
route 각 data class의 파라미터에 memberInfoEditModel을 담아 넘기는 쪽으로 리팩토링 하였다.
@Serializable
data class MemberInfoEditModel(
val nickname: String,
val isAlarmAllowed: Boolean,
val memberIntro: String,
val isPushAlarmAllowed: Boolean,
val fcmToken: String,
val memberLckYears: Int,
val memberFanTeam: String,
val memberDefaultProfileImage: String,
) : Parcelable
여기서 @Serializable 어노테이션을 추가 해야 한다
CustomNavType 은 아래와 같이 만들면 된다
object CustomNavType {
val MemberInfoEditModelType = object : NavType<MemberInfoEditModel>(
isNullableAllowed = false
) {
override fun get(bundle: Bundle, key: String): MemberInfoEditModel? {
return Json.decodeFromString(bundle.getString(key) ?: return null)
}
override fun parseValue(value: String): MemberInfoEditModel {
return Json.decodeFromString(Uri.decode(value))
}
override fun serializeAsValue(value: MemberInfoEditModel): String {
return Uri.encode(Json.encodeToString(value))
}
override fun put(bundle: Bundle, key: String, value: MemberInfoEditModel) {
bundle.putString(key, Json.encodeToString(value))
}
}
}
커스텀 객체를 만들기 위해서는 NavType을 확장하여 커스텀 타입을 만들어야 한다.
각 메소드의 역할은 아래에서 자세하게 설명하겠습니다.
하지만 data class를 전달하는데 serialization이 효율적일까?
→ Parceilze가 있다 우리에겐
val memberInfoNavType = object : NavType<MemberInfoEditModel>(
isNullableAllowed = false,
) {
override fun get(bundle: Bundle, key: String): MemberInfoEditModel? =
bundle.parcelable(key)
override fun parseValue(value: String): MemberInfoEditModel =
Json.decodeFromString(Uri.decode(value))
override fun serializeAsValue(value: MemberInfoEditModel): String =
Uri.encode(Json.encodeToString(value))
override fun put(bundle: Bundle, key: String, value: MemberInfoEditModel) {
bundle.putParcelable(key, value)
}
}
inline fun <reified T : Parcelable> Bundle.parcelable(key: String): T? = when {
SDK_INT >= 33 -> getParcelable(key, T::class.java)
else -> @Suppress("DEPRECATION")
getParcelable(key) as? T
}
이런식으로 코드를 리팩토링 할 수 있다.
Parceilze 를 이용하기에 Route의 인자로 들어가는 Model class도 Parcelize하게 바꾸어 준다
@Serializable
@Parcelize
data class MemberInfoEditModel(
val nickname: String,
val isAlarmAllowed: Boolean,
val memberIntro: String,
val isPushAlarmAllowed: Boolean,
val fcmToken: String,
val memberLckYears: Int,
val memberFanTeam: String,
val memberDefaultProfileImage: String,
) : Parcelable
fun NavController.navigateToAgreeTerms(
memberInfoEditModel: MemberInfoEditModel,
profileUri: String?,
) {
this.navigate(Route.AgreeTerms(memberInfoEditModel, profileUri))
}
fun NavGraphBuilder.agreeTermsNavGraph(
navigateToHome: () -> Unit,
onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
) {
composable<Route.AgreeTerms>(
typeMap = mapOf(
typeOf<MemberInfoEditModel>() to memberInfoNavType ,
),
) { backStackEntry ->
val args = backStackEntry.toRoute<Route.AgreeTerms>()
AgreeTermsRoute(
navigateToHome = navigateToHome,
onShowErrorSnackBar = onShowErrorSnackBar,
args = args,
)
}
}
coposable의 파라미터 내부의 type map에 초기화 시켜주면 이렇게 간단하게 사용이 가능하다.
Parcelable 타입을 받아, 해당 타입의 데이터를 안전하게 처리하는 NavTypeinline fun <reified T : Parcelable> parcelableNavType(): NavType<T> = object : NavType<T>(
isNullableAllowed = false,
) {
override fun get(bundle: Bundle, key: String): T? =
bundle.parcelable(key)
override fun parseValue(value: String): T =
Json.decodeFromString(Uri.decode(value))
override fun serializeAsValue(value: T): String =
Uri.encode(Json.encodeToString(value))
override fun put(bundle: Bundle, key: String, value: T) {
bundle.putParcelable(key, value)
}
}
inline fun <reified T : Parcelable> Bundle.parcelable(key: String): T? = when {
SDK_INT >= 33 -> getParcelable(key, T::class.java)
else -> @Suppress("DEPRECATION")
getParcelable(key) as? T
}
제너릭 타입을 인자로 받아 Parcelable의 타입의 객체들을 받을 수 있도록 코드를 수정하였다.
get(bundle: Bundle, key: String): T?
Bundle에서 Parcelable 객체를 가져오는 역할을 한다.bundle.parcelable(key)를 사용해, key에 해당하는 Parcelable 객체를 Bundle에서 가져옴.key는 네비게이션 경로에서 네비게이션 경로에서 지정한 인자의 이름.역할: 네비게이션을 통해 전달된 Parcelable 데이터를 Bundle에서 추출.
parseValue(value: String): T
T 타입의 객체로 변환하는 역할을 한다.Uri.decode(value)로 URI 형식으로 인코딩된 문자열을 디코딩하고, 그 후 Json.decodeFromString()을 사용해 문자열을 T 타입으로 역직렬화(객체로 변환)한다.역할: 네비게이션 경로에서 전달된 문자열을 Parcelable 객체로 변환.
serializeAsValue(value: T): String
T 타입의 객체를 문자열로 변환하는 역할을 한다.Json.encodeToString(value)를 사용해 T 객체를 JSON 문자열로 직렬화 하고, 그 결과를 Uri.encode()로 URI 안전한 문자열로 인코딩.역할: Parcelable 객체를 안전하게 네비게이션 경로에서 전달할 수 있도록 문자열로 직렬화.
put(bundle: Bundle, key: String, value: T)
Bundle에 Parcelable 객체를 저장하는 역할.bundle.putParcelable(key, value)를 통해, key로 value를 저장.역할: Parcelable 데이터를 Bundle에 저장하여, 다른 컴포넌트에서 사용할 수 있도록 전달.
get: Bundle에서 데이터를 꺼내는 역할.parseValue: 문자열을 Parcelable 객체로 변환하는 역할.serializeAsValue: Parcelable 객체를 문자열로 변환하는 역할.put: Parcelable 객체를 Bundle에 저장하는 역할.최종적으로는 아래와 같이 사용하면 된다!
fun NavGraphBuilder.agreeTermsNavGraph(
navigateToHome: () -> Unit,
onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
) {
composable<Route.AgreeTerms>(
typeMap = mapOf(
typeOf<MemberInfoEditModel>() to parcelableNavType<MemberInfoEditModel>(),
),
) { backStackEntry ->
val args = backStackEntry.toRoute<Route.AgreeTerms>()
AgreeTermsRoute(
navigateToHome = navigateToHome,
onShowErrorSnackBar = onShowErrorSnackBar,
args = args,
)
}
}
type map의 key값에는 경로의 argument에 들어갈 data class를 정의하면 되고 value에는 앞서 만들어 놓은 함수를 사용하면 된다.
해당 pr에 적용을 해놓았으니 참고하면 좋을 듯 하다!
처음에 여러 인자들을 어떻게 넘겨야 할지 고민이 많았다.
여러 시도를 했었다
최종적으로 지금과 같은 방법을 선택 하였다. 아직 해당 버전의 Navigation이 stable되지 않았어서 자료 찾는데 애로사항이 있었다…
https://github.com/Team-Wable/WABLE-ANDROID/pull/92
How to Pass Custom NavTypes in Compose Type-Safe Navigation - Android Studio Tutorial
Kotlin DSL 및 Navigation Compose의 유형 안전성 | Android Developers