Compose Type Safe Navigation Custom Data Class를 Argument로 전달하는 방법

이지훈·2024년 10월 13일
0

Navigation

목록 보기
4/5

TL;DR

서두에 첨부한 샘플 예제 레포 코드 를 참고

서두

Type-Safe Compose Navigation 이 androidx-navigation 2.8.0-alpha08 버전에 드디어 등장 하게되었다.

또한, 기존의 Route 방식의 Compose Navigation 의 경우 정석적인 방법으로는 primitive 한 데이터만을 argument에 담아 전달할 수 있었는데, 이제는 Custom Data Class argument 에 담아 전달하는 것이 가능하다!

논외로 Route 방식의 Compose Navigation 에서 argument 로 객체를 전달하는 방법은 아래 블로그 글의 방법으로 가능합니다.
https://kmpedia.dev/use-bundle-as-navigation-compose-argument/

따라서 이번 글에서는 Compose Type Safe Navigation 을 통해, 다음의 4가지 타입의 Argument 를 전달하는 방법에 대해 알아보도록 하겠다.

  1. primitive type
    lectureName: String
    강의 제목

  2. List<primitive type>
    List<studentGradeList>: List<Int>
    강의를 수강 학생들의 학년

  3. Custom Data Class
    lecture: Lecture
    강의

data class Lecture(
    val lectureId: Int = 0,
    val lectureName: String = "",
    val professor: String = "",
)
  1. List<Custom Data Class>
    List<student>: List<Student
    강의를 수강하는 학생 목록
data class Student(
    val studentId: Int,
    val studentName: String,
    val grade: Int,
)

이번 글에서 다룰 내용은 샘플 예제 레포지토리로 만들어두었습니다. 하단의 링크를 통해 확인할 수 있습니다.
https://github.com/easyhooon/NavigationCustomDataClassArgumentExample

본론

1. Primitive type

빠르게 넘어가도록 하겠다.

import kotlinx.serialization.Serializable

sealed interface Route {
	@Serializable
    data object Home : Route

    @Serializable
    data class Detail(
        val lectureName: String,
    ) : Route
}

위와 같이 Navigation 을 통해 이동하는 화면들을 정의하는 Route 파일 내에 Detail 화면으로 이동할때 전달해야할 파라미터를 Detail data class 에 추가하고, navigate 함수에 해당 argument 를 추가해주면 된다.

fun NavController.navigateToDetail(
    lectureName: String,
) {
    navigate(Route.Detail(lectureName))
}
fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail> {
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
        )
    }
}

왜 backStackEntry 로 argument 를 받지않나요?

이전 글에서도 설명했듯, HiltViewModel 을 사용하는 경우, savedStateHandle 을 뷰모델에 주입하여, Detail 화면의 뷰모델로 전달되는 데이터를 바로 받을 수 있기 때문에, 이러한 방식을 선호한다. (전달해야할 값이 많을 경우, Route Composable 의 parameter 도 그만큼 많아지는 것도 단점이라고 할 수 있다.)

이 방법이 가능한 이유는 하단의 링크를 통해 설명해두었습니다.
Compose Navigation Argument를 ViewModel 내의 SavedStateHandle로 전달받을 수 있는 이유

@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    companion object {
        private const val LECTURE_NAME = "lectureName"
    }

    private val name: String =
        requireNotNull(savedStateHandle.get<String>(LECTURE_NAME)) { "lectureName is required." }

savedStateHandle.toRoute<T> 를 사용하는 방식은 프로젝트에 적용을 해본 뒤, 글에 추가해보도록 하겠습니다.
https://github.com/easyhooon/NavigationCustomDataClassArgumentExample/issues/1
-> 글 하단에 내용을 추가하였습니다.

toRoute() 함수를 통해서 아래와 같이 전달받을 수 있다.

public inline fun <reified T> NavBackStackEntry.toRoute(): T {
    val bundle = arguments ?: Bundle()
    val typeMap = destination.arguments.mapValues { it.value.type }
    return serializer<T>().decodeArguments(bundle, typeMap)
}

fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail>(
        typeMap = Route.Detail.typeMap,
    ) { backStackEntry ->
        val args = backStackEntry.toRoute<Route.Detail>()
        val lectureName = args.lectureName
        val studentGradeList = args.studentGradeList
        val lecture = args.lecture
        val studentList = args.studentList
        
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
            lectureName = lectureName,
            studentGradeList = studentGradeList,
            lecture = lecture,
            studentList = studentList,
        )
    }
}

2. List<Primitive type>

import kotlinx.serialization.Serializable

@Serializable
data class Detail(
    val studentGradeList: List<Int>,
) : Route
import kotlinx.serialization.Serializable

sealed interface Route {
    @Serializable
    data object Home : Route

    @Serializable
    data class Detail(
        val lectureName: String,
        val studentGradeList: List<Int>,
    ) : Route
}
fun NavController.navigateToDetail(
    lectureName: String,
    studentGradeList: List<Int>,
) {
    navigate(Route.Detail(lectureName, studentGradeList))
}

fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail> {
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
        )
    }
}
@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    companion object {
        private const val LECTURE_NAME = "lectureName"
        private const val STUDENT_GRADE_LIST = "studentGradeList"
    }

    private val name: String =
        requireNotNull(savedStateHandle.get<String>(LECTURE_NAME)) { "lectureName is required." }

    // Array<Int> 로 받으면 java.lang.ClassCastException: int[] cannot be cast to java.lang.Integer[] 에러 발생
    private val studentGradeList: List<Int> =
        requireNotNull(savedStateHandle.get<IntArray>(STUDENT_GRADE_LIST)?.toList()) { "studentGradeList is required." }

위의 방법과 똑같이 적용해주면 되어서 설명은 생략.

Kotlin 의 IntArray 와 Array<Int> 타입의 차이는 아래의 글을 통해 확인할 수 있습니다.
https://stackoverflow.com/questions/45090808/intarray-vs-arrayint-in-kotlin

3. Custom Data Class

import kotlinx.serialization.Serializable

@Serializable
data class Detail(
    val lecture: Lecture,
) : Route

@Serializable
data class Lecture(
    val lectureId: Int,
    val lectureName: String,
    val professor: String,
)

본 게임 시작
위와 마찬가지로 Route 내에 Detail data class 에 lecture parameter 를 추가하는 것으로 시작

import kotlinx.serialization.Serializable

sealed interface Route {
    @Serializable
    data object Home : Route

    @Serializable
    data class Detail(
        val lectureName: String,
        val studentGradeList: List<Int>,
        val lecture: Lecture,
    ) : Route
}

@Serializable
data class Lecture(
    val lectureId: Int,
    val lectureName: String,
    val professor: String,
)

또한, 추가적으로 정의해줘야할 것이 있다.

import android.os.Bundle
import androidx.navigation.NavType
import com.yijihun.navigationcustomdataclassargumentexample.model.Lecture
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

val LectureType = object : NavType<Lecture>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Lecture? {
        return bundle.getString(key)?.let { Json.decodeFromString(it) }
    }

    override fun parseValue(value: String): Lecture {
        return Json.decodeFromString(value)
    }

    override fun put(bundle: Bundle, key: String, value: Lecture) {
        bundle.putString(key, Json.encodeToString(Lecture.serializer(), value))
    }

    override fun serializeAsValue(value: Lecture): String {
        return Json.encodeToString(Lecture.serializer(), value)
    }
}

바로 Lecture 라는 argument 의 NavType 을 정의해줘야 한다는 것인데,
abstract class 인 NavType 내에 정의된 abstract fun 를 구현해주는 작업이 필요하다. (@Serializabe 어노에티션이 붙은 이유이기도 하다.)

코드를 통해 알 수 있든, Lecture data class 를 직렬화하여 Json 문자열로 bundle 에 저장(put), bundle 에 저장된 Json 문자열을 역직렬화를 통해 다시 Lecture data class 로 변환하여 return(get) 하는 함수들이다.

직렬화, 역직렬화는 Kotlinx Serialization 을 통해 구현하였다.
(참고로 Type Safe Navigation 역시 내부적으로 Kotlinx Serialization 을 사용한다.)

NavType 을 정의하지 않고 앱을 실행하면 다음과 같은 에러가 발생하며 앱이 죽는 것을 확인할 수 있다.
java.lang.IllegalArgumentException: Route.Detail could not find any NavType for argument lecture of type model.Lecture - typeMap received was {}

