인텐트 기반 Compose 탐색

Mar 21 2023
Google이 탐색 구성요소에서 Jetpack Compose에 대한 지원을 도입했을 때 제공된 솔루션의 한계가 Android 개발자 커뮤니티에서 빠르게 발견되었습니다. 이 기사에서 집중하고 싶은 것은 대상 간에 더 복잡한 데이터 구조를 보낼 수 없다는 것입니다.

Google이 탐색 구성요소 에서 Jetpack Compose 에 대한 지원을 도입했을 때 제공된 솔루션의 한계가 Android 개발자 커뮤니티에서 빠르게 발견되었습니다. 이 기사에서 집중하고 싶은 것은 대상 간에 더 복잡한 데이터 구조를 보낼 수 없다는 것입니다.

구글의 방식

탐색을 담당하는 라이브러리의 기본 컴포저블은 NavHost입니다. 탐색 구조에 대상을 추가하려면 람다 인수 내에 컴포저블(또는 경고와 같은 다른 구성 요소)을 추가해야 합니다. 이 메서드에는 문자열 형식의 경로와 선택적 탐색 인수 목록이 필요합니다.

NavHost(startDestination = "profile/{userId}") {
    ...
    composable(
        "profile/{userId}",
        arguments = listOf(navArgument("userId") { type = NavType.StringType })
    ) {...}
}

Integer안타깝게도 , 및 와 같은 기본 항목만 Boolean경로 String에서 처리됩니다. 우리의 경우에 필요한 것인 custom 은 Parcelable라이브러리의 일부이지만 이 상황에서는 사용할 수 없습니다.

실제 탐색은 필요한 모든 인수를 포함하는 올바른 형식의 경로 문자열을 사용하여 navController에서 탐색 메서드를 호출하여 수행됩니다.

navController.navigate("profile/user1234")

Google은 탐색 인수에 대한 보다 복잡한 데이터 유형에 대한 지원 부족을 어떻게 설명합니까? 그들은 단순히 id-문자열과 같은 대상 간에 최소한의 필수 정보만 전달되어야 함을 의미하는 단일 진실 원칙을 구현할 것을 제안합니다 . 그런 다음 나머지 필수 데이터는 데이터 계층의 단일 소스에서 가져와야 합니다. 앱 전체에서 데이터 불일치를 방지하기 때문에 이 접근 방식이 바람직한 것은 사실이지만 앱의 현재 탐색 솔루션이 Parcelable 전송을 허용하는 경우 복잡한 앱에서는 달성하기 어렵고 리팩토링이 복잡할 수 있습니다.

일반적으로 사용되는 솔루션

Parcelable을 전송하는 가장 일반적인 솔루션은 원하는 유형을 지원할 사용자 정의 NavType을 생성해야 합니다( 여기 에 설명됨 ). 그것은 설명된 문제를 해결하지만 불행히도 완벽과는 거리가 멉니다. 우선, 일반적으로 JSON 형식을 사용하여 데이터를 문자열로 또는 문자열에서 구문 분석해야 하는데 이는 그다지 효율적이지 않습니다. 더 복잡한 데이터의 경우 JSON 문자열이 너무 길면 라이브러리에서 오류가 발생할 수 있습니다. 또한 가능한 모든 구문 분석 오류를 살펴보고 예를 들어 사용자 지정 구문 분석기로 해결해야 합니다.

데이터를 앞뒤로 구문 분석할 필요가 없는 더 나은 솔루션이 있습니까? 살펴보자!

표면 아래를 보고

설명서와 코드베이스를 살펴보면서 Compose용 탐색이 처음부터 빌드된 것이 아니라 프래그먼트 탐색에 사용되는 기존 NavGraph 솔루션 위에 Android Jetpack의 탐색 구성요소 에 추가되었다는 사실을 빠르게 발견했습니다. 당신은 지금 생각할 수 있습니다 :

기다리다! 그들은 Parcelables 전송을 지원합니다!! Compose에도 추가하지 않은 이유는 무엇입니까?!

간단한 설명은 단일 진실 패턴 소스를 적용하기 위한 의도적인 결정이었을 수 있다는 것입니다. 코드에서 더 깊은 곳에서 어떤 일이 발생하는지 봅시다.

