Распространенные ловушки навигации в Jetpack Compose и как мы с ними справились

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

Навигационные библиотеки в Jetpack Compose

Я нашел 3 самых популярных навигационных решения: Jetpack Compose Navigation, Voyager, Decompose.

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

  • Voyager — очень удобная и простая библиотека. Отлично подходит для небольших и домашних проектов. Подойдет и для более серьезных проектов, где вы готовы взять на себя следующие риски: нет поддержки навигации для iOS, слабая поддержка библиотек, есть проблемы с крашами приложений.
  • Decompose — единственная популярная библиотека, обеспечивающая полноценную мультиплатформенную навигацию. Хорошее стабильное решение с отличной поддержкой автора. Она хорошо впишется в ваш проект, если вы уже используете библиотеку MVIKotlin от Аркадия Иванова. Однако многие люди отмечают, что сначала очень сложно понять, как пользоваться библиотекой. Если вы возьметесь задействовать библиотеку в своем проекте, будьте готовы потратить на его реализацию несколько рабочих дней. Если у вас большая команда, вам также придется написать упрощенную навигационную документацию и подготовить LiveTemplates, чтобы избавиться от большого количества шаблонного кода.
  • Jetpack Compose Navigation — надежная библиотека для большинства проектов. Если описанные выше решения вам не подошли, смело берите навигационную библиотеку от Google. Я очень скептически отнесся к этой библиотеке, зная проблемы Jetpack Navigation в XML-мире. Однако библиотека показала себя превосходно. Критических проблем нет. Его легко внедрить в любой проект и реализовать любой уровень сложности навигации. Jetpack Navigation не блокирует возможность написания KMM-приложений, поскольку сама навигация происходит на уровне фреймворка (на слое View).

В итоге выбрали Jetpack Navigation, так как он лучше всего подходил для нашего проекта и нес в себе минимум рисков. Отмечу, что Voyager тоже может подойти для многих проектов, но как стартап мы не решились взять библиотеку, у которой было много потенциальных проблем, описанных на GitHub Issues

Анимации

Сразу после встраивания Jetpack Navigation в приложение вы заметите, что настроить анимации «из коробки» не получится.

Анимации в Jetpack Navigation от Google находятся в статусе эксперимента. И для таких экспериментов у Google есть отдельная коллекция расширений под Compose под названием Accompanist.

С помощью Accompanist Navigation Animation можно настроить любую анимацию перехода между экранами в стиле Compose за 10 минут.

Избавьтесь от мерцания экрана во время анимации затухания

Если фон окна Activity отличается от фона экранов Compose, то после добавления анимации перехода вы заметите, что экраны как бы мерцают во время перехода. Это связано с тем, что Compose НЕ накладывает один экран на другой, как в случае перехода между действиями или выполнения транзакций «добавить» при переходе между фрагментами Android. Compose сначала проигрывает анимацию разрушения экрана, а после сразу проигрывает анимацию создания нового экрана. А если вы добавили анимацию затухания, то где-то в середине перехода вы заметите фон Окна Активности, отличающийся от фона экранов Составления.

Исправить это мерцание просто: удалите фон с экранов «Написать» и добавьте его в качестве фона окна «Активность». Если вы используете свой фон на каждом экране, то в конце анимации Compose экрана, на который переключился пользователь, установите фон на окно Activity текущего экрана.

Вы можете изменить фон Окна активности следующим образом из Активность:

window.setBackgroundDrawable(BitmapDrawable)

Вы можете изменить фон Окна активности в Compose:

val activity = LocalContext.current as Activity
LaunchedEffect(activity) {
    activity.window.setBackgroundDrawable(BitmapDrawable)
}

Утечки памяти в Multi-Activity

В 2018 году Константин Цховребов написал известную статью об удобстве использования подхода Single Activity. Многие программисты восприняли его негативно и до сих пор не перешли на такой подход, не видя в нем плюсов.

Мне нравится использовать Single Activity, но для навигации Afterglow мы приняли решение перемещаться между несколькими Activity в отдельных модулях. Мы предположили, что подход к навигации между видами деятельности надежен и проверен на тысячах проектов.