fun NavController.navigateToDetail(
    lectureName: String,
    studentGradeList: List<Int>,
    lecture: Lecture,
    studentList: List<Student>,
) {
    navigate(Route.Detail(lectureName, studentGradeList, lecture))
}

fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail>(
        typeMap = mapOf(typeOf<Lecture>() to LectureType),
    ) {
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
        )
    }
}
@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    companion object {
        private const val LECTURE_NAME = "lectureName"
        private const val STUDENT_GRADE_LIST = "studentGradeList"
        private const val LECTURE = "lecture"
    }

    private val _uiState = MutableStateFlow(DetailUiState())
    val uiState: StateFlow<DetailUiState> = _uiState.asStateFlow()

    private val name: String =
        requireNotNull(savedStateHandle.get<String>(LECTURE_NAME)) { "lectureName is required." }

    // Array<Int> 로 받으면 java.lang.ClassCastException: int[] cannot be cast to java.lang.Integer[] 에러 발생
    private val studentGradeList: List<Int> =
        requireNotNull(savedStateHandle.get<IntArray>(STUDENT_GRADE_LIST)?.toList()) { "studentGradeList is required." }

    private val lecture: Lecture =
        requireNotNull(
            savedStateHandle.get<String>(LECTURE)?.let { string ->
                Json.decodeFromString<Lecture>(string)
            },
        ) { "lecture is required." }

4. List<Custom Data Class>

import kotlinx.serialization.Serializable

sealed interface Route {
   @Serializable
   data object Home : Route

   @Serializable
   data class Detail(
       val lectureName: String,
       val studentGradeList: List<Int>,
       val lecture: Lecture,
       val studentList: List<Student>,
   ) : Route
}

@Serializable
data class Student(
   val studentId: Int,
   val studentName: String,
   val grade: Int,
)

3번의 방식을 숙지했다면, 이와 같게 방식으로, List<Student> 에 대한 NavType 을 정의 해주면 된다.

import android.os.Bundle
import androidx.navigation.NavType
import com.yijihun.navigationcustomdataclassargumentexample.model.Lecture
import com.yijihun.navigationcustomdataclassargumentexample.model.Student
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json

val StudentListType = object : NavType<List<Student>>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): List<Student>? {
        return bundle.getStringArray(key)?.map { Json.decodeFromString<Student>(it) }
    }

    override fun parseValue(value: String): List<Student> {
        return Json.decodeFromString(ListSerializer(Student.serializer()), value)
    }

    override fun put(bundle: Bundle, key: String, value: List<Student>) {
        val serializedList = value.map { Json.encodeToString(Student.serializer(), it) }.toTypedArray()
        bundle.putStringArray(key, serializedList)
    }

    override fun serializeAsValue(value: List<Student>): String {
        return Json.encodeToString(ListSerializer(Student.serializer()), value)
    }
}

다만 bundle 의 특성상, List 타입의 대한 저장을 지원하지 않기 때문에, Array 타입으로 변환해주는 추가 작업을 필요로 한다.

bundle 이 기본적으로 어떤 타입을 지원하는지는 공식문서의 bundle 클래스가 가지고 있는 함수를 통해 확인할 수 있습니다.
https://developer.android.com/reference/android/os/Bundle

fun NavController.navigateToDetail(
    lectureName: String,
    studentGradeList: List<Int>,
    lecture: Lecture,
    studentList: List<Student>,
) {
    navigate(Route.Detail(lectureName, studentGradeList, lecture, studentList))
}

fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail>(
        typeMap = mapOf(
            typeOf<Lecture>() to LectureType,
            typeOf<List<Student>>() to StudentListType,
        ),
    ) {
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
        )
    }
}
@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    companion object {
        private const val LECTURE_NAME = "lectureName"
        private const val STUDENT_GRADE_LIST = "studentGradeList"
        private const val LECTURE = "lecture"
        private const val STUDENT_LIST = "studentList"
    }

    private val _uiState = MutableStateFlow(DetailUiState())
    val uiState: StateFlow<DetailUiState> = _uiState.asStateFlow()

    private val name: String =
        requireNotNull(savedStateHandle.get<String>(LECTURE_NAME)) { "lectureName is required." }

    // Array<Int> 로 받으면 java.lang.ClassCastException: int[] cannot be cast to java.lang.Integer[] 에러 발생
    private val studentGradeList: List<Int> =
        requireNotNull(savedStateHandle.get<IntArray>(STUDENT_GRADE_LIST)?.toList()) { "studentGradeList is required." }

    private val lecture: Lecture =
        requireNotNull(
            savedStateHandle.get<String>(LECTURE)?.let { string ->
                Json.decodeFromString<Lecture>(string)
            },
        ) { "lecture is required." }

    private val studentList: List<Student> =
        requireNotNull(
            savedStateHandle.get<Array<String>>(STUDENT_LIST)?.let { array ->
                array.map { Json.decodeFromString<Student>(it) }
            },
        ) { "student is required." }

