Android Compose Navigation 타입 안정성 지원

Mangbaam
6 min readMay 5, 2024

--

이런 형태로 제공됩니다!

Navigation 2.8.0-alpha08 버전부터 Compose 내비게이션에 잘 동작하도록 설계된 Kotlin Serializable 기반 타입 안정성 시스템이 도입되었습니다.

단, 알파 버전이기 때문에 최신 버전의 컴포즈, 코틀린 컴파일러 등과 호환성이 좋지 않을 수 있습니다.
아직 실험적인 단계이니 사용에 주의가 필요합니다.

기존 xml 에서도 DSL 을 활용한 Navigation 을 사용할 때 마찬가지로 적용됩니다. 아마 DSL 내비게이션을 사용해보신 분이라면 컴포즈의 내비게이션과 굉장히 유사하다는 것을 느끼셨을 겁니다.

내비게이션 DSL 과 컴포즈 내비게이션을 사용해보신 분이라면 xml 을 사용한 내비게이션에서 쓸 수 있는 Safe Args 를 사용할 수 없다는 점이 가장 큰 불편함이었을 겁니다.

Safe Args 를 사용하지 않았을 때 필수 아규먼트가 누락된 경우 크래시가 발생하기 때문에 이에 대한 책임은 개발자에게 있었습니다. (그래서 저는 Navigation 을 래핑한 클래스를 만들어 최소한 크래시는 발생하지 않도록 하고 로그를 남기는 방식으로 작업해왔습니다)

또 다른 불편함은 route와 아규먼트가 결합된 형태라는 점입니다. 가령 route 가 products이고 productId가 필수 아규먼트 일 때products/{productId}와 같이 작성해야 한다는 점입니다.

이러한 불편함을 해결하기 위해 여러 서드파티가 나오기도 했습니다. (ex. Compose Destinations, Kiwi’s navigation-compose-typed)

구글이 내비게이션을 설계할 때 중요하게 고민하는 점 중 하나는 내비게이션 컴포넌트를 다른 라이브러리로 전환할 때 쉽게 전환할 수 있도록 전염성이 낮은 방향으로 설계하는 것이라고 합니다.

이러한 이유로 테스트 문서에서도 NavController 를 화면(혹은 컴포저블)에 직접 전달하는 대신 파싱된 아규먼트나 콜백 정도로만 넘기라고 가이드하고 있습니다.

그래서 어떻게 바뀐다는 건가요?

Serializable 을 사용한 새로운 내비게이션 정의

홈화면과 프로필 화면으로 이동하는 경우를 예시로 들어보겠습니다.

@Serializable
object Home

@Serializable
data class Profile(val id: String)

아규먼트가 필요 없는 화면은 object로, 아규먼트가 필요한 화면은 data class 로 정의하여 정의합니다.

내비게이션 관련 인터페이스를 구현하거나 어노테이션을 달거나 할 필요 없이 평소에 사용하던 그 object와 data class 가 맞습니다. 대신 화면을 잘 설명할 수 있는 네이밍을 해야겠죠?

그리고 그래프는 다음과 같이 정의합니다.

NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(onNaivateToProfile = { id →
navController.navigate(Profile(id))
})
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(profile)
}
}

정말 깔끔해지지 않았나요? 기존에 route 와 arguments 로 문자열을 넘기던 코드가 사라졌습니다. (심지어 arguments 를 정의하던 코드도 없어졌습니다) object 나 data class 로 정의된 내용을 그대로 사용하기 때문에 훨씬 안정성이 높아진 걸 볼 수 있어요.

기존과 어떻게 바꼈나면…

  • startDestination 에 문자열을 넘기는 대신 타입을 넘깁니다
  • navArgument 를 정의하는 코드가 필요없어지고 아규먼트가 필요한 화면이라면 data class 에 정의된 파라미터로 대체되었습니다
  • route 로 문자열을 넘기는 대신 Serializable 객체를 넘깁니다
  • toRoute()확장함수로 NavBackStackEntry에서 아규먼트를 담은 Profile 객체를 다시 만듭니다. (SavedStateHandle 에도 비슷한 확장함수가 있어 ViewModel 에서도 안전하게 아규먼트를 쉽게 얻을 수 있다고 합니다)

이렇게 변경되어 구글의 내비게이션 설계 원칙과 같이 모든 부분에 영향을 미치는 것이 아니라 점진적으로 마이그레이션 할 수 있도록 설계한 것을 볼 수 있습니다.

popBackStack() 동작

popBackStack()의 구현도 다음과 같이 지원합니다

// 가장 처음 등장하는 Profile 화면까지 pop
navController.popBackStack<Profile>(inclusive = true)

// id가 42인 Profile 화면까지 pop
navController.popBackStack(Profile(42), inclusive = true)

커스텀 타입

개인적으로 이 부분이 가장 강력한 지원이 아닐까 싶습니다.

기존에는 아규먼트로 Int, String 과 같은 프리미티브 타입이나 배열만을 넘길 수 있었지만 이제는 Parcelable 등 커스텀 타입의 NavType 을 정의해서 전달할 수 있습니다.

@Parcelable
data class SearchParameters(
val searchQuery: String,
val filters: List<String>
)

@Serializable
data class Search(
val parameters: SearchParameters
)

val SearchParametersType = object : NavType<SearchParameters>(
isNullableAllowed = false
) {
// NavType을 정의하는 방법은 위에 있는 본문의 링크를 참고하세요
}

// typeMap으로 파라미터 타입을 정의할 수 있습니다
composable<Search>(
typeMap = mapOf(typeOf<SearchParameters>() to SearchParametersType)
) { backStackEntry ->
val searchParameters = backStackEntry.toRoute<Search>().parameters
}

--

--