본 문서는 Build a graph programmatically using the Kotlin DSL의 내용을 기반으로 작성되었습니다.
이번 포스팅에서는 Android에서 사용할 수 있는 Fragment Navigation Kotlin DSL에 대해 소개하겠습니다.
Jetpack Compose가 나오고 Jetpack Compose Navigation이 등장하면서 Fragment로 작성된 Navigation이 사용할 일이 점점 줄고 있습니다.
하지만 제가 근무하는 Grip에서도 마찬가지고 다른 프로젝트에서도 비슷한 문제를 겪을 것 같은데요 이미 Fragment에 DataBinding or ViewBinding으로 작성된 코드가 너무나 많고 Compose를 도입한다고 해도 Compose Navigation을 제대로 활용할 수 없는 구조로 되어있어 Fragment에 ComposeView를 사용하는 경우도 적지 않습니다.
Jetpack Compose가 등장하기 전까진, 구글에서 Single Activity + Fragment Navigation을 사용할 것을 권장했습니다.
그래서 아래와 같은 구조를 가진 프로젝트가 꽤 많을 것입니다.
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/main_nav" />
</FrameLayout>
MainActivity에 FragmentContainerView를 두고 xml로 구현된 main_nav.xml을 정의합니다.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_notification_settings_graph"
app:startDestination="@id/mainFragment">
<fragment
android:id="@+id/mainFragment"
android:name="com.sample.android.MainFragment"
android:label="MainFragment">
<action
android:id="@+id/action_to_homeFragment"
app:destination="@id/homeFragment />
<argument
android:name="id"
app:argType="string"
app:nullable="true" />
<deepLink app:uri="navigate://main" />
<fragment
android:id="@+id/homeFragment"
android:name="com.sample.android.HomeFragment"
android:label="HomeFragment">
...
</navigation>
위처럼 Navigation Graph를 XML로 작성하고, 특정 Fragment로 이동하는 Action을 정의하거나, Argument를 정의하고 deepLink를 정의하는 것들을 모두 XML에서 사용하고 있었습니다. + SafeArgs Plugin
KotlinDSL을 사용하면 Navigation Graph에 대한 XML을 사용하지 않고 Kotlin코드로 Navigation Graph를 정의하고 사용할 수 있습니다.
일단 기본적으로 FragmentContainerView는 필요합니다.
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true" />
</FrameLayout>
아까와 다른점은 뭘까요? app:navGraph로 xml로 정의된 graph를 선언하는 부분이 없어졌습니다.
그리고 KotlinDSL을 사용할 경우, 각 Fragment에 대한 고유 식별자로 사용되는 route값이 필요합니다.
일단 업데이트 되기 전 구글 문서에는 아래처럼 route를 정의하고 있습니다.
object nav_routes {
const val home = "home"
const val plant_detail = "plant_detail"
}
object nav_arguments {
const val plant_id = "plant_id"
const val plant_name = "plant_name"
}
하지만 Compose를 사용해보신 분들이라면 이렇게 String값을 Navigation Destination으로 사용하는 것은 관리가 어렵고 불편하다고 많이 느끼셨을 것이라 생각합니다. 따라서 저는 저번 포스팅에서 소개했던 Type-Safe Navigation 방식을 소개해보겠습니다.
import kotlinx.serialization.Serializable
sealed interface Route {
@Serializable
data object Main: Route
@Serializable
data class Home(val id: String) : Route
}
원하는 모듈(일반적으로 많이 사용하는 구조에서는 :core:navigation 모듈이겠네요), 또는 패키지에 위와같이 NavigationDestination이 될 수 있는 Route를 정의합니다.
그리고 findNavController(viewId: Int) 메소드로 navController객체를 가져옵니다.
// in Activity
val navController = findNavController(R.id.nav_host_fragment)
// in Fragment
val navController = Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
NavController객체로 Kotlin으로 그래프를 정의할 수 있습니다.
navController.graph = navController.createGraph(Route.Main) {
fragment<MainFragment, Route.Main>()
fragment<HomeFragment, Route.Home>()
}
뭔가 Compose Navigation이랑 비슷한 느낌인데요, 이전에 비해 엄청 편해졌습니다.
현재 업데이트 되기 전 구글 문서에 정의된 예시코드를 한번 볼까요?
val navController = findNavController(R.id.nav_host_fragment)
navController.graph = navController.createGraph(
startDestination = nav_routes.home
) {
fragment<HomeFragment>(nav_routes.home) {
label = resources.getString(R.string.home_title)
}
fragment<PlantDetailFragment>("${nav_routes.plant_detail}/{${nav_arguments.plant_id}}") {
label = resources.getString(R.string.plant_detail_title)
argument(nav_arguments.plant_id) {
type = NavType.StringType
}
}
}
Destination을 String으로 정의하고 argument에 한번 더 String값을 정의해야 하는 번거로움이 있고 SafeArgument가 지원되지 않아 arguments.getString() 과 같은 방법으로 argument를 관리해야 하므로 책임은 온전히 개발자의 몫이었습니다.
그리고 xml방식에서 정의하는 argument, action 등을 더이상 정의하지 않아도 됩니다. 단지 Destination으로 설정한 Route 객체를 잘 관리하면 되겠네요.
그럼 실제로navigate 하는 코드를 보겠습니다.
findNavController().navigate(Route.Main)
findNavController().navigate(Route.Home(id = "testId"))
navigate 안에 미리 정의했던 Destination인 Route 객체를 사용하기만 하면 됩니다.
그럼 새롭게 추가된 navigate() 메소드를 한번 보겠습니다.
/**
* Navigate to a route from an Object in the current NavGraph. If an invalid route is given, an
* [IllegalArgumentException] will be thrown.
*
* The target NavDestination must have been created with route from a [KClass]
*
* If given [NavOptions] pass in [NavOptions.restoreState] `true`, any args passed here as part
* of the route will be overridden by the restored args.
*
* @param route route from an Object for the destination
* @param navOptions special options for this navigation operation
* @param navigatorExtras extras to pass to the [Navigator]
* @throws IllegalArgumentException if the given route is invalid
*/
@MainThread
@JvmOverloads
public fun <T : Any> navigate(
route: T,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
val finalRoute = generateRouteFilled(route)
navigate(
NavDeepLinkRequest.Builder.fromUri(createRoute(finalRoute).toUri()).build(),
navOptions,
navigatorExtras
)
}
// Finds destination within _graph including its children and
// generates a route filled with args based on the serializable object.
// Throws if destination with `route` is not found
@OptIn(InternalSerializationApi::class)
private fun <T : Any> generateRouteFilled(route: T): String {
val id = route::class.serializer().generateHashCode()
val destination = graph.findDestinationComprehensive(id, true)
// throw immediately if destination is not found within the graph
requireNotNull(destination) {
"Destination with route ${route::class.simpleName} cannot be found " +
"in navigation graph $_graph"
}
return generateRouteWithArgs(
route,
// get argument typeMap
destination.arguments.mapValues { it.value.type }
)
}
KotlinSerialization을 이용하여 id 를 구하고 이 id로 지정된 Destination이 있는지 찾고 있습니다. 모든 코드를 볼 수는 없지만 내부에는 SparseArray형태로 NavDestination객체를 관리하고 있습니다.