결과

강의 목록 화면)

강의 상세 화면)
하나의 강의를 선택하면 해당 강의의 상세 화면으로 이동한다

성공적으로 화면이 이동되어, 4가지 타입의 argument 를 전달 받아, 화면에 매핑된 것을 확인할 수 있다.

이제 정말 fragment-navigation 이 부럽지 않을 정도로 발전이 된 것 같다는 생각이 든다.
(bottomSheet 이동만 지원해준다면...!)

끝!

내용 추가) savedStateHandle.toRoute<> 사용

savedStateHandle.toRoute<> 함수는 Compose Navigation 이 Type Safe 를 지원하기 시작하면서 등장한 함수이다.

이를 사용하면, 위에서 구현했던 것 처럼, savedStateHandle 에 key, value 형식으로 접근하여, 값을 가져올 필요 없이, 객체 내에 프로퍼티에 접근하는 방식으로 값을 가져올 수 있다.

// Before
private val name: String =
        requireNotNull(savedStateHandle.get<String>(LECTURE_NAME)) { "lectureName is required." }

// After
private val name: String = savedStateHandle.toRoute<Detail>().lectureName

이러한 변화를 통해 얻을 수 있는 장점은 2가지가 있다.

1. nullable -> null safety

앞선 key value 방식은 bundle 을 다루는 익숙한 방식이지만, 안전한 방법은 아니다.
문자열 하드 코딩을 하지 않고, companion object ~ const val 상수화 하여, 이를 key에 대입해주긴 했어도, 어쨌든 그 key 에 연결된 value 가 없다면 null 이 반환된다. 그래서 nullable 하다.(생각해보면 key 값에 아무 값이나 넣어줄 수 있으니, 당연한 얘기이다.)

하지만 toRoute<> 를 사용하는 방식은 그렇지 않다.

import kotlinx.serialization.Serializable

@Serializable
data class Detail(
    val lectureName: String,
    val studentGradeList: List<Int>,
    val lecture: Lecture,
    val studentList: List<Student>,
) : Route {

Route 내에 정의된 Detail data class 내에 변수에 접근하여 가져오는 것이기에, null 값이 반환 될 수 없음을 보장할 수 있다.

2. short code

1번에서 파생된 장점으로, nullable 변수에 대한 예외처리(require 구문 또는 방어 코드), Key 로 사용할 상수를 따로 정의할 필요가 없는 점으로 인해, 작성해야 할 코드가 짧아진다.

그렇다면 이를 코드에 적용해보고 실행을 해보도록 하자.

private val name: String = savedStateHandle.toRoute<Detail>().lectureName

private val studentGradeList: List<Int> = savedStateHandle.toRoute<Detail>().studentGradeList

private val lecture: Lecture = savedStateHandle.toRoute<Detail>().lecture

private val studentList: List<Student> = savedStateHandle.toRoute<Detail>().studentList

문제 발생

runtime 에 Detail 화면으로 화면 전환시 앱이 죽어버린다...

java.lang.IllegalArgumentException:  
Route.Detail could not find any NavType for argument lecture of type model.Lecture - typeMap received was {}

???
NavType 을 찾을 수 없다고?, 전달받은 typMap 이 없다고?...정의했는데?

우선 lecture 에서 문제가 발생한다는 것을 알 수 있었기에,
lecture, List<student> 는 주석 처리하고, lectureName, List<studentGradeList> 만 전달 했을 때는 정상적으로 실행되는 것을 확인 할 수 있었다.
문제는 Custom Data Class 쪽인가보다.

문제 해결

savedStateHandle.toRoute<> 함수의 내부 구현을 확인해보면 다음과 같이 구성되어 있음을 확인할 수 있다.

그렇다. savedStateHandle.toRoute<> 함수에 typeMap 을 argument 로 전달하지 않으면, default 값인 emptyMap 이 들어가, internalToRoute() 함수 내에서 그 emptyMap 을 기반으로 navArguement 들을 generate 후 decode 하여 반환하게 된다. (에러 메세지가 맞는 말만 했다.)

DetailViewModel

private val typeMap = mapOf(
    typeOf<Lecture>() to LectureType,
    typeOf<List<Student>>() to StudentListType,
)

private val name: String = savedStateHandle.toRoute<Detail>(typeMap).lectureName

private val studentGradeList: List<Int> = savedStateHandle.toRoute<Detail>(typeMap).studentGradeList

private val lecture: Lecture = savedStateHandle.toRoute<Detail>(typeMap).lecture

private val studentList: List<Student> = savedStateHandle.toRoute<Detail>(typeMap).studentList

DetailViewModel 내에 typeMap을 선언하고 이를 argument 로 넣어줌으로써, Detail 화면으로 전환 시 발생했던 문제를 해결할 수 있었다.

개선

똑같은 코드가 위에 있었는데...? 굳이 중복하여 typeMap 을 정의할 필요가 있을까...?

그렇다 이미 사용된 적이 있던 코드이다.

fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail>(
        // 여기!
        typeMap = mapOf(
            typeOf<Lecture>() to LectureType,
            typeOf<List<Student>>() to StudentListType,
        )
    ) {
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
        )
    }
}

