서두에 첨부한 샘플 예제 레포 코드 를 참고
Type-Safe Compose Navigation 이 androidx-navigation 2.8.0-alpha08 버전에 드디어 등장 하게되었다.
또한, 기존의 Route 방식의 Compose Navigation 의 경우 정석적인 방법으로는 primitive 한 데이터만을 argument에 담아 전달할 수 있었는데, 이제는 Custom Data Class(Parcelable 객체를) argument 에 담아 전달하는 것이 가능하다!
논외로 Route 방식의 Compose Navigation 에서 argument 로 객체를 전달하는 방법은 아래 블로그 글의 방법으로 가능합니다.
https://kmpedia.dev/use-bundle-as-navigation-compose-argument/
따라서 이번 글에서는 Compose Type Safe Navigation 을 통해, 다음의 4가지 타입의 Argument 를 전달하는 방법에 대해 알아보도록 하겠다.
primitive type
lectureName: String
강의 제목
List<primitive type
>
List<studentGradeList>: List<Int>
강의를 수강 학생들의 학년
Custom Data Class
lecture: Lecture
강의
data class Lecture(
val lectureId: Int = 0,
val lectureName: String = "",
val professor: String = "",
) : Parcelable
Custom Data Class
>List<student>: List<Student
data class Student(
val studentId: Int,
val studentName: String,
val grade: Int,
) : Parcelable
이번 글에서 다룰 내용은 샘플 예제 레포지토리로 만들어두었습니다. 하단의 링크를 통해 확인할 수 있습니다.
https://github.com/easyhooon/NavigationParcelableArgumentExample
빠르게 넘어가도록 하겠다.
sealed interface Route {
@Serializable
data object Home : Route
@Serializable
data class Detail(
val lectureName: String,
) : Route
}
위와 같이 Navigation 을 통해 이동하는 화면들을 정의하는 Route 파일 내에 Detail 화면으로 이동할때 전달해야할 파라미터를 Detail data class 에 추가하고, navigate 함수에 해당 인자를 추가해주면 된다.
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 로 인자를 받지않나요?
이전 글에서도 설명했듯, HiltViewModel 을 사용하는 경우, savedStateHandle 을 뷰모델에 주입하여, Detail 화면의 뷰모델로 전달되는 데이터를 바로 받을 수 있기 때문에, 이러한 방식을 선호한다.
이 방법이 가능한 이유는 하단의 링크를 통해 설명해두었습니다.
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/NavigationParcelableArgumentExample/issues/1
-> 글 하단에 내용을 추가하였습니다.
Primitive type
>@Serializable
data class Detail(
val studentGradeList: List<Int>,
) : Route
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
@Serializable
@Parcelize
data class Detail(
val lecture: Lecture,
) : Route, Parcelable
@Parcelize
@Serializable
data class Lecture(
val lectureId: Int,
val lectureName: String,
val professor: String,
) : Parcelable
본 게임 시작
위와 마찬가지로 Route 내에 Detail data class 에 해당 인자를 추가하는 것으로 시작
sealed interface Route {
@Serializable
data object Home : Route
@Serializable
@Parcelize
data class Detail(
val lectureName: String,
val studentGradeList: List<Int>,
val lecture: Lecture,
) : Route, Parcelable
}
@Parcelize
@Serializable
data class Lecture(
val lectureId: Int,
val lectureName: String,
val professor: String,
) : Parcelable
다만, 코드에서도 확인할 수 있듯, 차이점으로는 별도로 정의된 Custom Data class 를 넘기는 것이기 때문에, fragment navigation 에서도 그랬듯 Pacelable 어노테이션을 추가해줘야 한다.
또한, 추가적으로 정의해줘야할 것이 있다.
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 라는 인자의 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 com.yijihun.navigationparcelableargumentexample.Route.Detail could not find any NavType for argument lecture of type com.yijihun.navigationparcelableargumentexample.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." }
Custom Data Class
>sealed interface Route {
@Serializable
data object Home : Route
@Serializable
@Parcelize
data class Detail(
val lectureName: String,
val studentGradeList: List<Int>,
val lecture: Lecture,
val studentList: List<Student>,
) : Route, Parcelable
}
@Parcelize
@Serializable
data class Student(
val studentId: Int,
val studentName: String,
val grade: Int,
) : Parcelable
3번의 방식을 숙지했다면, 이와 같게 방식으로, List<Student
> 에 대한 NavType 을 정의 해주면 된다.
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가지 타입의 인자를 받아, 화면에 매핑된 것을 확인할 수 있다.
이제 정말 fragment-navigation 이 부럽지 않을 정도로 발전이 된 것 같다는 생각이 든다.
(bottomSheet 이동만 지원해준다면...!)
끝!
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가지가 있다.
앞선 key value 방식은 bundle 을 다루는 익숙한 방식이지만, 안전한 방법은 아니다.
문자열 하드 코딩을 하지 않고, companion object ~ const val 상수화 하여, 이를 key에 대입해주긴 했어도, 어쨌든 그 key 에 연결된 value 가 없다면 null 이 반환된다. 그래서 nullable 하다.(생각해보면 key 값에 아무 값이나 넣어줄 수 있으니, 당연한 얘기이다.)
하지만 toRoute<> 를 사용하는 방식은 그렇지 않다.
@Serializable
@Parcelize
data class Detail(
val lectureName: String,
val studentGradeList: List<Int>,
val lecture: Lecture,
val studentList: List<Student>,
) : Route, Parcelable {
Route 내에 정의된 Detail data class 내에 변수에 접근하여 가져오는 것이기에, null 값이 반환 될 수 없음을 보장할 수 있다.
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 을 인자로 전달하지 않으면, 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을 선언하고 이를 인자로 넣어줌으로써, 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 이다.
@Serializable
@Parcelize
data class Detail(
val lectureName: String,
val studentGradeList: List<Int>,
val lecture: Lecture,
val studentList: List<Student>,
) : Route, Parcelable {
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