Ничего страшного не случится (так мы думали, пока не подключили LeakCanary к проекту)! Оказалось, что при пересоздании конфигурации Activity протекал весь граф Compose. И ссылка на Activity была сохранена Recomposer (одна из внутренних ключевых сущностей Compose).

Мы обратились к сообществу Android за ответом, но не получили никакого решения. Позже мы нашли один забавный твит, в котором мы были не единственными, кто ругал Compose за утечку памяти.

Опытным путем мы выяснили, что проблемы с утечками памяти в Compose появляются, когда в приложении одновременно живы несколько Activity. Мы полностью перешли на подход Single Activity и навсегда забыли об утечках памяти.

Кстати, утечки памяти также возможны при навигации между Фрагментами, использующими Compose. Однако в этом случае утечки памяти можно устранить, изменив стратегию очистки Compose из памяти в ComposeView:

setViewCompositionStrategy(
    DisposeOnLifecycleDestroyed(viewLifecycleOwner)
)

SharedViewModel в ограниченном потоке экранов

Допустим, у нас есть вложенная навигация в какую-то общую навигацию приложения. Внутри этой навигации у нас есть несколько экранов, которые должны иметь общий SharedViewModel. На StackOverflow есть несколько похожих вопросов, но в итоге действительно подходящих ответов мы не нашли. Причины, по которым ответы от StackOverflow нас не устроили:

  • ViewModel не следит за жизненным циклом функции Compose и не очищается при необходимости.
  • Невозможно встроить SharedViewModel во вложенную навигацию. Например, вы не можете создать функцию Compose, такую ​​как FlowScreen, и разместить внутри нее вложенную навигацию. Jetpack Navigation не позволяет вам это сделать.
  • Трудно получить экземпляр SharedViewModel из функции Compose. Для доступа к ViewModel требуется написать много неочевидного кода.

Поэтому мы решили вопрос по-другому. Мы создали SharedViewModelHolder вне навигации, которая содержит ссылку на ViewModel.

В него добавлены функции расширения для создания новой ViewModel и получения существующей. В итоге мы получили очень простой и многоразовый код для работы с SharedViewModel. Также мы не встраиваемся в поведение Compose, который корректно обрабатывает для нас жизненный цикл ViewModel в рамках функций Compose. При этом на навигационном графе четко видно, где будет создаваться и уничтожаться ViewModel.

GitHub Gist: SharedViewModelHolder.kt

Открытие цепочки экранов

Часто в приложении приходится открывать цепочку экранов для какого-то события. Например, при нажатии на уведомление нужно открыть следующую цепочку экранов: главный экран, список чатов и экран конкретного чата, в который пришло сообщение. В Compose Navigation нет отдельного механизма, который позволял бы одной транзакцией открывать цепочку экранов, поэтому каждый экран мы будем открывать отдельной транзакцией.

Помните, что одиночное действие приветствуется в Compose. Этот подход также очень поможет нам здесь. У нас есть AppActivity — основная и единственная Activity приложения. Он содержит ссылку на AppViewModel, внутри которого лежит Channel (от Kotlin Coroutines) со списком открываемых экранов. AppActivity подписывается на этот канал и при получении нового списка (цепочки) экранов в цикле делает переход на каждый из этих экранов по методу navigate(). Если кому-то нужно открыть новую цепочку экранов, то список новых экранов (маршрутов), на которые нужно переключиться, передается на AppActivity через Intent.

Покажу ключевые моменты в коде:

GitHub Gist: Фрагмент кода Open Screen Chain

Jetpack Navigation рекомендует передавать аргументы через маршруты. Это очень элегантный способ передачи данных. маршрут показывает, что пользователь перешел на следующий экран. Это легко войти. Compose под капотом восстанавливает аргументы после смерти процесса.

Но есть проблема: в Compose Navigation нельзя передавать сложные объекты. С одной стороны, ссылочные типы данных нельзя сохранить, чтобы они пережили смерть процесса (если только они не были предварительно сериализованы).

С другой стороны, разработчики Android уже привыкли использовать типы Serializable и Parcelable для передачи сложных данных между экранами. Однако даже их нельзя передать в Compose.

