Compose에서 객체를 넘길때 보통 route에 경로와 '{}'를 통해 argument의 키 값을 지정하고 데이터를 넘긴다.
composable(
route = "$TALK_ROUTE/{id}",
arguments = listOf(
navArgument("id") {
type = NavType.LongType
nullable = false
}
)
)
그런데 간혹 프리미티브 타입이 아닌 사용자가 정의한 객체를 넘겨야 할때가 있다.
회사 프로젝트에서 보통 id 등 원시타입으로 받은 뒤, API로 호출했는데 이번엔 그냥 객체를 넘기라고하여 구현해야 됐다.
ViewSystem에선 Bundle에 Parcelable, serialized 등으로 설정 후 넘겼는데 Compose에선 어떻게 넘길까
우선 Type-safety하게 navigation을 구현한다면 간단할 것 같다.
locationsNavGraph(
navigateToMap = { (origin, destination) ->
navController.navigate(
MainRoute.Map(origin, destination)
)
},
onShowErrorSnackBar = onShowErrorSnackBar
)
-----------------
fun NavGraphBuilder.mapNavGraph(
popBackStack: () -> Unit,
onShowErrorSnackBar: (message: String) -> Unit,
) {
composable<MainRoute.Map> { navBackStackEntry ->
MapRoute(
path = with(navBackStackEntry.toRoute<MainRoute.Map>()) { origin to destination },
popBackStack = popBackStack,
onShowErrorSnackBar = onShowErrorSnackBar
)
}
}
Compose Navigation 2.8.0 부터 지원하는데
하지만 내가 맡은 프로젝트는 2.7.7 버전을 사용하고 있으므로 지원 되지 않는다
결국 직렬화와 역직렬화를 직접 해야한다.
ViewSystem 처럼 Parcelable 혹은 Serializable로 보낼 수 있을 것 같다
compose의 argument의 타입은 결국 bundle이기 때문에 Compose에서도 지원하거나 지원하지 않는다고 해도 값을 넣은 Bundle을 보내면 될 것이라고 생각했다.
아니면 compose navigation 처럼 Kotlin-Serialization을 사용하거나 SharedViewModel을 사용하는 방법도 가능 할 것 같다.
우선 어떤 방식을 택할지 정해야한다.
단순히 객체하나를 옮기기 위해서 뷰모델을 생성해야 된다는게 불 필요하다 생각들었다.
공유 하는 데이터가 많으면 모를까, 수명주기 등 고려해야할게 많으므로 사용하지 않기로 했다.
Serializable은 Java의 인터페이스다.
동작원리는 리플렉션을 통해 객체를 생성한뒤 그 에 맞춰서 직렬화 역직렬화를 지원한다.
굉장히 편하지만 리플렉션을 사용하기 때문에 느리다
Parcelable 같은 경우 안드로이드 SDK로 빠른 성능을 보인다.
그러나 이에 관해선 말이 많은데
Serializable의 직렬화 역직렬화 프로세스를 사용자가 정의한다면 오히려 Serializable이 더 빠르고, 그렇기에 비교가 잘못되었다는 말이 있다.
그런데 kotlin-parcelize를 사용하면, 구현 편하게 Parcelable의 성능을 챙길 수 있으므로 Parcelize로 정했다.
고민이 많이 된 부분이다.
Compose Navigation 2.8.0 버전에서 추가된 Type-safety는 Kotlin-Serialization을 사용한다.
@MainThread
@JvmOverloads
public fun <T : Any> navigate(
route: T,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
navigate(generateRouteFilled(route), navOptions, navigatorExtras)
}
코드를 살펴보면 이렇게 구성되어 있는데
generateRouteFilled를 제외하면 기존의 navigate를 사용하고 있는걸 볼 수 있다.
그렇담 타입이 추가되면서 generateRouteFilled안에 그와 관련된 데이터 처리 로직을 볼 수 있을 것 이다.
@OptIn(InternalSerializationApi::class)
private fun <T : Any> generateRouteFilled(route: T): String {
val id = route::class.serializer().generateHashCode()
val destination = graph.findDestinationComprehensive(id, true)
// 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 generateRouteWithArgs(
route,
// get argument typeMap
destination.arguments.mapValues { it.value.type }
)
}
코드는 이렇게 구성되어있었다.
.serializer()는 kotlin-serializer의 함수이다.
살펴 보면 KSerializer를 반환하는 것을 볼 수 있는데, 위에선 그 값의 hashCode를 가져온다.
destination은 navigation그래프에서 목적지를 가져오는것으로 유추 할 수 있다.
저 2가지의 내부 코드 까진 중요하지 않아보인다.
return을 보면 generateRouteWithArgs라는 함수가 보인다.
route와 args를 합친것으로 볼 수 있는데 이부분이 중요해 보인다.
@OptIn(InternalSerializationApi::class)
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun <T : Any> generateRouteWithArgs(route: T, typeMap: Map<String, NavType<Any?>>): String {
val serializer = route::class.serializer()
val argMap: Map<String, List<String>> = RouteEncoder(serializer, typeMap).encodeToArgMap(route)
val builder = RouteBuilder(serializer)
serializer.forEachIndexed(typeMap) { index, argName, navType ->
val value = argMap[argName]!!
builder.appendArg(index, argName, navType, value)
}
return builder.build()
}
안을 보면 RouteEncoder를 통해 argument의 map을 만들었다.
RouteEncoder는 Encoder를 상속받는 class로 Encoder는 Kotlin-serialization의 인터페이스다.
즉 저기서 직렬화를 해서 Json형식으로 저장하는 원리다.
반면 Parcelable은 바이너리 형태로 저장하고 읽는다.
성능면에서 좀 더 유리하다판단 된다.
그렇담 Parcelize는 컴포즈에서 어떻게 사용할 수 있을까
compose의 navigate함수를 살펴보면
@MainThread
@JvmOverloads
public fun navigate(
route: String,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
navigate(
NavDeepLinkRequest.Builder.fromUri(createRoute(route).toUri()).build(), navOptions,
navigatorExtras
)
}
다음과 같이 구성되어 있는걸 찾아 볼 수 있다.
Bundle은 어디있을까? 더 들어가보자
@MainThread
public open fun navigate(
@IdRes resId: Int,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
) {
var finalNavOptions = navOptions
val currentNode = (
if (backQueue.isEmpty())
_graph
else
backQueue.last().destination
) ?: throw IllegalStateException(
"No current destination found. Ensure a navigation graph has been set for " +
"NavController $this."
)
@IdRes
var destId = resId
네비게이션 함수를 뒤적이다 보면 다음과 같은 코드를 발견 할 수 있다.
여기서 IdRes는 Destination의 ID라는 걸 유추 할 수 있다.
args에서 Bundle을 받는게 보이는데, 이 곳에 Pacelabe한 객체를 보내면 될 것 이다.
@Parcelize
data class TestUi(
val a: Int,
val b: Int,
val c: Int,
val id: Long,
) : Parcelable
보낼 데이터 클래스에 Parcelable을 상속한다
navigateToTalk: (TestUi) -> Unit
-----------------------------------------------------------
composable(
route = HOME_ROUTE,
) {
HomeRoute(
popBackStack = navController.popBackStack,
navigateToTalk = {data ->
navController.navigate(
"$TALK_ROUTE?key=",
Bundle().apply { putParcelable("data", data) })
}
)
}
그런데 코드를 보면 String이 아닌 IdRes를 받아서 Route도 변환을 해줘야한다.
id라는건 Route를 구별하기위한 id이다.
그렇기 때문에 NavGraph에서 Route를 찾아서 id를 구하면 된다.
composable(
route = HOME_ROUTE,
) {
HomeRoute(
popBackStack = navController.popBackStack,
navigateToTalk = {data ->
navController.navigate(
navController.graph.findNode("$TALK_ROUTE?key=")?.id,
Bundle().apply { putParcelable("data", data) })
}
)
이제 받아오는건 어떻게 할까?
우선 코틀린에선 composable 스코프에선 NavBackStackEntry를 반환한다.
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = null,
exitTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = null,
popEnterTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
enterTransition,
popExitTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
exitTransition,
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {
이 백스택엔트리에선 argument를 반환하는데 여기서 Parcelable로 받으면 될 것 같다고 생각했다.
composable(
route = "$TALK_ROUTE?key={data}",
) { backStackEntry ->
val talk = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
backStackEntry.arguments?.getParcelable("data", TalkUi::class.java)
} else {
backStackEntry.arguments?.getParcelable("data")
}
talk?.let {
TalkRoute(
talk = it,
popBackStack = navController.popBackStack,
)
} ?: navController.popBackStack()
}
버전 분기해서 받아주면 데이터를 반환하는 것을 볼 수 있다.
다양한 방법이 생갔났는데 Parcelize를 사용하는게 효율적이라 생각했다.
그런데 2.8.0에선 Parcelize가 아닌 Kotlin-serialization으로 구현하는 이유가 뭘까
String으로 변환하고 다시 역질렬하는 과정이 Parcelize를 보내는 것에 비해 불필요해보였다.
그부분에 대해선 좀 더 고민해 봐야할 것 같다
코틀린과 컴포즈를 사용하면서 굉장히 중요한걸 잊고있었다.
멀티 플랫폼 환경을 생각했어야했다.
Parcelable은 안드로이드 프레임워크이기 때문에 안드로이드와 강한 결합을 가진다.
그렇기 때문에 멀티플랫폼환경에선 좋지 않다.
Kotlin과 Compose는 멀티플랫폼 환경을 고려해서 구현되기 때문에 kotlin-serialization을 택한 것
사실 Json으로 직렬화 역직렬화하는건 큰 데이터를 옮기는 작업을 하는 것도 아니라면 Parcelable과 차이를 느끼기도 힘들긴하다.
큰 데이터를 그냥 Bundle로 넘기는 것도 이상하고