NavHost регистрирует OnBackPressedCallback, который включён, пока в его стеке больше одной записи, поэтому «Назад» сначала снимает верхний экран со стека NavController; только на стартовом destination нажатие проходит дальше и завершает Activity.
Два стека: системная задача (task) хранит Activity, а NavController хранит объекты NavBackStackEntry внутри одной Activity. Друг о друге они ничего не знают.
Свой обработчик: BackHandler(enabled = dirty) на экране стоит выше колбэка NavHost, пока этот экран в композиции. Так диалог «отменить изменения?» перехватывает «Назад».
Где они расходятся: вторая Activity стартует со своим стеком навигации из одной записи, а deep link в плоском графе даёт синтезированный стек только Home -> Target, поэтому «Назад» выходит раньше, чем ожидает пользователь.
AndroidMiddle
Навигация в Android
Navigation Component, Safe Args, типизированные маршруты, deep links, back stack, predictive back
Закрыто вопросов
0 / 20
- 0Повторить
- 2Часто спрашивают
Вопросы
Back хронологический: он отменяет последний шаг пользователя и может вывести из приложения. Up иерархический: он ведёт на логический родительский экран и никогда не покидает ваше приложение.
В Navigation Back идёт через OnBackPressedDispatcher в popBackStack(), а Up означает стрелку в верхней панели, связанную с navController.navigateUp().
Они совпадают, когда пользователь пришёл по иерархии. После deep link из другого приложения они расходятся: Back возвращает в то приложение, Up открывает родителя внутри вашего.
Верхнеуровневые destination (по умолчанию только стартовый) стрелку Up не показывают.
Аргумент хранится как копия, замороженная в Bundle в момент перехода, поэтому целый объект устаревает, съедает лимит сохранённого состояния и не может прийти из deep link. Передавайте id, а актуальные данные экран пусть загружает из репозитория.
Устаревание: синхронизация или правка на другом экране меняет запись, а экран деталей продолжает показывать старую копию.
Размер: аргументы пишутся в saved instance state, который проходит через Binder с лимитом около 1 МБ на всю транзакцию; большой объект заканчивается TransactionTooLargeException.
Достижимость: deep link или уведомление может передать только то, что помещается в URL, а после смерти процесса стек восстанавливается только из сохранённых аргументов.
Быстрый первый кадр: при необходимости добавьте рядом с id одно-два лёгких поля (заголовок).
ViewModel читает id из SavedStateHandle и подписывается на репозиторий.
Для XML-графов Gradle-плагин Safe Args генерирует классы Directions и Args из тегов <argument>; начиная с Navigation 2.8 можно вместо этого объявить @Serializable-класс маршрута, переходить с его экземпляром и читать обратно через toRoute(). В обоих случаях всё оказывается в Bundle.
Safe Args: плагин androidx.navigation.safeargs.kotlin. Генерирует ListFragmentDirections.actionListToDetail(orderId) для вызывающей стороны и DetailFragmentArgs, который читают через by navArgs(). Типы берутся из app:argType.
Типобезопасные маршруты: в вашем коде ничего не генерируется; kotlinx.serialization выводит маршрут и NavType для каждого поля. Для своего типа нужна запись в typeMap.
Во ViewModel: savedStateHandle.toRoute<Detail>() отдаёт тот же объект, в том числе после смерти процесса.
Легаси: строковые маршруты вида "detail/{id}" с navArgument по-прежнему компилируются, но ключи и типы никто не проверяет на этапе сборки.
Ограничения: только данные для Bundle: примитивы, строки, enum, Parcelable/Serializable. Никаких лямбд, Context, Bitmap или Flow.
popUpTo(X) перед переходом снимает со стека всё, что выше X, inclusive = true снимает и сам X, а launchSingleTop переиспользует destination, если он уже наверху. После входа один вызов navigate(Home) с popUpTo<AuthGraph> { inclusive = true } не оставляет пути назад.
**Забыли inclusive: цель остаётся в стеке, и «Назад» снова приводит на Login. Это самый частый вариант бага.
Очистить всё**: снимите со стека корневой граф вместе с ним: popUpTo(navController.graph.id) { inclusive = true }. Запись корневого графа всегда лежит в стеке.
**launchSingleTop: двойной тап или продублированное событие не кладут вторую копию.
Тихий no-op**: popUpTo с маршрутом, которого нет в стеке, ничего не снимает и пишет только строку уровня info. Когда стек выглядит странно, начните с logcat.
Один вызов: цепочка popBackStack() и затем navigate показывает промежуточные экраны.
**Без saveState**: иначе при следующем входе могут восстановиться записи и ViewModel предыдущего пользователя.
Вложенный граф объединяет destination в один флоу со своим стартовым экраном и сам лежит в стеке отдельной записью. Если взять эту запись как ViewModelStoreOwner, все экраны флоу получат одну общую ViewModel, которая очищается, когда флоу снимают со стека.
Объявление: navigation<WizardGraph>(startDestination = Step1) { ... } или <navigation> в XML. Снаружи переходят на граф, а не на его внутренние экраны.
Общий экземпляр: hiltViewModel(navController.getBackStackEntry<WizardGraph>()); во фрагментах используют by navGraphViewModels(R.id.wizard_graph) или hiltNavGraphViewModels.
Ключ для поиска: оборачивайте его в remember(entry) по записи текущего destination, чтобы не держать запись графа из прошлого захода.
Только внутри графа: getBackStackEntry бросает IllegalArgumentException, если графа нет в стеке.
Тихий баг: обычный hiltViewModel() на каждом шаге даёт каждому шагу свой экземпляр, и ничто об этом не предупредит.
Смерть процесса: ViewModel пропадает; то, что должно выжить, храните в её SavedStateHandle или в хранилище.
Запишите значение в previousBackStackEntry.savedStateHandle и сделайте pop; вызывающий экран наблюдает за SavedStateHandle своей записи. Это переживает поворот и смерть процесса, в отличие от колбэка или общей ViewModel.
**Значения для Bundle: передавайте id, а не объект.
Прочитать один раз**: наблюдайте через getStateFlow(...) и collectAsStateWithLifecycle(), обработайте, затем сделайте remove() ключа, иначе старый результат применится снова при следующем заходе.
Фрагменты: Fragment Result API (setFragmentResult / setFragmentResultListener) делает то же для фрагментов и диалогов.
Общая ViewModel: подходит внутри одного флоу, но теряется при смерти процесса, если не опирается на SavedStateHandle.
Колбэки: лямбда не может быть аргументом маршрута, а синглтон-«шина результатов» удерживает вызывающий экран в памяти и доставляет результат экрану, которого может уже не быть.
Результат записывается после popBackStack(). Pop синхронный, поэтому к этому моменту previousBackStackEntry указывает на запись под Search (здесь Home): город попадает в SavedStateHandle экрана Home, и Search его так и не видит.
Почему так происходит: popBackStack() обновляет стек ещё до следующей строки: currentBackStackEntry теперь указывает на Search, а previousBackStackEntry указывает на Home. Будь Search стартовым destination, previousBackStackEntry оказался бы null, и цепочка ?. молча выбросила бы результат.
Исправление: запишите значение в previousBackStackEntry.savedStateHandle, пока пикер ещё наверху, и только потом делайте pop. getStateFlow на Search отдаст id, когда экран снова попадёт в композицию.
Как поймать в следующий раз: симптом тихий (исключения нет, значение уходит в SavedStateHandle не той записи или в никуда), поэтому нужен тест с TestNavHostController: перейти на Search, затем на CityPicker, вызвать выбор и проверить currentBackStackEntry?.savedStateHandle?.get<Long>("city_id") == 7L.
Каждый тап вызывает обычный navigate, поэтому вкладки складываются в один стек и ничего не сохраняется. Переключение вкладки должно снимать со стека всё до стартового destination с saveState = true и переходить с restoreState = true и launchSingleTop = true.
**popUpTo(graph.findStartDestination().id) { saveState = true }: убирает уходящую вкладку из стека и откладывает её записи: аргументы, сохранённое состояние и ViewModel.
restoreState = true**: возвращает отложенное состояние открываемой вкладки: её глубину, значения rememberSaveable и скролл LazyListState.
**launchSingleTop = true: повторные тапы по той же вкладке не кладут копии её корня.
selected**: проверяйте destination.hierarchy, а не сам destination, чтобы вкладка оставалась выделенной, пока пользователь глубоко внутри. Дайте каждой вкладке свой вложенный граф.
Не восстанавливается: обычный remember, потому что композиция уничтожается, а LaunchedEffect(Unit) запускается заново при каждом заходе. Каждая сохранённая вкладка держит свои ViewModel в памяти.
Тело composable может выполняться много раз (рекомпозиция, кадры анимации), поэтому navigate(), написанный там, срабатывает снова и снова и копит Login в стеке. Навигация относится к побочным эффектам: делайте её в LaunchedEffect или по событию из ViewModel, а лучше выберите стартовый destination до появления NavHost.
Почему дважды: тело выполняется снова, пока старая запись ещё в композиции во время анимации выхода, и каждый проход снова вызывает navigate() и кладёт ещё один Login.
Решить заранее: когда сессия известна, выберите startDestination = Home или AuthGraph до показа NavHost. Неавторизованный пользователь просто не попадёт в Profile.
Проверка на конкретном экране: ViewModel отправляет событие через Channel(...).receiveAsFlow(), которое собирается в одном LaunchedEffect. Флаг в StateFlow повторится после поворота и снова вызовет переход.
Подстраховка: launchSingleTop = true при переходе на Login, а после входа снимите со стека граф авторизации.
LaunchedEffect(loggedIn) { if (!loggedIn) navigate(Login) } даёт минимальное исправление; решение заранее или из ViewModel вообще не даёт Profile попасть в композицию.
Результат обратно: Login сообщает об успехе через previousBackStackEntry.savedStateHandle (флаг LOGIN_SUCCESSFUL), как в руководстве Android по условной навигации.
Второй тап ничто не останавливает: во время анимации выхода список ещё на экране и кликабелен, поэтому двойной тап вызывает navigate() дважды и кладёт OrderDetail в стек два раза. Если обернуть клик в dropUnlessResumed, все тапы после первого перехода игнорируются.
Почему так происходит: navigate() кладёт запись в стек синхронно, но NavHost анимирует переход (по умолчанию fade), и уходящий экран продолжает получать ввод, пока не выйдет из композиции. Каждый тап вызывает отдельный navigate() со своей записью.
Как работает исправление: dropUnlessResumed (lifecycle-runtime-compose 2.8+) в момент клика проверяет LocalLifecycleOwner, здесь это NavBackStackEntry экрана. Первый navigate() переводит запись Orders из RESUMED в STARTED, поэтому второй тап отбрасывается.
**launchSingleTop даёт лишь частичную защиту**: он защищает только от повтора того же destination наверху; тапы по двум разным строкам (OrderDetail(1), затем OrderDetail(2)) или по строке и иконке в тулбаре всё равно положат в стек два экрана, а dropUnlessResumed отсекает все.
Без хелпера: та же проверка вручную: переходить, только если entry.lifecycle.currentState == Lifecycle.State.RESUMED.
Как поймать в следующий раз: считайте голый navigate() внутри onClick поводом для замечания на ревью и добавьте двойные тапы по строкам списков в проверки QA.
Когда deep link запускает новую задачу, Navigation строит стек по иерархии графа: из стартовых destination родительских графов. Положите OrderDetail во вложенный граф со стартовым destination OrderList, и «Назад» приведёт к списку.
Плоский граф: если детали лежат рядом со стартовым destination, синтезированный стек выглядит просто как Home -> OrderDetail; списка, куда вернуться, нет.
Свои уведомления: собирайте PendingIntent через TaskStackBuilder с интентом ACTION_VIEW или через NavDeepLinkBuilder для XML-графов; середину заполняет то же правило иерархии.
**onNewIntent**: у Activity с singleTop или singleTask последующие ссылки приходят туда, а не в onCreate. Передайте их в navController.handleDeepLink(intent), иначе тап ничего не сделает.
Тест с холодного старта: adb shell am start -a android.intent.action.VIEW -d "https://shop.example.com/order/42", а не переходом внутри запущенного приложения.
Синтезированные записи: их никогда не было на экране, поэтому список загружает данные, только когда пользователь возвращается назад.
Deep link означает любой URI, который совпал с вашим intent-filter, поэтому пользователь может увидеть выбор приложения. App Link представляет собой https-ссылку с android:autoVerify="true", которую система проверяет по assetlinks.json вашего домена и затем сразу открывает в приложении.
Файл верификации: https://<host>/.well-known/assetlinks.json содержит имя пакета и SHA-256-отпечаток сертификата подписи. При Play App Signing это ключ Play из Console, а не ваш upload-ключ.
Android 12+: неверифицированная веб-ссылка открывается в браузере, а не в диалоге выбора, так что сломанный assetlinks.json молча отправляет пользователей на сайт.
Свои схемы вроде myapp:// верифицировать нельзя; их может зарегистрировать любое приложение, поэтому их параметрам не доверяют.
Отладка: adb shell pm get-app-links <package> показывает статус по каждому домену, adb shell pm verify-app-links --re-verify <package> запускает проверку заново.
Navigation: destination всё так же объявляет navDeepLink; в манифесте нужен <intent-filter> (или <nav-graph> для XML-графов).
У каждого destination в стеке свой жизненный цикл, своё хранилище ViewModel и своё сохранённое состояние, и всем этим владеет его запись. Они переживают поворот и перекрытие другим экраном и уничтожаются вместе, когда запись снимают со стека.
Жизненный цикл: верхняя запись находится в RESUMED, запись под полноэкранным destination переходит в CREATED, а под диалогом остаётся в STARTED. collectAsStateWithLifecycle() внутри destination следует за записью, поэтому сбор останавливается, когда экран перекрыт.
ViewModelStore: viewModel() или hiltViewModel() внутри composable { } привязаны к записи; onCleared() вызывается при pop, а не при повороте.
Сохранённое состояние: rememberSaveable и SavedStateHandle ViewModel живут в записи и восстанавливаются после смерти процесса.
Композиция и запись: композиция невидимой записи уничтожается, а сама запись живёт: rememberSaveable выживает, а remember не выживает.
Две записи дают две ViewModel: два перехода на один и тот же маршрут ничего не разделяют.
Исключение: pop с saveState = true сохраняет состояние и ViewModel записи для последующего restoreState.
По умолчанию берите одну Activity: один NavHost владеет всем стеком, поэтому переходы, общие ViewModel и deep links живут в одном месте. Вторая Activity нужна только там, где сама граница задачи или окна и есть смысл.
Что даёт одна Activity: один стек, ViewModel на граф для целого флоу, единые анимации переходов и predictive back, одна точка входа для deep links.
Когда уместна вторая: системная точка входа, которая может жить в своей задаче (получатель ACTION_SEND, экран настройки виджета), другой launchMode, taskAffinity или процесс, либо окно со своей темой и ориентацией, например камера или видеоплеер.
Не причина: вход, онбординг или модальный флоу. Делайте их вложенными графами.
Легаси-приложения: мигрируют поэкранно: сначала NavHost в каждой Activity, затем Activity объединяют.
Predictive back показывает превью того, куда ведёт «Назад», пока пользователь ещё ведёт пальцем, поэтому система должна знать до начала жеста, обработает ли его приложение. Это сообщают включёнными колбэками, а не переопределением onBackPressed().
Включение: android:enableOnBackInvokedCallback="true" в манифесте. Для приложений с target Android 16 (API 36) это поведение по умолчанию, а onBackPressed() и KEYCODE_BACK больше не доставляются.
Миграция: замените переопределения onBackPressed() на OnBackPressedCallback во View или BackHandler в Compose, а isEnabled выставляйте из состояния. Колбэк, который включён всегда и решает внутри обработчика, блокирует анимацию.
Анимации бесплатно: возврат на домашний экран и переход между Activity рисует система. Navigation Compose 2.8+ показывает превью предыдущего экрана через ваши popEnterTransition и popExitTransition; шиты, drawer и search bar из Material 3 следуют за пальцем.
Своя анимация: PredictiveBackHandler даёт Flow<BackEventCompat>: следуйте за progress, завершайте действие, когда поток закончился, и откатывайте при CancellationException.
Экраны не должны знать про NavController: они принимают лямбды вроде onOpenOrder: (Long) -> Unit, и тест проверяет вызов лямбды. Связку графа тестируйте отдельно через TestNavHostController, проверяя стек.
Экраны: обычные UI-тесты Compose; библиотека навигации не участвует, поэтому они быстрые.
Граф: TestNavHostController из navigation-testing с добавленным ComposeNavigator, переданный в ваш настоящий NavHost. Кликните и проверьте currentBackStackEntry?.destination?.hasRoute<OrderDetail>(), а аргументы прочитайте через toRoute<OrderDetail>().
**Проверка popUpTo**: сверяйте весь currentBackStack.value, а не только верхнюю запись.
Deep links: navController.navigate(uri) лишь проверяет, что ссылка совпала. Чтобы увидеть синтезированный родительский стек, передайте VIEW-Intent через handleDeepLink(intent) или запустите с ним Activity (ActivityScenario или вручную adb shell am start).
Все фичи должен видеть только :app: каждая фича отдаёт расширение NavGraphBuilder и принимает исходящую навигацию лямбдами, а :app их связывает. Второй вариант предполагает общий модуль :core:navigation, где лежат только классы маршрутов.
Билдеры вверх, лямбды вниз
fun NavGraphBuilder.checkoutSection(onOpenProfile: (Long) -> Unit); экраны остаются internal. Так устроен Now in Android.
Фичи остаются независимыми и тестируются изолированно. Цена: длинные списки лямбд в :app.
Общий модуль маршрутов
:core:navigation содержит только @Serializable-маршруты, так что navigate(Profile(id)) проверяется компилятором из любой фичи.
Цена: модуль разрастается в каталог всех экранов, и любое его изменение перекомпилирует все фичи.
Разделите каждую фичу на :feature:x:api (только маршруты) и :feature:x:impl вместо одного :core:navigation; тогда общими остаются только маленькие api-модули.
Без прямой ссылки
**Обычные модули, которые :app не должен связывать вручную**: каждая фича кладёт NavGraphBuilder.() -> Unit в Hilt-мультибайндинг @IntoSet, а :app подключает их в цикле.
Dynamic feature-модули (не видны на этапе сборки): переходите по Uri, который совпадает с navDeepLink цели; для графов на фрагментах есть navigation-dynamic-features-fragment с <include-dynamic>.
Лишний уровень косвенности убирает проверку компилятора: ссылка, которая перестала совпадать, падает в рантайме. Добавьте тест, который резолвит каждую объявленную ссылку.
Линейный стек предполагает один видимый destination. С двумя панелями List -> Detail утверждает, что пользователь ушёл со списка, который всё ещё на экране, и «Назад» как будто ничего не делает. Держите list-detail одним destination, а выбор пусть хранит навигатор панелей.
Что ломается
«Назад» возвращает на список, который никуда не уходил.
При складывании или повороте в компактную ширину стек приходится переосмысливать, и проверки размера окна расползаются по всем вызовам navigate.
Что работает
Один destination NavController на весь экран. Внутри него rememberListDetailPaneScaffoldNavigator<Long>() из Material 3 adaptive хранит историю панелей и выбор и переживает смену конфигурации.
NavigableListDetailPaneScaffold показывает две панели, когда позволяет window size class, и одну в остальных случаях, а также обрабатывает «Назад» между панелями.
navigateTo и navigateBack объявлены как suspend-функции; вызывайте их из rememberCoroutineScope().
Пустая панель деталей считается нормальным состоянием на планшете. Выбор хранится как ключ; панель деталей сама загружает свои данные.
В Navigation 3 ту же идею реализует стратегия сцен вроде ListDetailSceneStrategy, которая рисует несколько записей стека в одной сцене.
Navigation 3 (стабильна с конца 2025 года) делает стек снапшот-списком ключей, которым вы владеете и который сами меняете, а NavDisplay рисует то, что в нём лежит. Навигация превращается в обычные операции со списком вместо вызовов NavController.
Что вы получаете
Условные флоу пишутся на обычном Kotlin: if (!loggedIn) backStack.add(Login).
Ключами служат типизированные объекты, а entryProvider сопоставляет каждому типу ключа его контент.
SceneStrategy может показывать несколько записей сразу: list-detail, диалоги или своя сцена.
Стек является тестируемым состоянием: тест проверяет список.
Что теперь пишете сами
popUpTo, launchSingleTop, стеки по вкладкам (по списку на вкладку) и превращение deep link в начальный список.
Состояние на запись дают декораторы: rememberSaveableStateHolderNavEntryDecorator() для rememberSaveable, rememberViewModelStoreNavEntryDecorator() из lifecycle-viewmodel-navigation3 для ViewModel.
rememberNavBackStack переживает смерть процесса только потому, что ключи @Serializable и реализуют NavKey.
Когда переходить
Только Compose: нет поддержки фрагментов, XML-графов и NavigationUI. Хорошо подходит новым Compose-приложениям и адаптивным layout; Navigation 2 продолжает поддерживаться, поэтому у работающего приложения нет срочной причины мигрировать.