@JvmOverloads
public 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 ""

@MainThread
public open fun navigate(
    request: NavDeepLinkRequest,
    navOptions: NavOptions?,
    navigatorExtras: Navigator.Extras?
) {
    val deepLinkMatch = _graph!!.matchDeepLink(request)
    if (deepLinkMatch != null) {
        val destination = deepLinkMatch.destination
        val args = destination.addInDefaultArgs(deepLinkMatch.matchingArgs) ?: Bundle()
        val node = deepLinkMatch.destination
        val intent = Intent().apply {
            setDataAndType(request.uri, request.mimeType)
            action = request.action
        }
        args.putParcelable(KEY_DEEP_LINK_INTENT, intent)
        navigate(node, args, navOptions, navigatorExtras)
    } else {
        throw IllegalArgumentException(
            "Navigation destination that matches request $request cannot be found in the " +
                "navigation graph $_graph"
        )
    }
}

매칭 프로세스는 route 에서 시작하여 NavDestination가장 좋은 것을 찾기 위해 모든 하위 항목을 재귀적으로 살펴봅니다.

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public override fun matchDeepLink(navDeepLinkRequest: NavDeepLinkRequest): DeepLinkMatch? {
    // First search through any deep links directly added to this NavGraph
    val bestMatch = super.matchDeepLink(navDeepLinkRequest)
    // Then search through all child destinations for a matching deep link
    val bestChildMatch = mapNotNull { child ->
        child.matchDeepLink(navDeepLinkRequest)
    }.maxOrNull()

    return listOfNotNull(bestMatch, bestChildMatch).maxOrNull()
}

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public open fun matchDeepLink(navDeepLinkRequest: NavDeepLinkRequest): DeepLinkMatch? {
    if (deepLinks.isEmpty()) {
        return null
    }
    var bestMatch: DeepLinkMatch? = null
    for (deepLink in deepLinks) {
        val uri = navDeepLinkRequest.uri
        val matchingArguments =
            if (uri != null) deepLink.getMatchingArguments(uri, arguments) else null
        val requestAction = navDeepLinkRequest.action
        val matchingAction = requestAction != null && requestAction ==
            deepLink.action
        val mimeType = navDeepLinkRequest.mimeType
        val mimeTypeMatchLevel =
            if (mimeType != null) deepLink.getMimeTypeMatchRating(mimeType) else -1
        if (matchingArguments != null || matchingAction || mimeTypeMatchLevel > -1) {
            val newMatch = DeepLinkMatch(
                this, matchingArguments,
                deepLink.isExactDeepLink, matchingAction, mimeTypeMatchLevel
            )
            if (bestMatch == null || newMatch > bestMatch) {
                bestMatch = newMatch
            }
        }
    }
    return bestMatch
}

@MainThread
private fun navigate(
    node: NavDestination,
    args: Bundle?,
    navOptions: NavOptions?,
    navigatorExtras: Navigator.Extras?
) {...}

라이브러리는 그 과정에서 다음 단계를 수행합니다.

  • NavDeepLinkRequest형식이 지정된 경로로 생성
  • 경로에서 인수를 추출하여 번들에 추가합니다.
  • NavDestination주어진 요청에 가장 적합한 것을 찾고 ,
  • NavDestination및 Bundle?매개변수로 사용되는 탐색 메소드를 호출합니다 .

표면으로 돌아가자

navigate원하는 메서드의 사용법을 보면 다른 메서드 에서 사용되는 것을 볼 수 있기 때문에 모든 희망이 사라지는 것은 아닙니다 .

@MainThread
public open fun navigate(
    @IdRes resId: Int,
    args: Bundle?,
    navOptions: NavOptions?,
    navigatorExtras: Navigator.Extras?
)

  • 번들 형식의 인수를 사용 하므로 publicCompose 탐색에 사용할 수 있습니까?
  • 이 방법에서 resId는 무엇이며 경로 문자열을 어떻게 변환할 수 있습니까?

공통 스레드 검색 resId및 route문자열