Есть поддержка NavType.ParcelableType, но по факту передать данные через него без кастомной сериализации в строку невозможно. Этот вопрос задавали много раз на StackOverflow: здесь и здесь. Но создатели Compose Navigation по-прежнему уверены, что передавать сложные данные между экранами невозможно.

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

Но я так не думаю и полностью разделяю позицию Аркадия Иванова:

…, ‹простого идентификатора в виде строки или числа› не всегда достаточно, и Parcelable очень полезен. Например, для экрана пользователя помимо его идентификатора может потребоваться информация о контексте, из которого открывается этот экран. Если это друг из моего профиля, или пользователь из экрана поиска, или еще откуда-то. Может возникнуть необходимость отображать их по-разному. Ключом для запроса может быть набор параметров. В этом случае может иметь смысл сделать запечатанный класс с разными комбинациями вообще. И да, если изменится дизайн, то достаточно изменить этот класс и все места использования перестанут компилироваться. Но если тип идентификатора станет строкой, а не числом, то во время выполнения все рухнет. А с диплинками все нормально работает, если уметь.

Кстати, мы будем сохранять именно Parcelable, а не Serializable или любой другой эталонный тип данных, потому что Parcelable — это самый оптимальный способ сериализации данных в Android: его легко создать и он быстро сериализуется в довольно компактный вид.

Способов передачи Parcelable между экранами в интернете предостаточно, но все они сводятся к 3-м:

1) Сохранение Parcelable в предыдущем заднем стеке:

На текущем экране…

navController.currentBackStackEntry?.arguments = Bundle().apply {
    putParcelable("article", article)
}
navController.navigate("article")

На следующем экране…

val article = navController.previousBackStackEntry?.arguments
    ?.getParcelable<Article>("article")

Минусы: при закрытии предыдущего экрана через popBackStack() данные для следующего экрана будут потеряны

2) Передача данных через Bundle, игнорируя маршрут экрана:

fun NavController.navigate(
    route: String,
    args: Bundle,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null
) {
    val routeLink = NavDeepLinkRequest
        .Builder
        .fromUri(NavDestination.createRoute(route).toUri())
        .build()
    val deepLinkMatch = graph.matchDeepLink(routeLink)
    if (deepLinkMatch != null) {
        val destination = deepLinkMatch.destination
        val id = destination.id
        navigate(id, args, navOptions, navigatorExtras)
    } else {
        navigate(route, navOptions, navigatorExtras)
    }
}

Минусы:
1. невозможно продолжать передачу простых данных по маршруту, как это принято в Compose (будут сложности с логированием навигации).
2. Этот способ ненадежен, т.к. мы используем методы, которые предназначены только для внутренних целей библиотеки навигации. Их можно закрыть в любой момент.

3) Сериализация данных в виде строки внутри маршрута извне или внутри NavType:

class AssetParamType : NavType<Device>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): Device? {
        return bundle.getParcelable(key)
    }
    override fun parseValue(value: String): Device {
        return Gson().fromJson(value, Device::class.java)
    }
    override fun put(bundle: Bundle, key: String, value: Device) {
        bundle.putParcelable(key, value)
    }
}

Минусы:
1. Приходится писать много лишнего кода
2. Сериализация и десериализация занимает много времени и заметно влияет на скорость рендеринга Compose невооруженным глазом

Изучив все описанные выше способы, мы поняли, что ни один из них нам не подходит, и решили придумать свое решение, которое работает быстро и не противоречит принципам библиотеки.

Если не вдаваться в подробности реализации нашей обертки для навигации, то принцип основан на хранении данных в parcelableArguments типа HashMap<String, Parcelable>.

При получении данных на следующем экране мы обязательно оборачиваем parcelableArguments в rememberSaveable{}, чтобы аргументы Parcelable могли пережить смерть процесса.

Вы можете перейти по ссылке ниже в GitHub Gist и узнать больше о нашем подходе. Там я постарался выложить наиболее важные фрагменты кода для решения проблемы с передачей объектов Parcelable.

GitHub Gist: Передавайте разделяемые аргументы

Заключение

Мы рассмотрели основные проблемы, с которыми вы можете столкнуться при реализации навигации в Compose. Мы нашли решения, которые каждый может легко интегрировать в свой проект. Мы надеемся, что теперь вы будете меньше страдать и получать больше удовольствия, используя Compose!

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