[Android/Compose] Type safe navigation에서 Custom data class를 넘기는 법

차누·2024년 10월 14일
post-thumbnail

[Android/Compose] Type safe navigation에서 Custom data class를 넘기는 법

컴포즈에서도 Navigation 2.8.0 부터 type safety한 navigation을 사용 할 수 있게 되었다.

컴포즈 네비게이션과 기존 xml 방식과의 차이는 kotlin dsl을 활용하여 Navigation graph를 구축한다는 점이다.

또한 컴파일 방식에 구축했던 그래프가 런타임에 생성되는 방식으로 변경되었다.

그러나 Navigation의 경우

하지만 이전 버전의 불편한 점은 route와 argment를 결합해서 경로에 보내야하는 단점이 있다.

이전 버전 Navigation

String-based Routes: 경로(route)를 문자열로 정의하여 화면 간 이동을 할 수 있었다.

NavHost(navController, startDestination = "home") {
    composable("home") { HomeScreen() }
    composable("profile/{userId}") { backStackEntry ->
        ProfileScreen(userId = backStackEntry.arguments?.getString("userId"))
    }
}

여기서 각 화면 간에 userId와 같은 값을 전달할 때는 경로 문자열을 통해 데이터를 전달했습니다. 즉, "profile/{userId}"와 같은 방식으로 파라미터를 경로에 포함시켜 데이터를 전달했다. 하지만 이 방식은 다음과 같은 문제점이 있다.

하지만 이 방식은 다음과 같은 문제가 있다.

  • 타입 안전성 부족: 문자열 기반의 경로이므로 오타나 잘못된 형식으로 인한 오류가 런타임에만 감지. ex) "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로 인코딩 한 후 받는 쪽에서 다시 해당 문자열을 역 직렬화 하여 객체화를 시켜야 했다. 이는 복잡성이 매우 높았다.

Type safety Navigation


위의 이유로 인해 개발자들에게 있어 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>: 클래스와 같은 인수를 전달할 필요가 없는 경우 사용합니다. 매개변수가 없는 클래스 또는 모든 매개변수에 기본값이 있는 클래스
    1. 예: 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을 담아 넘기는 쪽으로 리팩토링 하였다.


Custom Nav Type 정의


@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



Custom Nav Type 사용하기


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 타입을 받아, 해당 타입의 데이터를 안전하게 처리하는 NavType


inline 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의 타입의 객체들을 받을 수 있도록 코드를 수정하였다.


각 메소드의 역할

  1. get(bundle: Bundle, key: String): T?

    • 이 메소드는 Bundle에서 Parcelable 객체를 가져오는 역할을 한다.
    • bundle.parcelable(key)를 사용해, key에 해당하는 Parcelable 객체를 Bundle에서 가져옴.
    • 여기서의 key는 네비게이션 경로에서 네비게이션 경로에서 지정한 인자의 이름.

    역할: 네비게이션을 통해 전달된 Parcelable 데이터를 Bundle에서 추출.

  2. parseValue(value: String): T

    • 문자열 형태로 전달된 데이터를 T 타입의 객체로 변환하는 역할을 한다.
    • 먼저 Uri.decode(value)로 URI 형식으로 인코딩된 문자열을 디코딩하고, 그 후 Json.decodeFromString()을 사용해 문자열을 T 타입으로 역직렬화(객체로 변환)한다.

    역할: 네비게이션 경로에서 전달된 문자열을 Parcelable 객체로 변환.

  3. serializeAsValue(value: T): String

    • T 타입의 객체를 문자열로 변환하는 역할을 한다.
    • Json.encodeToString(value)를 사용해 T 객체를 JSON 문자열로 직렬화 하고, 그 결과를 Uri.encode()로 URI 안전한 문자열로 인코딩.

    역할: Parcelable 객체를 안전하게 네비게이션 경로에서 전달할 수 있도록 문자열로 직렬화.

  4. put(bundle: Bundle, key: String, value: T)

    • 이 메서드는 BundleParcelable 객체를 저장하는 역할.
    • bundle.putParcelable(key, value)를 통해, keyvalue를 저장.

    역할: 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에 적용을 해놓았으니 참고하면 좋을 듯 하다!


느낀점


처음에 여러 인자들을 어떻게 넘겨야 할지 고민이 많았다.
여러 시도를 했었다

  1. SharedViewModel : 멀티 모듈구조이고 각 feature모듈들이 Navigator가 정의된 main모듈을 의존하지 않아 main → feature 구조, 이기에 사용이 불가했다.
  2. 싱글톤 객체 : 온보딩은 최초 회원가입시에만 나타나기에 싱글톤 패턴은 비효율적이라 판단. 자동로그인 된 유저는 불필요한 필들이 힙 메모리에 할당이 되기 때문.
  3. List + enum class : enum class로 각 index와 들어갈 type을 정의하고 List를 전달 했지만 최종적으로 사용해야하는 model을 List와 달리 초기화 해야한다는 점에 비효율적으로 판단

최종적으로 지금과 같은 방법을 선택 하였다. 아직 해당 버전의 Navigation이 stable되지 않았어서 자료 찾는데 애로사항이 있었다…

실제 적용한 PR 링크

https://github.com/Team-Wable/WABLE-ANDROID/pull/92

Reference

How to Pass Custom NavTypes in Compose Type-Safe Navigation - Android Studio Tutorial

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

[Android/Compose] Jetpack Navigation 사용해보기 (기초)

profile
android를 공부하고 정리합니다

0개의 댓글