Navigation Compose에서 argument로 Bundle을 사용하기
navigation-compose 에서 과연 Bundle을 통해 객체를 넘겨줄 수 있을까? 결론부터 말하자면 ‘Yes’이다.
![Navigation Compose에서 argument로 Bundle을 사용하기](/content/images/size/w1200/format/webp/2024/04/1-ibibonsw4u5mt_mblvgarg.png)
Navigation Compose에서 Bundle 사용하기
커뮤니티에 올라온 한 질문에 대한 답변을 정리하며, 괜찮은 방법일지 피드백을 구하고자 글을 작성하게 되었다. 질문을 간략히 하자면 navigation-compose
에서 과연 Bundle을 통해 객체를 넘겨줄 방법이 있을까?
![](https://kmpedia.dev/content/images/2024/04/1-lgd_rxmedki-8rx2xk-xaw.png)
결론부터 말하자면 ‘Yes’이다. 내가 찾은 방법을 소개하기 전, 이런 저런 다양한 context를 함께 공유 해보고자 한다.
![](https://kmpedia.dev/content/images/2024/04/1-7ek_c3lpzbpvaukqxjnmmw.gif)
구글의 의도는 무엇이었을까?
navigation-compose
에서는 route라는 String으로
- 화면 특정하기
- argument 넘기기
등 모든 것을 담고자 한다. route의 역할 중 argument 전달도 있다보니 route를 이용하는 navigate 함수에는 argument라는 인자가 의도적으로 빠져있어 Bundle
을 사용할 수 없게 되었다.
// compose
fun navigate(
route: String,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
)
// fragment
fun navigate(
@IdRes resId: Int,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
)
왜 이런 차이를 가지게 되었는지는 공식 문서의 설명을 보면 알 수 있는데 복잡한 data를 navigation시 넘기지 말고 최소한의 식별자만 넘기도록 의도(거의 강제…)한 것이라 볼 수 있다.
It is strongly advised not to pass around complex data objects when navigating, but instead pass the minimum necessary information, such as a unique identifier or other form of ID, as arguments when performing navigation actions:
- Retrieving complex data when navigating
(나는 구글의 가이드를 따른 것은 아니지만 구글과 같은 방식으로 id 정도의 식별자를 전달하고 있다.)
Navigation Compose, Route — 불편했던 첫 인상
안드로이드 앱을 개발 시 보통 Jetpack Navigation Component를 채택하게 된다. 나 또한 기존 앱에서 navigation-fragment
를 사용해왔고, 자연스럽게 navigation-compose
까지 채택하게 되었다. 아마 나처럼 예전 라이브러리의 사용 경험이 있는 사람이라면 navigation-compose
를 접하게 되었을 때 이상함과 불편함을 느끼는 부분이 많았을 것이다. (이 글과 거의 99% 일치)
Route
질문에도 있듯이, Activity/Fragment 전환 시 안드로이드에선 Bundle
을 사용하는 것은 익숙한 일이다. 이를 대체하고자 한 새로운 개념이 route
인데 단일 문자열에 모든 걸 구겨 넣어야하는 것은 썩 좋아보이지 않았다.
문서의 패턴만 봐도 route는 uri의 path와 query와 같은 형태라는 것을 유추할 수 있는데, android-app://androidx.navigation/
이라는 prefix와 합쳐진 Uri
가 되어 deep link와 같이 동작하도록 구현되어있다. 그래서 required parameter는 Uri
의 path, optional parameter는 Uri
의 query와 같은 꼴을 하고 있다.
fun navigate(
route: String,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
navigate(
NavDeepLinkRequest.Builder.fromUri(createRoute(route).toUri()).build(), navOptions,
navigatorExtras
)
}
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun createRoute(route: String?): String =
if (route != null) "android-app://androidx.navigation/$route" else ""
여기서 개발자가 직접 들여다 보지 않으면 / 해보지 않으면 놓칠 수 있는 부분이 있는데, Uri
의 일부가 되기 때문에 route는 Uri
의 path, query로서의 spec도 준수해야한다는 것이다. 이런 내용은 공식 문서에 나와있지 않기 때문에 관련하여 문제를 한번 쯤 겪었을 확률이 높다.
대표적으론 argument로 url string을 넘기는 경우 크래시가 난다는 것이 있었다. url에는 다양한 특수문자가 포함되어있고, 따라서 이를 바로 활용하면 당연히 문제가 생긴다. 이 경우, UrlEncoder/Decoder를 주고/받는 쪽에서 사용해야 url을 문제없이 넘겨받을 수 있다.
개발자 경험
다음으로는 개발자 경험이다. navigation이란 것에 대한 모델링(navigation-common
) 자체는 공유하기 때문에 이를 잘 이해하고 compose에서는 변경된 ‘문법’을 잘 사용하면 된다고 생각했다.
그러나, 기존에 navigation-fragment
에서 Safe args plugin을 통해 생성된 action, args등을 사용하며 받았던 type safety 관련 도움이 사라지니 navigation 관련 변경이 있을 때 실수를 정말 많이 하게 되었다.
<fragment android:id="@+id/myFragment" >
<argument
android:name="myArg"
app:argType="integer"
android:defaultValue="0" />
</fragment>
override fun onClick(v: View) {
val amount: Float = ...
val action =
SpecifyAmountFragmentDirections
.actionSpecifyAmountFragmentToConfirmationFragment(amount)
v.findNavController().navigate(action)
}
위는 기존 navigation-fragment
에서 argument를 선언하고, action을 통해 navigate를 시키는 공식 홈페이지 예제 코드인데 개인적으론 더 명확하고 직관적인 것 같다.
있었는데, 사라졌다.😕
앞서 언급한 것들은 불편할 뿐이지 어떻게든 사용할 수는 있었다. 하지만 정말 큰 불편은 바로 ‘있었다가 사라진 것들’이었다.
- Safe args plugin
- 화면 전환 애니메이션
- navigate시 객체 넘기기
Safe args plugin이 사라진 것은 모두에게 큰 실수를 유발한 변화였다. Type safety를 해결한 가장 빠른 방법은 plugin이 작성해주던 코드와 같은 역할을 하는 Destination, Argument등 명세를 직접 작성하는 것이었다. 그 다음엔 직접 손으로 작성하는 번거로움을 해결하기 위한 compose-destinations이라는 ksp 오픈 소스가 등장했다. 또한, compose-navigation-reimagined와 같이 아예 직접 작성한 라이브러리도 등장하게 되었다.
다음으로는 화면 전환 애니메이션을 들 수 있다. 상황에 따라 아래에서 위로 올라오든, 오른쪽에서 왼쪽으로 들어오든 화면 전환 효과가 사용자 경험에 중요하다고 판단되는 경우가 있는데 navigation-compose
로 달성할 수 없었다. 그나마 google이 관리하는 오픈소스인 accompanist
에서 navigation-animation
이라는 것을 제공하여 문제를 해결할 수 있었다. 최근 이 라이브러리 구현의 안정성을 어느 정도 검증했다고 판단했는지 해당 구현을 내부로 가져와 navigation-compose
2.7.0부터 화면 전환 효과를 사용할 수 있게 되었다.
마지막으로 이 글의 주제인 ‘navigate시 객체 넘기기’이다. 구글의 의도가 어쨌든 화면 전환을 할 때 상황에 따라 많은 데이터를 넘길 필요성이 있을 수 있는데 익숙한 방식을 사용할 수 없게 아예 배제한 선택에 대한 다른 분들의 의견이 궁금하다.
Navigate시 객체 넘기기
알려진 방법 : navigate시 객체를 String으로 변환하여 전달
앞서 설명한 것과 같이 navigation-compose
에서는 route로 모든 것을 하고자 하며, Bundle
을 활용할 수 없다. 그렇다보니 compose 출시 초기에는 navigate시 객체를 json string으로 변환 후 전달해 destination에서 객체로 변환하는 방법이 제안되었다.
이 글에 따르면 compose 1.0.3, navigation 2.4.0-alpha10 버전에서 custom NavType
를 만들 수 있게 되어 좀 더 나은 방법이 가능해졌다고 한다.
나의 제안: navigate(id, args), Compose에서도 쓸 수 있다⚠️
그럼에도 불구하고 나는 저 해결책이 맘에 들진 않았다. 결국 uri의 query parameter로 json string을 보내는게 동작은 하지만 썩 자연스럽지 않아보였다.
compose에서는 꼭 navigate(route)만을 사용해야할까?
라는 질문과 함께
navigate(id, args)를 사용할 수 없을까?
라는 생각을 하고 내부 구현을 까보기 시작했다.
Compose의 문법으로 정의된 NavDestination
의 id는 무엇일까? NavDestination
의 내부 구현에서 쉽게 알 수 있었다.
// NavDestination.kt
public var route: String? = null
set(route) {
if (route == null) {
id = 0
} else {
require(route.isNotBlank()) { "Cannot have an empty route" }
val internalRoute = createRoute(route)
id = internalRoute.hashCode()
...
}
...
field = route
}
...
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun createRoute(route: String?): String =
if (route != null) "android-app://androidx.navigation/$route" else ""
보다시피 id에는 createRoute(route)
의 결과인 internalRoute
의 hashcode값이 할당 되는 것을 확인할 수 있다.
아래는 route와 id를 통한 navigate 코드인데 결론적으로 route나 id는 navigate하고자 하는 NavDestination
을 찾는데 활용된 뒤, 찾아진 NavDestination
으로 private navigate function을 호출하는 것을 확인할 수 있었다.
// NavController.kt
// Route(DeepLinkRequest)를 통한 navigate
fun navigate(
route: String,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
navigate(
NavDeepLinkRequest.Builder
.fromUri(createRoute(route).toUri()).build(),
navOptions,
navigatorExtras
)
}
fun navigate(
request: NavDeepLinkRequest,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
) {
val deepLinkMatch = _graph!!.matchDeepLink(request)
if (deepLinkMatch != null) {
val destination = deepLinkMatch.destination // NavDestination
val args = destination.addInDefaultArgs(deepLinkMatch.matchingArgs) ?: Bundle()
val node = deepLinkMatch.destination
...
navigate(node, args, navOptions, navigatorExtras)
}
...
}
// id를 통한 navigate
fun navigate(
@IdRes resId: Int,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
) {
...
val node = findDestination(destId) // NavDestination
...
navigate(node, combinedArgs, finalNavOptions, navigatorExtras)
}
// 최종적으로 불리는 navigate 함수
private fun navigate(
node: NavDestination,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
) {
...
}
다만, 구체적으로 보면 navOptions에 따라 backstack을 처리하는 로직이 달라 100% 의도가 같은 일이 일어날 것인지는 확인해봐야한다.
즉, Compose에서도 id, args를 통한 navigate가 가능할 수 있다는 것이다. 실제로 잘 동작할 지 간단한 예제를 통해 확인해보자.
성, 이름을 가지는 User 객체를 넘기는 예제
![](https://kmpedia.dev/content/images/2024/04/1-hf6yc3vl9qdfazgo82quha.png)
첫 화면에서 firstName, lastName을 입력 받고 버튼을 눌렀을 때 결과 페이지로 객체를 전달하는 예제로 가설을 검증해보자.
먼저, User
class를 선언했다. 객체를 Bundle
을 통해 넘겨주기 위해 kotlin-parcelize
plugin을 프로젝트에 추가했다.
@Parcelize
data class User(
val firstName: String,
val lastName: String
) : Parcelable
다음은 Destination
정의인데, 앞서 언급한 createRoute
함수가 라이브러리 그룹 내에서만 public으로 정의되어있어 해당 구현을 그대로 가져왔다.
sealed class Destination(val route: String) {
// 잠재적 리스크가 될 수 있기에 만약 이 방법을 차용하고자 한다면
// navigation 라이브러리 버전을 올릴 때 항상 주의해야한다.
val id get() = "android-app://androidx.navigation/$route".hashCode()
data object Home : Destination("home")
data object Result : Destination("result")
}
UI는 간단하니 스킵하고, Parcelable
은 아래와 같이 전달하고 얻어올 수 있었다.🎉
// 시작 화면
Button(
onClick = {
navController.navigate(
resId = Destination.Result.id, // internalRoute.hashCode()
args = bundleOf(
"user" to User(firstName, lastName) // Parcelable
)
)
}
) {
Text("Navigate with route id")
}
// 결과 화면
val args = entry.arguments ?: Bundle()
val user = BundleCompat.getParcelable(args, "user", User::class.java)
if (user != null) {
Column(
modifier = Modifier.padding(it)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = user.toString())
Text(text = user.firstName)
Text(text = user.lastName)
}
}
![](https://kmpedia.dev/content/images/2024/04/1-7ek_c3lpzbpvaukqxjnmmw-1.gif)
마치며
이 이야기는 2년 전 compose가 정식 출시된 지 얼마 안되었을 때 커뮤니티에 한번 공유한 적이 있다. 그러나 당시엔 이런 이슈를 겪을만큼 compose를 활용하는 팀이나 개발자 분들이 안계셔서 나만의 꿀팁(?), 딥다이브(?) 정도로 담아두고 있던 주제였다. 사실 질문이 올라왔을 때
createRoute
함수 알려주고, 결과값 hashCode를 id로 사용하시면 됩니다.
라고 간단히 답장할 수 있었지만 생각난 김에 정리했다. (또한, 내부 코드 구현을 보면 많은 것을 알 수 있다는 구체적인 예시를 보여주고 싶었다😅)
앞서 언급했듯이, 구글이 의도한 동작이 아니므로 문제가 있어도 어떤 지원을 기대하기도 어려울 것 이다. 구글이 navigation-common
설계와 구현을 잘해뒀다면 크게 문제는 없을 것이라 생각하지만 Nested navigation, Deep link 등등 세세하게 들어가면 생각한대로 동작하지 않는 부분이 있을 것 같다.
개발자 경험이 좋을지 확신은 없지만, Safe args plugin의 compose로의 확장도 가능하지 않을까 생각하며 글을 마친다.
Full code
Jetpack Compose 사용자 모임 링크
![](https://storage.educast.com/image/880cac39-b5fe-44c9-b2ed-0d7f3969b311/1001.png)