Профессиональная навигация в Jetpack Compose

Курс охватывает современные подходы к навигации, включая Type-Safe маршруты и работу с Koin [habr.com](https://habr.com/ru/companies/wildberries/articles/939526/). Вы изучите принципы построения графа [developer.android.google.cn](https://developer.android.google.cn/guide/navigation/principles?hl=ru), передачу данных между экранами [habr.com](https://habr.com/ru/companies/wildberries/articles/905238) и новые подходы в Multiplatform [habr.com](https://habr.com/ru/articles/984552/).

1. Принципы навигации: NavHost, бэкстек и фиксация начального экрана

Принципы навигации: NavHost, бэкстек и фиксация начального экрана

Навигация в современном Android-приложении — это не просто переключение видимости View или замена фрагментов. В экосистеме Jetpack Compose навигация представляет собой реакцию на изменение состояния. Когда пользователь переходит от списка товаров к деталям заказа, он фактически изменяет состояние приложения, а UI перерисовывается, чтобы соответствовать этому новому состоянию.

Понимание фундаментальных принципов работы NavHost и внутреннего устройства стека возврата (back stack) — это то, что отличает профессионального разработчика от новичка, копирующего код из StackOverflow. Ошибки на этом этапе приводят к утечкам памяти, некорректному поведению кнопки «Назад» и потере данных при повороте экрана.

Архитектура единой Activity

Jetpack Compose поощряет подход Single Activity. Это означает, что ваше приложение состоит из одной Activity, которая служит контейнером для всего контента. Внутри этой Activity происходит подмена Composable-функций.

В отличие от классического подхода с множеством Activity или Fragment, где системой навигации управляла сама ОС Android (через Intent и FragmentManager), в Compose вы берете управление на себя. Вы создаете граф навигации, который определяет структуру вашего приложения.

Ключевые компоненты навигации

Для реализации навигации в Compose используются три основных компонента, которые работают в тесной связке:

  • NavController — центральный координатор.
  • NavGraph — карта маршрутов.
  • NavHost — контейнер для отображения.
  • NavController: Мозг операции

    NavController — это объект, который хранит состояние навигации и бэкстек. Он отвечает за выполнение переходов, обработку нажатия кнопки «Назад» и управление глубокими ссылками (Deep Links). Этот объект должен быть создан на верхнем уровне иерархии вашего UI, обычно внутри MainActivity или корневой Composable-функции.

    Для создания используется функция rememberNavController():

    Этот объект переживает рекомпозицию, так как использует remember, и сохраняет состояние при повороте экрана благодаря rememberSaveable внутри своей реализации.

    NavHost: Контейнер отображения

    NavHost — это Composable-функция, которая связывает NavController с графом навигации. Она действует как «экран», на котором проецируется текущий пункт назначения (Destination). Когда вы командуете контроллеру перейти на другой экран, NavHost выполняет рекомпозицию и подменяет содержимое.

    Пример базовой настройки согласно документации Android Developers:

    В этом примере лямбда-выражение внутри NavHost использует NavGraphBuilder для определения графа. Функция composable связывает строковый маршрут (route) с конкретной Composable-функцией.

    Бэкстек (Back Stack): Структура и поведение

    Бэкстек — это структура данных, работающая по принципу LIFO (Last In, First Out — «последним пришёл, первым ушёл»). Это история перемещений пользователя.

    Как работает стек

    Представьте бэкстек как стопку тарелок. Каждая «тарелка» — это запись о посещенном экране (NavBackStackEntry). Эта запись хранит не только информацию о том, какой экран показать, но и состояние этого экрана (ViewModel, SavedStateHandle).

  • Push (Добавление): Когда вы вызываете navController.navigate("details"), новый экран кладется поверх текущего. Предыдущий экран не уничтожается полностью, но переходит в фоновое состояние (stopped).
  • Pop (Удаление): Когда пользователь нажимает системную кнопку «Назад» или вы вызываете navController.popBackStack(), верхняя «тарелка» снимается со стопки и уничтожается. Управление возвращается к экрану, который лежал под ней.
  • Состояние навигации

    Согласно официальным принципам навигации, состояние навигации всегда представлено стеком пунктов назначения. Верхняя часть стека — это текущий экран. Нижняя часть стека — это всегда начальный экран (Start Destination).

    Важно понимать, что операции со стеком всегда происходят на его вершине. Вы либо кладете что-то сверху, либо убираете сверху. Исключение составляют сложные операции очистки стека (например, popUpTo), которые позволяют убрать сразу несколько «тарелок», чтобы вернуться к определенному состоянию.

    Пример работы стека:

  • Запуск приложения: Стек = [Home]
  • Переход к списку: Стек = [Home, List]
  • Переход к деталям: Стек = [Home, List, Details]
  • Нажатие «Назад»: Стек = [Home, List] (Details уничтожен)
  • Фиксация начального экрана (Start Destination)

    Каждое приложение имеет фиксированную точку входа. Это первый экран, который видит пользователь при запуске приложения из лаунчера. В терминах Jetpack Navigation это называется Start Destination.

    Почему это важно?

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

    Правила работы с начальным экраном:

  • Единственность: У каждого графа навигации должен быть ровно один стартовый маршрут.
  • Точка выхода: Если пользователь находится на стартовом экране и нажимает «Назад», приложение должно закрыться (или свернуться). Это ожидаемое системное поведение Android.
  • Восстановление: При восстановлении процесса (например, после того как система убила приложение для освобождения памяти) навигационный компонент восстановит стек, и начальный экран снова окажется в основании.
  • Ошибка циклической навигации

    Распространенная ошибка новичков — попытка «вернуться» на главный экран через navigate("home") вместо использования popBackStack.

    Рассмотрим сценарий:

  • Стек: [Home]
  • Пользователь идет в профиль: navigate("profile"). Стек: [Home, Profile]
  • Пользователь нажимает кнопку «Домой» в интерфейсе, и разработчик вызывает navigate("home").
  • Результат: Стек становится [Home, Profile, Home]. Теперь у вас два экземпляра домашнего экрана в памяти. Если пользователь нажмет «Назад», он попадет в профиль, а затем снова домой. Это нарушает UX и расходует память.

    Правильное решение: Использовать popUpTo для возврата к существующему экземпляру экрана или просто popBackStack, если экран находится прямо под текущим.

    Маршруты (Routes) как идентификаторы

    В классическом Jetpack Navigation (до версии 2.8.0) маршруты представлялись строками (String), напоминающими URL-адреса веб-сайтов. Например, "profile/user123".

    Хотя сейчас набирает популярность типобезопасная навигация (Type-Safe Navigation) с использованием объектов и классов, принцип остается прежним: Route — это уникальный адрес пункта назначения. NavHost использует этот адрес, чтобы найти соответствующий composable в графе и отобразить его.

    Как отмечается в статье на habr.com, при определении графа вы указываете route (адрес) и startDestination (точку входа). Это создает карту, по которой NavController будет перемещать пользователя.

    Жизненный цикл Composable в навигации

    Понимание жизненного цикла критически важно для управления ресурсами. Composable-функции, привязанные к навигации, имеют свой жизненный цикл, отличный от Activity.

    * Вход (Enter): Когда экран добавляется в стек, вызывается Composable-функция. Запускаются все LaunchedEffect и DisposableEffect. * Уход в фон (Stop): Когда поверх текущего экрана открывается новый, текущий экран не уничтожается, но его UI перестает отрисовываться. Однако его состояние (ViewModel) сохраняется в памяти. * Уничтожение (Destroy): Когда экран удаляется из стека (пользователь нажал «Назад»), Composable покидает композицию. Срабатывает onDispose в DisposableEffect, а связанная с экраном ViewModel очищается (вызывается onCleared).

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

    Итоги

    * NavHost и NavController — неразрывная пара. Контроллер управляет логикой и состоянием, а хост отображает актуальный экран в UI. * Бэкстек работает по принципу LIFO. Новые экраны кладутся сверху, кнопка «Назад» снимает верхний экран. Нижний элемент стека — всегда начальный экран. * Фиксация начального экрана обязательна. Это якорь вашего приложения. Нажатие «Назад» на этом экране должно выводить из приложения. * Избегайте дублирования в стеке. Для возврата назад используйте popBackStack или навигацию с флагом popUpTo, чтобы не создавать копии экранов. * Жизненный цикл привязан к стеку. Экраны, ушедшие из стека, уничтожаются вместе с их состоянием, что экономит ресурсы устройства.

    2. Type-Safe навигация и модульная архитектура: один модуль на продуктовый флоу

    Type-Safe навигация и модульная архитектура: один модуль на продуктовый флоу

    В предыдущей статье мы рассмотрели фундаментальные принципы работы NavHost и бэкстека, используя строковые маршруты. Однако по мере роста приложения использование строк (например, "profile/{userId}") становится источником критических ошибок: опечатки приводят к падению приложения в рантайме, а передача сложных объектов превращается в мучение с сериализацией в JSON вручную.

    Начиная с версии Navigation 2.8.0, Google представил Type-Safe Navigation (типобезопасную навигацию). Это подход, при котором маршруты описываются не строками, а классами и объектами. В сочетании с правильной модульной архитектурой это позволяет создавать масштабируемые приложения, где навигация проверяется на этапе компиляции.

    Проблема строковой навигации

    Классический подход Jetpack Navigation опирался на строковые URI. Это работало для простых приложений, но в крупных проектах вызывало хаос. Разработчику приходилось помнить названия аргументов, следить за слешами и вручную парсить данные.

    С приходом Type-Safe навигации мы используем библиотеку kotlinx.serialization. Теперь маршрут — это объект:

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

    Архитектурный сдвиг: от экранов к флоу

    При проектировании многомодульных приложений долгое время популярным был подход «один экран — один модуль». Однако на практике это приводило к избыточной фрагментации и сложности сборки.

    Концепция «Один модуль на продуктовый флоу»

    Современный подход, который применяют крупные компании, заключается в объединении логически связанных экранов в один функциональный модуль. Это называется Product Flow (продуктовый флоу).

    Например, процесс оформления заказа (Checkout) может состоять из экранов:

  • Корзина
  • Выбор адреса
  • Выбор способа оплаты
  • Подтверждение
  • Вместо создания 4 разных модулей, мы создаем один модуль :feature:checkout. Внутри этого модуля экраны знают друг о друге и могут свободно навигироваться. Внешний мир (другие модули) знает только о точке входа в этот флоу.

    Согласно Wildberries & Russ, такой подход упрощает работу с общими сущностями и внутренней навигацией, так как все экраны фичи находятся в одном контексте.

    Реализация Type-Safe навигации в многомодульности

    Главный вызов многомодульной навигации — избежать циклических зависимостей. Модуль Home хочет открыть модуль Profile, но модуль Profile может хотеть вернуться в Home.

    Для решения этой задачи используется разделение на API и Implementation.

    Структура модулей

    Для каждой фичи (например, profile) создается два модуля (или пакета, если проект небольшой):

  • :feature:profile:api — содержит только контракты навигации (Route-классы).
  • :feature:profile:impl — содержит экраны (Composables), ViewModel и реализацию графа.
  • Шаг 1: Определение маршрутов (API)

    В API-модуле мы описываем, какие экраны доступны извне. Используем аннотацию @Serializable.

    Этот модуль очень легкий, он подключается ко всем, кому нужно перейти в профиль.

    Шаг 2: Реализация графа (Implementation)

    В модуле реализации мы создаем расширение для NavGraphBuilder. Это позволяет инкапсулировать создание экранов внутри модуля.

    Обратите внимание на функцию toRoute(). Она автоматически извлекает и десериализует аргументы из навигации, избавляя нас от работы с Bundle.

    Шаг 3: Сборка в App-модуле

    Корневой модуль приложения (:app) знает обо всех feature-модулях и собирает их в единый NavHost.

    Математика связности модулей

    Почему важно объединять экраны во флоу, а не держать их отдельно? Рассмотрим это через призму количества связей. Если каждый экран — это отдельный узел графа, то количество потенциальных связей между ними растет квадратично.

    Количество связей в полносвязном графе (где каждый экран может ссылаться на каждый) рассчитывается по формуле:

    где — количество связей (dependencies), а — количество экранов (модулей).

    Если у нас 20 экранов, и каждый в отдельном модуле, то потенциальная сложность системы:

    Если же мы сгруппируем эти 20 экранов в 4 продуктовых флоу (по 5 экранов в каждом), то внешняя архитектура будет оперировать только 4 узлами:

    Это упрощает граф зависимостей в десятки раз, ускоряет сборку Gradle и облегчает понимание проекта новым разработчиком.

    Передача сложных данных

    Type-Safe навигация позволяет передавать не только примитивы, но и сложные объекты, если они помечены @Serializable. Однако, согласно Android Developers, передавать большие объекты через навигацию — плохая практика. Данные могут не поместиться в буфер транзакции Android.

    Правильный подход: Передавайте только минимально необходимый идентификатор (ID), а данные загружайте внутри целевого экрана из репозитория или базы данных.

    Пример правильного маршрута:

    Пример неправильного маршрута:

    Изоляция навигации

    Как отмечается в статье на habr.com, фича не должна знать, как осуществляется навигация. Она должна лишь сообщать о намерении.

    В примере выше (profileScreen) мы передаем лямбду onNavigateBack. Сам экран профиля не вызывает navController.popBackStack(). Это делает экран независимым от навигационного фреймворка и позволяет легко тестировать его или переиспользовать в другой части приложения (например, в планшетной верстке, где нет кнопки «Назад»).

    Итоги

    * Type-Safe Navigation заменяет строки на классы, обеспечивая проверку типов во время компиляции и удобную передачу аргументов через kotlinx.serialization. * Архитектура «Один модуль на флоу» снижает сложность проекта, объединяя связанные экраны (например, весь процесс авторизации) в один модуль. * Разделение API и Impl позволяет модулям ссылаться на маршруты друг друга без циклических зависимостей и утечки реализации UI. * Передача ID вместо объектов остается золотым стандартом даже при наличии возможности сериализовать сложные классы, чтобы избежать переполнения буфера транзакции.

    3. Интеграция Koin: управление зависимостями и Scopes в навигационном графе

    Интеграция Koin: управление зависимостями и Scopes в навигационном графе

    В предыдущих статьях мы разобрали, как построить модульную архитектуру на основе Type-Safe навигации и объединить экраны в продуктовые флоу. Однако, как только мы изолируем экраны в отдельные модули, возникает вопрос: как передавать данные между ними и управлять состоянием?

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

    Решением является использование Dependency Injection (DI) с привязкой к жизненному циклу навигационного графа. В этой статье мы рассмотрим интеграцию фреймворка Koin в Jetpack Compose Navigation.

    Основы Koin в Jetpack Compose

    Koin — это легковесный DI-фреймворк, который идеально ложится на философию Kotlin. В отличие от Dagger/Hilt, он не использует кодогенерацию, что ускоряет сборку проекта.

    Для начала работы в Compose используется артефакт koin-androidx-compose. Базовое внедрение ViewModel выглядит так:

    Функция koinViewModel() под капотом находит текущего владельца хранилища ViewModel (ViewModelStoreOwner), создает или возвращает существующий экземпляр ViewModel и привязывает его жизненный цикл к текущему экрану.

    Проблема разделения данных во флоу

    Представьте сценарий «Оформление заказа» (Checkout Flow), состоящий из трех экранов:

  • CartScreen — список товаров.
  • AddressScreen — выбор адреса доставки.
  • PaymentScreen — оплата.
  • Все эти экраны должны работать с одним и тем же объектом заказа (OrderDraft). Если пользователь ввел адрес на втором экране и вернулся на первый, данные не должны пропасть. Если пользователь завершил заказ или вышел из флоу, данные должны очиститься.

    Если мы просто используем koinViewModel() на каждом экране, мы получим три разных экземпляра ViewModel, так как у каждого экрана свой NavBackStackEntry (и, следовательно, свой ViewModelStore).

    Scoped ViewModel: привязка к графу

    Чтобы разделить одну ViewModel между несколькими экранами, нам нужно найти общего «родителя» для этих экранов. В Jetpack Navigation этим родителем выступает вложенный граф навигации (Nested Navigation Graph).

    Шаг 1: Создание вложенного графа

    Используя Type-Safe навигацию, мы определяем маршрут для самого графа:

    Шаг 2: Настройка NavHost

    Мы используем функцию navigation<CheckoutGraph>, чтобы сгруппировать экраны. Это создает отдельную запись в бэкстеке для самого графа.

    Шаг 3: Получение Shared ViewModel

    Ключевой момент — указать Koin, чей жизненный цикл использовать. Вместо текущего экрана (backStackEntry) мы должны использовать запись родительского графа.

    Теперь, пока пользователь находится внутри CheckoutGraph, sharedViewModel будет оставаться в памяти. Как только пользователь покинет этот граф (нажмет «Назад» на первом экране или завершит флоу), NavHost очистит ViewModelStore графа, и вызовется метод onCleared() у ViewModel.

    Koin Scopes: Управление обычными зависимостями

    Иногда нам нужно шарить не ViewModel, а обычные классы (например, SessionManager или BluetoothConnection), которые не являются компонентами Android Architecture Components. Для этого в Koin существуют Scopes (области видимости).

    Теория множеств в Scopes

    Области видимости можно представить как иерархию множеств. Пусть — это глобальный скоуп (Singleton), а — скоуп конкретной фичи.

    Отношение между ними описывается как строгое подмножество:

    где — это множество зависимостей фичи, а — глобальное множество зависимостей.

    Это означает, что объекты из могут ссылаться на объекты из , но не наоборот (чтобы избежать утечек памяти).

    Настройка Scope в модуле

    В модуле Koin мы объявляем скоуп, используя строковый идентификатор или тип:

    Привязка Scope к Navigation

    Согласно статье на habr.com, интеграция Koin Scopes с Jetpack Navigation требует тщательного управления жизненным циклом. Мы должны создать скоуп при входе в граф и закрыть его при выходе.

    Однако, наиболее надежный способ в современном Compose — использовать KoinScope в связке с NavBackStackEntry.

    Важно: Ручное управление скоупами (getOrCreateScope / close) часто приводит к ошибкам, если не учитывать пересоздание конфигурации. Поэтому для 90% задач, связанных с UI, рекомендуется использовать подход с SharedViewModel (через viewModelStoreOwner), описанный выше. Это перекладывает ответственность за очистку ресурсов на Android Framework.

    Изоляция модулей и DI

    Как мы обсуждали в статье про модульную архитектуру, модуль фичи (:feature:checkout:impl) не должен раскрывать свои внутренности. Koin позволяет объявлять модули внутри impl-пакетов и подключать их в :app модуле.

    Пример структуры:

  • :feature:checkout:api — содержит CheckoutGraph (маршрут).
  • :feature:checkout:impl — содержит CheckoutViewModel, Koin-модуль и экраны.
  • Когда :app модуль собирает приложение, он инициализирует Koin:

    Таким образом, DI связывает слабую связность архитектуры (через API-модули) с сильной связностью во время выполнения (Runtime).

    Эффективность использования памяти

    Использование Scoped-зависимостей вместо создания новых объектов на каждом экране можно выразить через оценку потребления ресурсов.

    Пусть — количество экранов во флоу, а — объем памяти, занимаемый тяжелым объектом (например, загруженным списком товаров).

    Без использования общего скоупа (пересоздание на каждом экране), пиковое потребление памяти может достигать:

    где — пиковое потребление памяти, — количество экранов в стеке, — память на один объект. Это происходит, потому что каждый экран в бэкстеке хранит свою копию данных.

    При использовании Shared ViewModel или Koin Scope:

    где — пиковое потребление памяти, — единственный экземпляр данных, — память на один объект. Это существенно экономит ресурсы, особенно на длинных флоу.

    Итоги

    * koinViewModel() — основной инструмент для внедрения зависимостей в Composable. По умолчанию привязывается к текущему экрану. * Shared ViewModel — для обмена данными между экранами используйте koinViewModel(viewModelStoreOwner = ...), передавая туда NavBackStackEntry родительского графа. * Жизненный циклNavHost автоматически очищает ViewModel, когда пользователь покидает граф навигации, что предотвращает утечки памяти. * Koin Scopes — мощный инструмент для объектов, не являющихся ViewModel, но требующий ручного управления закрытием (scope.close()) или использования оберток. * Экономия ресурсов — использование общих зависимостей снижает потребление памяти с линейной зависимости от глубины стека () до константной ().

    4. Типобезопасная передача данных и возврат результатов через SavedStateHandle

    Типобезопасная передача данных и возврат результатов через SavedStateHandle

    В предыдущих статьях мы настроили модульную архитектуру и внедрили Koin для управления зависимостями. Теперь перед нами стоит сугубо практическая задача: как передавать данные между экранами так, чтобы приложение не падало при повороте устройства или смене темы, и как возвращать результаты (например, выбор фильтра или товара) с дочернего экрана на родительский.

    В экосистеме Android «золотым стандартом» хранения состояния навигации является SavedStateHandle. Это ключе-значение хранилище, которое переживает смерть процесса системы. В этой статье мы разберем, как использовать его для типобезопасной передачи аргументов вперед и возврата результатов назад.

    SavedStateHandle: Фундамент навигации

    SavedStateHandle — это словарь (map), который привязан к жизненному циклу ViewModel и сохраняется системой даже если Android убивает процесс приложения для освобождения памяти. В контексте Jetpack Navigation каждый элемент бэкстека (NavBackStackEntry) имеет свой собственный SavedStateHandle.

    Почему не обычные переменные?

    Если вы просто передадите данные через конструктор ViewModel или сохраните их в обычное поле класса, то при сворачивании приложения и нехватке памяти Android уничтожит ваш процесс. Когда пользователь вернется, приложение перезапустится, но переменные будут сброшены в значения по умолчанию. SavedStateHandle восстановит данные.

    Часть 1: Forward Navigation (Передача аргументов)

    С выходом Navigation 2.8.0 и поддержкой Type-Safe навигации (о которой мы говорили во второй статье), работа с аргументами стала тривиальной. Вам больше не нужно парсить строки вручную.

    Автоматическая интеграция с ViewModel

    Когда вы используете koinViewModel() или hiltViewModel(), библиотека навигации автоматически заполняет SavedStateHandle аргументами, извлеченными из маршрута (Route).

    Допустим, у нас есть маршрут:

    Внутри UserDetailsViewModel мы можем получить этот объект целиком, используя расширение toRoute():

    Это полностью избавляет от необходимости использовать ключи-строки типа savedStateHandle["userId"]. Компилятор гарантирует, что если вы перешли на этот экран, аргументы корректного типа уже находятся внутри.

    Часть 2: Backward Navigation (Возврат результата)

    Более сложная задача — вернуть данные назад. Классический пример: экран «Профиль» открывает экран «Выбор города». Пользователь выбирает город, экран закрывается, и «Профиль» должен узнать, что был выбран «Санкт-Петербург».

    Google не предоставляет встроенного callback-механизма для этого. Согласно документации Android Developers, мы должны использовать SavedStateHandle предыдущего элемента бэкстека.

    Механика процесса

  • Screen B (Child): Получает доступ к previousBackStackEntry (запись экрана А) и кладет данные в его SavedStateHandle.
  • Screen B: Вызывает popBackStack().
  • Screen A (Parent): Наблюдает за своим currentBackStackEntry и реагирует на появление новых данных в SavedStateHandle.
  • Проблема строковой типизации

    Стандартный подход выглядит так:

    Это небезопасно. Мы используем хардкод-строку "city_result" и теряем информацию о типе (значение приводится к Any?). Опечатка в ключе приведет к тому, что родительский экран никогда не получит данные.

    Решение: Контракты результатов

    Чтобы сделать возврат данных типобезопасным, мы применим паттерн Result Contract. Мы определим типизированные ключи.

    Создадим абстракцию для ключа результата:

    Теперь напишем extension-функции для NavController, которые инкапсулируют работу с SavedStateHandle и previousBackStackEntry.

    Математика состояния при возврате

    Рассмотрим процесс возврата результата как функцию изменения состояния родительского экрана. Пусть — состояние родительского экрана в момент времени , а — возвращаемый результат.

    Обновление состояния можно описать формулой:

    где — новое состояние экрана (например, отображение выбранного города), — предыдущее состояние, а — результат, полученный из SavedStateHandle.

    Важно отметить, что должен быть одноразовым событием. Если мы не удалим из хранилища после применения функции , то при повороте экрана (рекомпозиции) формула сработает снова:

    Это может привести к нежелательным эффектам (например, повторному показу SnackBar или лишнему запросу в сеть). Именно поэтому в коде выше мы вызываем savedStateHandle.remove(key.key).

    Практический пример: Выбор города

    Соберем всё вместе. У нас есть ProfileScreen и CitySelectionScreen.

    1. Определение контракта (API модуль)

    2. Дочерний экран (Отправка)

    3. Родительский экран (Получение)

    Обработка сложных объектов

    Хотя SavedStateHandle поддерживает сохранение примитивов и строк, передача сложных объектов требует, чтобы они реализовывали интерфейс Parcelable (в Android) или Serializable.

    Однако, согласно статье на habr.com, передача больших объектов через навигацию — плохая практика. Буфер транзакций Android ограничен (около 1 МБ на все процессы). Если вы попытаетесь вернуть огромный список объектов через SavedStateHandle, приложение упадет с ошибкой TransactionTooLargeException.

    Рекомендация: Возвращайте только ID или минимальные данные (строки, числа, enum). Если нужно передать сложный объект, сохраните его в базу данных или репозиторий на дочернем экране, верните ID, а родительский экран подгрузит актуальные данные по этому ID.

    Жизненный цикл данных

    Понимание того, как долго живут данные в SavedStateHandle, критически важно.

  • Создание: Данные появляются, когда дочерний экран вызывает setResult. В этот момент родительский экран находится в состоянии STARTED или RESUMED (если виден в режиме split-screen), либо STOPPED (если полностью перекрыт).
  • Потребление: Родительский экран становится активным (RESUMED), срабатывает observeResult.
  • Уничтожение: Если родительский экран будет удален из стека (пользователь нажмет «Назад» на нем), его SavedStateHandle очистится.
  • Если вы используете SharedViewModel (как описано в предыдущей статье про Koin), то SavedStateHandle привязан к графу навигации. Это означает, что данные будут жить до тех пор, пока жив сам граф.

    Итоги

    * SavedStateHandle — это надежный механизм для хранения аргументов и результатов навигации, переживающий смерть процесса. * Forward Navigation: Используйте savedStateHandle.toRoute<T>() внутри ViewModel для автоматического получения типобезопасных аргументов. * Backward Navigation: Используйте previousBackStackEntry.savedStateHandle для возврата данных. * Типобезопасность: Избегайте сырых строк. Создавайте объекты-ключи (NavigationResultKey<T>) и extension-функции для гарантии соответствия типов. * Правило одного раза: Всегда удаляйте результат из SavedStateHandle после обработки, чтобы избежать повторного срабатывания при повороте экрана.

    5. Продвинутые техники: Navigation3 и особенности Compose Multiplatform

    Продвинутые техники: Navigation3 и особенности Compose Multiplatform

    Мы прошли долгий путь: от базового понимания NavHost до построения модульной архитектуры и внедрения Koin. Казалось бы, мы знаем о навигации всё. Однако экосистема Kotlin развивается стремительно. С выходом Compose Multiplatform (KMP) перед разработчиками встал новый вызов: как использовать привычные инструменты навигации на iOS, Desktop и Web, где нет понятий Activity, Fragment или Bundle.

    В этой финальной статье курса мы разберем концепцию Navigation3 — новый подход к навигации, который отказывается от жесткого графа в пользу гибкого управления состоянием, и рассмотрим особенности реализации навигации в кроссплатформенных проектах.

    Эволюция: От графа к состоянию (Navigation3)

    Классический Jetpack Navigation (Nav2) строится вокруг статического графа навигации. Вы определяете NavHost, внутри него — composable маршруты. Это работает отлично, пока ваши требования стандартны. Но что, если вам нужно динамически менять структуру навигации или вы хотите полностью отвязать логику переходов от UI-фреймворка?

    Navigation3 (или подход State-Driven Navigation) — это смена парадигмы. Вместо того чтобы говорить контроллеру «перейди туда», вы просто меняете состояние списка экранов, а UI реактивно перерисовывается.

    В чем суть Navigation3?

    В Nav2 NavController — это «черный ящик», который управляет стеком внутри себя. В Navigation3 (и подобных подходах, таких как Circuit от Slack или Decompose) навигация — это просто список ключей (экранов).

    Согласно habr.com, основу такой архитектуры составляет объект состояния, который хранит ключи навигации. Это делает навигацию предсказуемой и легко тестируемой, так как стек — это просто List<NavKey>.

    Математическая модель навигации

    В подходе Navigation3 состояние навигации в любой момент времени можно описать как упорядоченное множество (кортеж) экранов:

    где — состояние стека в момент времени , — корневой экран, а — текущий видимый экран.

    Операция перехода вперед (Push) описывается добавлением элемента в конец кортежа:

    где — новое состояние стека, — предыдущее состояние, а — новый экран, добавляемый в стек.

    Операция возврата (Pop) — это усечение кортежа:

    где становится новым активным экраном. В отличие от Nav2, где эти операции скрыты, в Navigation3 вы управляете этим списком явно, что дает полный контроль над историей переходов.

    Compose Multiplatform: Проблема контекста

    Главная сложность при переносе навигации в KMP — это зависимость классического Jetpack Navigation от Android SDK. SavedStateHandle, ViewModel, Parcelable — всё это исторически было частью Android.

    Однако Google и JetBrains проделали огромную работу. Начиная с версий Navigation 2.8.0+, многие компоненты стали мультиплатформенными. Но «под капотом» реализация отличается.

    Сериализация как основа

    В Android мы могли положить в Bundle любой объект, реализующий Parcelable. В KMP нет Parcelable (хотя есть экспериментальные аннотации). Единым стандартом передачи данных стала библиотека kotlinx.serialization.

    Любой аргумент навигации в KMP обязан быть сериализуемым:

    Это требование становится жестким ограничением, но оно же гарантирует, что ваше приложение не упадет при попытке сохранить состояние на iOS или Desktop.

    Реализация Navigation3 в KMP

    Давайте рассмотрим, как построить гибкую навигацию в KMP, используя подход Navigation3. Мы не будем использовать NavHost в привычном виде, а создадим свой контейнер, который рендерит экраны на основе состояния.

    1. Определение ключей (NavKey)

    Вместо строковых маршрутов мы используем интерфейс-маркер:

    2. Состояние навигации

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

    3. Рендеринг контента (Content Provider)

    Вместо composable("route") мы используем простую функцию when, которая сопоставляет ключ с UI. Это чистый Kotlin код, который работает везде.

    Такой подход, описанный в статье на habr.com, позволяет полностью контролировать процесс создания экранов и внедрения зависимостей.

    Интеграция Koin в KMP Navigation

    В Android Koin автоматически привязывается к ViewModelStoreOwner (Activity или Fragment). В KMP (особенно на Desktop или iOS) понятие ViewModelStore реализуется иначе.

    Scopes в мультиплатформенной среде

    При использовании Navigation3 мы должны сами управлять временем жизни скоупов Koin. Когда ключ удаляется из стека (pop), мы должны закрыть соответствующий скоуп.

    Согласно habr.com, правильное управление скоупами критически важно для предотвращения утечек памяти, так как в KMP сборщик мусора работает по-разному на разных платформах (например, Kotlin/Native использует свой механизм управления памятью).

    Особенности платформенной навигации

    Хотя логика навигации общая, взаимодействие с системными кнопками отличается.

    Обработка кнопки «Назад»

    * Android: Необходимо перехватывать BackHandler. В Nav3 вы просто вызываете state.pop() внутри BackHandler. * iOS: Здесь нет физической кнопки «Назад». Навигация обычно осуществляется через свайп от левого края. Для реализации этого в Compose Multiplatform часто используют обертку PredictiveBackGestureOverlay или нативные контроллеры (UINavigationController), встраивая Compose View внутрь. * Desktop: Кнопка «Назад» может быть в заголовке окна или отсутствовать вовсе.

    Deep Links

    В KMP нет единого механизма Deep Links. * На Android это Intent с data URI. * На iOS это Universal Links.

    Вам придется написать платформенный код, который парсит входящую ссылку, превращает её в ваш NavKey и передает в общий код (shared module), который затем делает state.push(parsedKey).

    Производительность и рекомпозиция

    В подходе Navigation3 важно следить за тем, чтобы изменение стека не вызывало рекомпозицию всего дерева UI. Использование derivedStateOf для вычисления текущего экрана помогает оптимизировать этот процесс.

    Эффективность рендеринга можно условно выразить как:

    где — эффективность, а — количество узлов, подвергшихся рекомпозиции. Чем меньше узлов перерисовывается при смене currentKey, тем выше плавность анимации перехода.

    Итоги

    * Navigation3 — это отказ от жесткого графа в пользу управления состоянием (List<NavKey>). Это дает максимальную гибкость и упрощает тестирование. * Compose Multiplatform требует использования kotlinx.serialization для всех аргументов навигации, так как Parcelable недоступен на других платформах. * NavKey вместо Route — использование типобезопасных объектов-ключей (sealed classes/interfaces) является стандартом для KMP навигации. * Ручное управление Scopes — в KMP часто приходится вручную создавать и закрывать Koin Scopes или использовать специальные обертки, так как автоматическая привязка к жизненному циклу Android-компонентов отсутствует. * Платформенные различия — логика переходов общая, но триггеры (кнопка «Назад», жесты, Deep Links) требуют реализации на уровне платформы.