Изучение ограничений Compose при отправке более сложных структур данных между пунктами назначения

Когда Google представил поддержку Jetpack Compose в Компоненте навигации, ограничения предоставленного решения были быстро обнаружены сообществом разработчиков Android. На чем я хочу сосредоточиться в этой статье, так это на невозможности отправлять более сложные структуры данных между пунктами назначения.

Путь Google

Главный компонуемый в библиотеке, отвечающий за навигацию, — это NavHost. Чтобы добавить пункт назначения в навигационную структуру, нам нужно добавить компонуемый (или другой компонент, например, оповещение) внутрь его лямбда-аргумента. Для метода требуется маршрут в строковом формате и необязательный список аргументов навигации.

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

В документации библиотеки мы можем увидеть поддерживаемые типы аргументов:

К сожалению, только основные, такие как Integer, Boolean и String, обрабатываются маршрутами. Нужный для нашего случая кастомный Parcelable, хотя и входит в состав библиотеки, в данной ситуации использовать нельзя.

Фактическая навигация выполняется путем вызова метода навигации в navController с правильно отформатированной строкой маршрута, содержащей все необходимые аргументы.

navController.navigate("profile/user1234")

рассуждения Google

Как Google объясняет отсутствие поддержки более сложных типов данных для аргументов навигации? Они просто предлагают реализовать принцип единого источника правды, что означает, что между пунктами назначения должна передаваться только минимально необходимая информация, такая как id-строки. Затем остальные требуемые данные должны быть получены из единого источника достоверности на уровне данных. Хотя этот подход и должен быть желательным, поскольку он предотвращает любые несоответствия данных в приложении, его может быть трудно реализовать в сложных приложениях, и он усложняет рефакторинг, когда текущее решение для навигации в приложении позволяет отправлять Parcelables.

Обычно используемое решение

Наиболее распространенное решение для отправки Parcelables требует создания собственного NavType, который будет поддерживать нужный тип (как описано здесь). Это решает описанную проблему, но, к сожалению, далеко не идеально. Прежде всего, данные должны быть преобразованы в строку и из строки, обычно в формате JSON, что неэффективно. В случае более сложных данных библиотека может выдавать ошибки, если строка JSON слишком длинная. Кроме того, нам нужно следить за любыми возможными ошибками синтаксического анализа и устранять их, например, с помощью пользовательских парсеров.

Есть ли лучшее решение, которое не требовало бы самостоятельного разбора данных? Давайте рассмотрим это!

Глядя под поверхность

Изучив документацию и кодовую базу, мы быстро обнаруживаем, что навигация для компоновки не создавалась с нуля, а была добавлена ​​в компонент навигации Android Jetpack поверх существующего решения NavGraph, используемого для навигации по фрагментам. Вы можете подумать:

Ждать! Они поддерживают отправку 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
    )
}

При вызове метода navigate с route мы создаем NavDeepLinkRequest с помощью построителя. Это предоставит Uri, созданный методом createRoute. Он преобразует строку маршрута, чтобы ее можно было понять при использовании механизма глубоких ссылок. Этот метод будет очень важен в нашем решении, так что запомните его на потом.

@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"
        )
    }
}

Внутри этого метода навигации мы пытаемся найти соответствие для глубокой ссылки в нашем NavGraph (она была создана путем добавления NavDestinations с методами composable, описанными ранее). Из совпадения извлекаются конкретные пункты назначения и args в Bundle. Затем добавляется аргумент ключа глубокой ссылки. Давайте остановимся здесь на мгновение и посмотрим, как это работает.

Процесс сопоставления начинается с маршрута 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()
}

Здесь создается объект DeepLinkMatch с именем newMatch. Он состоит из destination, args и кучи дополнительной информации. Пакет args создается в методе getMatchingArguments, где он извлекает данные из параметризованного запроса и добавляет их в пакет.

@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
}

После того, как мы получили все необходимые данные, вызывается еще один метод navigate. Вот как это выглядит:

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

Итог глубокого погружения

По пути библиотека выполняет следующие шаги:

  • Создает NavDeepLinkRequest с отформатированным маршрутом
  • Извлекает аргументы из маршрута и добавляет их в пакет
  • Находит лучший NavDestination для заданного запроса
  • Вызывает метод навигации, который принимает NavDestination и Bundle? в качестве параметров.

Можем ли мы тогда опустить часть реализации, анализирующую маршрут, и вызвать последний метод навигации напрямую? Таким образом, мы могли бы передать Bundle, которое мы сами заполним Parcelable. К сожалению, в то время как все предыдущие navigate являются общедоступными, этот является частным.

Вернемся на поверхность

Однако не вся надежда потеряна, потому что, глядя на использование желаемого метода, мы видим, что он используется в другом методе navigate:

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

Возможно, вы знакомы с ней, если когда-либо использовали эту библиотеку для навигации между фрагментами с помощью navGraph. После этого открытия у меня возникли некоторые вопросы:

  • Это public и он принимает аргументы в формате Bundle, поэтому можем ли мы использовать его для нашей навигации по Compose?
  • Что такое resId в этом методе и как мы можем преобразовать в него строку маршрута?

Ответ для них обоих, к счастью, да!

Общий поток поиска resId и route строк

Когда navGraph создается при составлении библиотеки, NavDestinations добавляются к ней с помощью компонуемых методов. Если мы внимательно посмотрим на его реализацию, мы увидим, что маршрут применяется с помощью метода установки.

/**
 * 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)
            }
        }
    )
}

Сеттер устанавливает не только значение маршрута, но и идентификатор NavDestination. Это то же значение, которое используется для поиска соответствующего места назначения при навигации с аргументом resId. Зная это, мы можем легко преобразовать маршрут в id, а затем использовать Bundles для передачи аргументов между пунктами назначения!

/**
 * 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
    }

Давайте создадим решение из результатов

Бинго! Теперь мы можем проверить, работает ли он так, как хотелось бы, и разработать решение. Давайте начнем с создания нашего собственного метода navigate, который принимает маршруты String и Bundle в качестве аргументов и преобразует маршрут в resId с тем, что мы обнаружили.

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
}

Первым переданным аргументом была строка, поэтому мы можем получить ее с помощью метода getString. Для аргумента ParcelableClass нам нужно немного более сложное решение, чтобы учесть, что старый метод getParcelable обесценивается, начиная с Android Tiramisu.

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. Поскольку некоторая часть кода сильно зависит от конкретной реализации библиотеки, этот подход может сломаться, если он подвергнется большим внутренним изменениям. Каждый программист должен учитывать риск и вероятность рефакторинга библиотеки.

Одна из вещей, которую было бы полезно добавить в проект, использующий это решение, — это простые модульные тесты, которые могли бы оповещать, если описанное решение перестанет работать должным образом.

В следующей части я покажу, как эти выводы можно преобразовать в универсальное решение, которое можно повторно использовать в нескольких приложениях.