라이브러리의 구성 추가에서 navGraph가 생성되면 NavDestinations가 구성 가능한 메서드와 함께 여기에 추가됩니다. 구현을 자세히 살펴보면 경로가 setter 메서드와 함께 적용되는 것을 볼 수 있습니다.

/**
 * Add the [Composable] to the [NavGraphBuilder]
 *
 * @param route route for the destination
 * @param arguments list of arguments to associate with destination
 * @param deepLinks list of deep links to associate with the destinations
 * @param content composable for the destination
 */
public fun NavGraphBuilder.composable(
    route: String,
    arguments: List<NamedNavArgument> = emptyList(),
    deepLinks: List<NavDeepLink> = emptyList(),
    content: @Composable (NavBackStackEntry) -> Unit
) {
    addDestination(
        ComposeNavigator.Destination(provider[ComposeNavigator::class], content).apply {
            this.route = route
            arguments.forEach { (argumentName, argument) ->
                addArgument(argumentName, argument)
            }
            deepLinks.forEach { deepLink ->
                addDeepLink(deepLink)
            }
        }
    )
}

/**
 * The destination's unique route. Setting this will also update the [id] of the destinations
 * so custom destination ids should only be set after setting the route.
 */
public var route: String? = null
    set(route) {
        if (route == null) {
            id = 0
        } else {
            require(route.isNotBlank()) { "Cannot have an empty route" }
            // the transformation from route to id
            val internalRoute = createRoute(route)
            id = internalRoute.hashCode()
            addDeepLink(internalRoute)
        }
        deepLinks.remove(deepLinks.firstOrNull { it.uriPattern == createRoute(field) })
        field = route
    }

빙고! 이제 원하는 대로 작동하는지 테스트하고 그에 대한 솔루션을 개발할 수 있습니다. route 및 인수를 navigate사용 하고 우리가 발견한 대로 route를 변환하는 고유한 메서드를 만드는 것으로 시작하겠습니다 .StringBundleresId

data class ParcelableClass(...) : Parcelable

fun NavHostController.navigate(route: String, bundle: Bundle = Bundle()) {
    val r = createRoute(route)
    navigate(r.hashCode(), bundle)
}

...
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "first",
    ) {
    composable("first") {
        ....
        Button(onClick = {
            val params = bundleOf(
                "name" to "Max",
                "data" to ParcelableClass(...)
            )
            navController.navigate("second", params)
        }) {...}
    }
    composable("second") {
        ....
    }
...

그런 다음 이름 매개변수를 사용하여 첫 번째 컴포저블 에서 두 번째 컴포저블로 이동하는 데 사용할 수 있습니다 . 두 번째 화면 내부에서 이러한 매개 변수를 어떻게 추출할 수 있습니까 ? 구성 가능한 메서드를 자세히 살펴보면 람다 인수 내부에 하나의 매개 변수가 있습니다 — NavBackStackEntry. 여기서 인수를 검색할 수 있습니다.

composable("second") { navBackStackEntry ->
    val args: Bundle? = navBackStackEntry.arguments
    val name = args?.getString("name") // assigns "Max" in the example
}

val args: Bundle? = navBackStackEntry.arguments
val data: ParcelableClass? = args?.let {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        this?.getParcelable(keyName, ParcelableClass::class.java)
    } else {
        @Suppress("DEPRECATION")
        this?.getParcelable<ParcelableClass>(keyName)
    }
}

이 솔루션을 사용하기로 결정했다면 Jetpack Compose와 함께 Navigation 라이브러리를 사용하는 의도된 방식이 아님을 알아야 합니다. 일부 코드는 특정 라이브러리 구현에 크게 의존하기 때문에 내부에서 큰 변경을 거치면 이 접근 방식이 중단될 수 있습니다. 각 프로그래머는 라이브러리 리팩토링의 위험과 가능성을 스스로 고려해야 합니다.

이 솔루션을 사용하는 프로젝트에 추가하는 것이 유용한 것 중 하나는 설명된 솔루션이 의도한 대로 작동하지 않는 경우 경고할 수 있는 간단한 단위 테스트입니다.

다음 부분에서는 이 결과를 여러 앱에서 재사용할 수 있는 일반 솔루션으로 변환하는 방법을 보여 드리겠습니다.