사실 2~3번 반복까지는 강박적으로 공통 함수로 빼거나, 모듈화 할 필요는 없다고 생각하지만, 이를 한 곳으로 모으는 방향으로 개선해보고자 한다.

내가 선택한 위치는 Route 내에 Detail data class 이다.

import kotlinx.serialization.Serializable
import com.yijihun.navigationcustomdataclassargumentexample.model.Lecture
import com.yijihun.navigationcustomdataclassargumentexample.model.Student
import kotlin.reflect.typeOf

@Serializable
data class Detail(
    val lectureName: String,
    val studentGradeList: List<Int>,
    val lecture: Lecture,
    val studentList: List<Student>,
) : Route {
    companion object {
        val typeMap = mapOf(
            typeOf<Lecture>() to LectureType,
            typeOf<List<Student>>() to StudentListType,
        )
    }
}

해당 typeMap 을 사용하는 곳들이 전부 Detail 화면과 관련된 곳이기도 하고, companion object 내에 선언하였기에, 생성되는 모든 Detail 객체는 처음 클래스가 로드될 때 초기화된, 같은 typeMap 을 참조하게 된다.

결과

DetailNavigation

fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail>(
        typeMap = Route.Detail.typeMap,
    ) {
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
        )
    }
}

DetailViewModel


private val name: String = savedStateHandle.toRoute<Detail>(Detail.typeMap).lectureName

private val studentGradeList: List<Int> = savedStateHandle.toRoute<Detail>(Detail.typeMap).studentGradeList

private val lecture: Lecture = savedStateHandle.toRoute<Detail>(Detail.typeMap).lecture

private val studentList: List<Student> = savedStateHandle.toRoute<Detail>(Detail.typeMap).studentList

코드 깔끔! 진짜 끝!

P.S
글을 작성하는데 도움을 준 정근이한테 감사.
그리고 잘못된 내용을 지적해주신 재성님 감사합니다!

참고)
https://developer.android.com/guide/navigation/design/type-safety?hl=ko

https://ioannisanif.medium.com/jetpack-compose-navigation-embracing-type-safety-and-simplifying-parcelable-handling-17c67f60da71

https://everyday-develop-myself.tistory.com/361

https://everyday-develop-myself.tistory.com/m/365

profile
실력은 고통의 총합이다. Android Developer

6개의 댓글

comment-user-thumbnail
2024년 11월 15일

안녕하세요 지훈님! 블로그 글 잘 읽었습니다
혹시 전달 할 객체에 Parcelize Serializable 어노테이션 모두 붙여서 사용하신 이유가 있으실까요?

2개의 답글
comment-user-thumbnail
2024년 11월 28일

감사합니다!

1개의 답글