Navigation Compose에서 argument로 Bundle을 사용하기

navigation-compose 에서 과연 Bundle을 통해 객체를 넘겨줄 수 있을까? 결론부터 말하자면 ‘Yes’이다.

Navigation Compose에서 argument로 Bundle을 사용하기

커뮤니티에 올라온 한 질문에 대한 답변을 정리하며, 괜찮은 방법일지 피드백을 구하고자 글을 작성하게 되었다. 질문을 간략히 하자면 navigation-compose 에서 과연 Bundle을 통해 객체를 넘겨줄 방법이 있을까?

4일 전 올라온 질문

결론부터 말하자면 ‘Yes’이다. 내가 찾은 방법을 소개하기 전, 이런 저런 다양한 context를 함께 공유 해보고자 한다.

firstName, lastName을 갖는 User라는 객체를 넘기는 예제

구글의 의도는 무엇이었을까?

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 정도의 식별자를 전달하고 있다.)

안드로이드 앱을 개발 시 보통 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시 객체를 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 객체를 넘기는 예제

잘 된다는 스포(?)

첫 화면에서 firstName, lastName을 입력 받고 버튼을 눌렀을 때 결과 페이지로 객체를 전달하는 예제로 가설을 검증해보자.

먼저, Userclass를 선언했다. 객체를 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) 
  } 
}
Nested Navigation도 잘 될까?

마치며

이 이야기는 2년 전 compose가 정식 출시된 지 얼마 안되었을 때 커뮤니티에 한번 공유한 적이 있다. 그러나 당시엔 이런 이슈를 겪을만큼 compose를 활용하는 팀이나 개발자 분들이 안계셔서 나만의 꿀팁(?), 딥다이브(?) 정도로 담아두고 있던 주제였다. 사실 질문이 올라왔을 때

createRoute 함수 알려주고, 결과값 hashCode를 id로 사용하시면 됩니다.

라고 간단히 답장할 수 있었지만 생각난 김에 정리했다. (또한, 내부 코드 구현을 보면 많은 것을 알 수 있다는 구체적인 예시를 보여주고 싶었다😅)

앞서 언급했듯이, 구글이 의도한 동작이 아니므로 문제가 있어도 어떤 지원을 기대하기도 어려울 것 이다. 구글이 navigation-common 설계와 구현을 잘해뒀다면 크게 문제는 없을 것이라 생각하지만 Nested navigation, Deep link 등등 세세하게 들어가면 생각한대로 동작하지 않는 부분이 있을 것 같다.

개발자 경험이 좋을지 확신은 없지만, Safe args plugin의 compose로의 확장도 가능하지 않을까 생각하며 글을 마친다.

Full code

GitHub - workspace/navigation-compose-with-id: Example about using id as parameter for navigation compose
Example about using id as parameter for navigation compose - workspace/navigation-compose-with-id

Jetpack Compose 사용자 모임 링크

Jetpack Compose 사용자 모임 | 홀릭스(HOLIX)
이미 Production에서 사용중인 개발자와 새로 공부하는 뉴비까지, 안드로이드의 새로운 UI 프레임워크에 대한 최신 정보를 얻으세요!