어쨌든 Destination을 찾아서 generateRouteWithArgs 메소드로 최종 String값을 생성합니다.
/**
* Generates a route filled in with argument value for use in Navigation functions such as
* [::navigate] from a destination instance of type T.
*
* The generated route pattern contains the path, path args, and query args. See
* [RouteBuilder.computeParamType] for logic on how parameter type (path or query) is computed.
*/
@OptIn(InternalSerializationApi::class)
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun <T : Any> generateRouteWithArgs(route: T, typeMap: Map<String, NavType<Any?>>): Strin**텍스트**g {
val serializer = route::class.serializer()
val argMap: Map<String, List<String>> = RouteEncoder(serializer, typeMap).encodeToArgMap(route)
val builder = RouteBuilder(serializer)
serializer.forEachIndexed(typeMap) { index, argName, navType ->
val value = argMap[argName]!!
builder.appendArg(index, argName, navType, value)
}
return builder.build()
}
결국 내부에서는 String으로 Route를 관리하고, 개발자가 신경쓸 필요 없도록 여러 확장함수를 제공하도록 구현한 것이고 그 방법으로 Kotlin Serialization을 활용하고 있다고 할 수 있겠네요
다시 돌아가서 navigate()를 보겠습니다.
val finalRoute = generateRouteFilled(route)
navigate(
NavDeepLinkRequest.Builder.fromUri(createRoute(finalRoute).toUri()).build(),
navOptions,
navigatorExtras
)
다른건 다 알겠는데.. NavDeepLinkRequest? 이건 xml에서 <deepLink app:uri="navigate://main" />로 deepLink navigation을 사용할때 쓰던 방법인데.. 기본으로 NavDeepLinkRequest를 사용하고 있네요?
참고로 저는 Navigation best practices for multi-module projects 문서 처럼 멀티모듈에서 Navigation을 사용하고 있었습니다.
기존 Navigation DeepLink는 아래와 같은 상황에서 사용했습니다.

