서두에 첨부한 샘플 예제 레포 코드 를 참고
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 를 전달하는 방법에 대해 알아보도록 하겠다.
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 = "",
)
Custom Data Class
>List<student>: List<Student
data class Student(
val studentId: Int,
val studentName: String,
val grade: Int,
)
이번 글에서 다룰 내용은 샘플 예제 레포지토리로 만들어두었습니다. 하단의 링크를 통해 확인할 수 있습니다.
https://github.com/easyhooon/NavigationCustomDataClassArgumentExample
빠르게 넘어가도록 하겠다.
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,
)
}
}
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
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." }
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<>
함수는 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<> 를 사용하는 방식은 그렇지 않다.
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 값이 반환 될 수 없음을 보장할 수 있다.
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
코드 깔끔! 진짜 끝!
글을 작성하는데 도움을 준 정근이한테 감사.
그리고 잘못된 내용을 지적해주신 재성님 감사합니다!
Type Safe Naivgation 을 적용할 경우, 기존 Compose Navigation 에서 발생하였던 url 관련 에러가 발생하지 않을 것이라 생각하였는데, 여전히 발생하였다.
그렇기에 기존의 해결 방법처럼 data class 내에 포함된 String 타입의 프로퍼티가 url 형태일 경우, 다음과 같이 encode, decode 과정을 추가하여 에러를 해결할 수 있다.
@Stable
@Serializable
data class Album(
val id: String,
val name: String,
@Serializable(with = UriSerializer::class)
val thumbnail: String,
val imageCount: Int,
)
object UriSerializer : KSerializer<String> {
override val descriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: String) {
encoder.encodeString(URLEncoder.encode(value, StandardCharsets.UTF_8.name()))
}
override fun deserialize(decoder: Decoder): String {
return URLDecoder.decode(decoder.decodeString(), StandardCharsets.UTF_8.name())
}
}
참고)
https://developer.android.com/guide/navigation/design/type-safety?hl=ko
안녕하세요 지훈님! 블로그 글 잘 읽었습니다
혹시 전달 할 객체에 Parcelize Serializable 어노테이션 모두 붙여서 사용하신 이유가 있으실까요?