:feature:home 모듈의 HomeFragment 에서 :feature:settings의 SettingsFragment 로 Navigation을 하고 싶지만 nav_graph는 app모듈에 정의되어 있고 :feature:home모듈은 :feature:settings 모듈에 대한 의존성이 없어 Navigation할 방법이 없습니다.
이런 상황에서 <deepLink app:uri="navigate://main" />와 같이 그래프에 deepLink를 정의하고 아래처럼 사용하면 navigation을 사용할 수 있었습니다.
val request = NavDeepLinkRequest.Builder
.fromUri("navigate://main".toUri())
.build()
findNavController().navigate(request)
따라서 navigate://main과 같은 deepLink String 값을 navigation 모듈에 정의하고, 그 String값을 사용하고 있었습니다.
결국 이번 Navigation alpha-08에 추가된 Type Safe Navigation은 기존에 그래프에 정의하는 DeepLink방식을 기본으로 활용하고 있네요
이외에도 Destination에 Primitive Type이 아닌 Custom Type을 가지고 있다면 저번 포스팅에서 소개했던 NavType 객체를 통해 타입을 직렬화하는 방법을 직접 정의하여 사용하는 것도 동일하게 사용 가능합니다.
NavDeepLinkDslBuilder를 사용하는 deepLink메소드를 이용하여 deepLink Path도 직접 정의할 수 있습니다 구글 공식문서 에서 소개하는 Create a deep link for a destination 를 아래처럼 대체할 수 있겠네요
fragment<MainFragment, Route.Main> {
deepLink {
uriPattern = "www.example.com"
action = "android.intent.action.MY_ACTION"
mimeType = "type/subtype"
}
}
이부분이 Compose에서는 잘 소개되어 있는데요, Fragment를 사용하는 경우에는 소개가 안되어 있습니다.
우선 아래 상황을 가정해보겠습니다.
MainFragment -> HomeFragment로 Navigate하는 상황에서 HomeFragment는 Destination으로 위에 정의한 @Serializable data class Home(val id: String): Route를 사용하고 있습니다.
Compose에서 SafeArgs로 값을 어떻게 받는지 보겠습니다.
composable<Route.Home> { navBackStackEntry ->
val args = navBackStackEntry.toRoute<Route.Home>()
HomeScreen(...)
}
NavBackStackEntry에서 toRoute()라는 확장함수를 제공하고 있습니다. 한번 볼까요?
/**
* Returns route as an object of type [T]
*
* Extrapolates arguments from [NavBackStackEntry.arguments] and recreates object [T]
*
* @param [T] the entry's [NavDestination.route] as a [KClass]
*
* @return A new instance of this entry's [NavDestination.route] as an object of type [T]
*/
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)
}
다시 현재 상황으로 돌아와서, NavBackStackEntry만 있으면 Fragment Navigation Kotlin DSL에서도 동일하게 사용할 수 있을것 같네요
currentBackStackEntry는 NavController 객체가 가지고 있습니다.
직접 해보니 currentBackStackEntry 대신 getBackStackEntry()를 사용해야 하네요
class HomeFragment: Fragment() {
private val args: Route.Home by lazy {
findNavController().getBackstackEntry<Route.Home>().toRoute()
}
이렇게 사용하면 멀티모듈에서 객체를 argument로 넘길 수 있으며, Navigation의 목적지가 되는 Fragment에서도 arguments?.getXX()와 같은 메소드를 사용하지 않아도 됩니다
한가지 의문점이 있었는데요, Destination으로 객체를 사용하면 arguments.getXX()와 ViewModel에서 주입받은 SavedStateHandle객체에서는 이 값을 꺼내는 것이 불가능한가?에 대해서 입니다.
이에대한 정답은 위를 자세히 읽어보셨다면 알 수 있는데요, 결국 내부에서 String값으로 관리하기 때문에 똑같습니다.
대신 argument에 대한 Key를 Destination객체의 parameter name을 사용합니다.
따라서 Route.Home이 id라는 이름으로 parameter를 가지고 있으므로
당연히 아래와 같이 사용할 수도 있습니다.
// in Fragment
private val id: String? = arguments.getString("id")
// in ViewModel + SavedStateHandle
private val id: String = requireNotNull(savedStateHandle.get<String>("id"))