12 авг. 2025 г.·7 мин чтения

Ошибки навигации в SwiftUI: глубокие ссылки, авторизация и холодный запуск

Ошибки навигации в SwiftUI часто проявляются, когда deep links встречаются с экранами входа и холодным запуском. Узнайте, как моделировать маршруты, задавать правила для модалок и строить безопасные пути восстановления.

Ошибки навигации в SwiftUI: глубокие ссылки, авторизация и холодный запуск

Почему это ломается в реальных приложениях

Многие ошибки навигации в SwiftUI начинаются с одного неверного предположения: приложение думает, что каждый пользователь заходит через главный экран. В реальности это не так. Люди открывают письмо со ссылкой, push-уведомление, общую ссылку или сохранённый ярлык. Такая ссылка часто ведёт глубоко внутрь приложения и пропускает обычный путь, который ожидает приложение.

Обычный запуск приложения и без того не простой. SwiftUI строит первый экран, восстанавливает состояние, проверяет авторизацию, загружает данные пользователя и может показать onboarding или обязательное обновление. Если в этот момент приходит deep link, приложение может попытаться открыть экран раньше, чем навигационный стек будет готов. Итог получается быстро и неприятно.

Авторизация всё усложняет. Ссылка может открывать страницу с деталями заказа, но пользователь не вошёл в аккаунт. Теперь у приложения две задачи одновременно: попросить пользователя войти и запомнить, куда он хотел попасть. Если приложение слишком рано откроет целевой экран, пользователь увидит пустую страницу. Если сначала покажет вход, но забудет целевой маршрут, после входа он попадёт не туда. Если оба потока реагируют на изменения состояния, приложение может начать метаться между экранами по кругу.

Модальные окна добавляют ещё один слой проблем. Холодный запуск может почти одновременно вызвать запрос разрешения, уведомление об обновлении, форму входа и экран по deep link. SwiftUI плохо справляется с такой кучей событий. Одно модальное окно может закрыть другое. Одно закрытие может показать не тот экран под ним. Иногда кажется, что приложение зависло, но на самом деле две части приложения просто спорят, что должно появиться следующим.

Небольшой пример показывает типичный сценарий. Пользователь открывает ссылку на биллинг, пока приложение закрыто. Приложение запускается, видит истёкшую сессию, показывает вход, обновляет данные аккаунта, а потом пытается открыть биллинг раньше, чем появятся tab view и navigation path. Пользователь видит вспышку одного экрана, потом другого, а в итоге не попадает никуда полезного.

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

Сначала смоделируйте маршруты, а потом открывайте экраны

Большинство ошибок навигации в SwiftUI начинаются ещё до появления первого экрана. Они начинаются с того, как приложение описывает, куда может попасть пользователь.

Если у каждого экрана своё @State-переключение, приложение перестаёт само с собой соглашаться. Один экран думает, что должна открыться форма входа. Другой открывает страницу с деталями. Третий сбрасывает стек при изменении авторизации. Именно так deep links превращаются в петли, пустые экраны или неправильную вкладку.

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

enum AppTab { case home, search, account }
enum Screen: Hashable { case product(id: String), order(id: String) }
enum Modal: Identifiable { case login
    var id: String { "login" }
}

struct AppRoute {
    var tab: AppTab = .home
    var path: [Screen] = []
    var modal: Modal?
    var pendingScreen: Screen?
    var isAuthenticated = false
}

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

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

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

Эта модель не уберёт все ошибки, но сильно сократит количество ошибок навигации в SwiftUI, потому что у приложения появляется одна история о том, где пользователь находится, куда хотел попасть и что должно произойти сначала.

Установите чёткие правила для модальных окон

Большинство ошибок навигации в SwiftUI начинаются не со стека push-экранов. Они начинаются в тот момент, когда две части приложения пытаются показать разный интерфейс одновременно. Deep link хочет открыть экран деталей, слой авторизации хочет показать форму входа, а обработчик ошибки хочет вывести alert. Обычно SwiftUI превращает это в мерцание, предупреждения или экран, который так и не появляется.

Push-экраны и модальные экраны должны выполнять разные задачи. Push используйте тогда, когда пользователь углубляется в ту же задачу. Sheet или full-screen cover показывайте, когда приложение прерывает основной путь, просит принять решение или запускает отдельный сценарий, например вход, переключение аккаунта или запрос разрешения.

Один владелец модалок

Выберите одно место в приложении, которое отвечает за показ модальных окон. Во многих приложениях это корневой контейнер над вкладками и navigation stack. Дочерние экраны могут просить показать модалку, но не должны показывать её сами. Это одно правило уже убирает много ошибок навигации в SwiftUI.

Правила должны быть скучными и строгими:

  • Одновременно может быть активна только одна модалка.
  • Вход блокирует все остальные sheets, пока не завершится или пользователь не отменит действие.
  • Alerts не должны появляться поверх входа, если только alert не объясняет проблему со входом.
  • Deep links могут ставить маршрут в очередь, но должны ждать, пока модалка закроется.
  • Экран, который не виден, не может ничего показывать.

Холодный запуск хорошо показывает, почему это важно. Допустим, пользователь открывает ссылку на закрытый счёт-фактуру. Приложение запускается, восстанавливает состояние, проверяет авторизацию и видит, что сессия истекла. Root view показывает вход. Счёт-фактуру оно пока не открывает. Оно сохраняет целевой маршрут, ждёт завершения входа, закрывает модалку и только потом открывает счёт-фактуру. Если вход не удался, приложение остаётся в одном понятном состоянии вместо того, чтобы метаться между экранами.

Простые правила лучше хитрых. Если форма входа, диалог разрешения и запрос на обновление могут конкурировать между собой, пользователь быстро теряет контекст. Запишите, какой поток побеждает, какой ждёт, а какой отменяется. Потом держите эту логику в одном coordinator, а не размазывайте её по экранам.

Разбирайте холодный запуск по шагам

Холодный запуск — это место, где начинаются многие ошибки навигации в SwiftUI. У приложения ещё нет живого состояния навигации, но ссылка уже требует конкретный экран. Если вы создадите экраны слишком рано, SwiftUI может показать не тот стек, мелькнуть экраном входа или отправить пользователя на главную.

Исправление простое: относитесь к запуску приложения как к маленькому state machine. Прочитайте ссылку, решите, куда она ведёт, проверьте, может ли пользователь туда попасть, и только потом стройте состояние навигации.

  1. Сразу после запуска прочитайте входящий URL. Сделайте это до того, как откроете какой-либо экран из сохранённого состояния приложения или выбора вкладки по умолчанию.
  2. Разберите URL в один объект маршрута. Этот маршрут должен включать целевой экран и все нужные значения, например project ID, invite token или reset code.
  3. Проверьте авторизацию для этого маршрута. Некоторые экраны публичные. Другим нужен вошедший пользователь, чтобы они имели смысл.
  4. Если вход блокирует доступ, сохраните маршрут как pending. Держите его в одном месте, а не размазывайте по экранам.
  5. После успешного входа продолжите именно этот маршрут и очистите pending-значение.

Частая ошибка выглядит так: приложение открывается по ссылке /projects/42, видит, что пользователь вышел из аккаунта, показывает Login, а потом забывает, зачем вообще открывалось. После входа приложение попадает на dashboard. Пользователь начинает искать project 42 и думает, что ссылка сломана.

Лучше сделать поток строже. Приложение читает /projects/42, превращает его в route.project(id: 42), проверяет авторизацию и сохраняет этот маршрут, если нужен вход. После входа приложение при необходимости загружает проект, один раз строит стек и сразу отправляет пользователя на нужный экран.

Порядок здесь важен. Сначала разбор. Потом проверка доступа. UI — в самом конце. Одно это уже убирает много ошибок deep linking в SwiftUI, потому что запуск перестаёт гадать и начинает следовать одному маршруту с первой секунды открытия приложения.

Простой пример холодного запуска

Приведите в порядок правила модалок
Назначьте одного владельца модальных окон и остановите конфликт sheets во время входа и запуска.

Пользователь открывает приглашение из Mail, пока приложение полностью закрыто. Ссылка ведёт в конкретную команду. Когда приложение запускается, оно проверяет сохранённую сессию и не находит её. Именно здесь начинаются многие ошибки навигации в SwiftUI, потому что приложение часто пытается открыть целевой экран ещё до того, как узнает, кто перед ним.

Спокойный поток сохраняет цель приглашения, показывает вход и ждёт, пока вход завершится, прежде чем переходить дальше. Приложение должно воспринимать ссылку как намерение, а не как немедленное открытие экрана.

  • Разберите ссылку приглашения сразу после запуска приложения.
  • Сохраните целевое место как pending route, например team ID или invite token.
  • Покажите вход как единственный активный путь.
  • После успешного входа проверьте сохранённый маршрут и откройте экран команды.

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

Представьте простой случай. Маша получает приглашение по email, нажимает на него, и приложение открывается из холодного запуска. Она не вошла в аккаунт, поэтому приложение сразу показывает вход. Внутри оно сохраняет что-то вроде pendingRoute = teamInvite(42). После входа приложение проверяет, всё ли ещё действительно и есть ли у аккаунта доступ. Если да, оно сразу открывает экран команды.

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

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

Делайте понятные пути восстановления

Когда deep link не срабатывает, пользователю всё равно, из-за чего именно случилась ошибка: авторизация, разбор ссылки или состояние приложения. Ему важно, чтобы приложение продолжало работать и объясняло, что делать дальше.

Многие ошибки навигации в SwiftUI кажутся хуже, чем есть на самом деле, потому что приложение застревает между экранами, ничего не показывает или молча отправляет пользователя не туда. Спокойный fallback работает лучше, чем попытка любой ценой протолкнуть исходный маршрут.

Если приложение открыло ссылку, а часть данных маршрута отсутствует, всегда отправляйте пользователя на один безопасный экран. Это может быть вкладка Home, Inbox, страница аккаунта или простая страница "Страница недоступна" с одним чётким действием. Выберите одно правило и соблюдайте его. Случайный fallback — вот как люди перестают доверять приложению.

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

Таймаут помогает сильнее, чем ожидают многие команды. Если вы сохраняете pending deep link во время холодного запуска, дайте ему короткую жизнь — часто 30–60 секунд. После этого выбросьте его и верните приложение к обычному экрану. Это убирает странные случаи, когда человек открывает приложение позже и попадает на старый путь, который уже не имеет смысла.

Короткий статусный текст тоже важен. Пользователю нужна одна простая фраза на экране, пока приложение восстанавливается. Подойдёт что-то вроде "Не удалось открыть эту ссылку" или "Срок вашей сессии истёк. Пожалуйста, войдите снова". Оставьте её видимой, пока не появится следующий экран, или дайте пользователю закрыть сообщение. Тихое восстановление ощущается как баг.

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

Частые ошибки, которые вызывают петли и тупики

Получите второе мнение
Олег может проверить поток вашего Swift-приложения и предложить более простую архитектуру, которую команда сможет поддерживать.

Ошибки навигации в SwiftUI часто начинаются со времени, а не с самой ссылки. Пользователь нажимает deep link из письма, приложение запускается холодно и начинает открывать экраны ещё до того, как узнает, вошёл ли пользователь в аккаунт. Через секунду догружается авторизация, root view меняется, и пользователя перебрасывает в другое место.

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

Ещё одна распространённая проблема — кодировать навигацию через набор булевых флагов. Если у вас одновременно меняются showLogin, showPaywall, showInviteSheet и goToProject, у вас больше нет одного состояния навигации. У вас жеребьёвка. Два флага могут стать true одновременно, и SwiftUI попробует показать два разных пути от одного события.

С sheets всё становится ещё хуже, если вы показываете их с экрана, который может ещё не существовать. Допустим, deep link должен открыть документ, а потом показать sheet с разрешением. Если view документа ещё не появился, у sheet нет стабильного места, откуда его показать. Иногда ничего не происходит. Иногда приложение откатывается на другой экран. Иногда оно пытается снова при каждом обновлении view.

Небольшой пример показывает этот паттерн. Пользователь открывает ссылку-приглашение из холодного запуска:

  • приложение ничего не сохраняет и сразу открывает экран приглашения
  • авторизация завершается и меняет root на login
  • пользователь входит и оказывается на dashboard
  • маршрут приглашения исчезает

Обратная проблема тоже встречается очень часто: приложение сохраняет pending route, но никогда не очищает его после успеха. Тогда каждый новый запуск снова проигрывает тот же deep link. Пользователь закрывает модалку, снова открывает приложение и опять попадает в тот же поток.

Исправление менее магическое, чем кажется. Держите одну модель маршрутов, одно место для pending deep links и одно правило, когда можно начинать навигацию. Повторяйте сохранённый маршрут только тогда, когда приложению действительно хватает состояния, и очищайте его сразу после того, как пользователь дошёл до цели или отменил действие.

Быстрая проверка перед релизом

Постройте более удобный router
Перенесите правила авторизации и deep link в один координатор вместо точечных правок экранов.

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

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

  • Откройте одну и ту же ссылку, будучи вошедшим в аккаунт и выйдя из него. В итоге приложение должно прийти в одно и то же место, даже если в одном сценарии сначала будет вход.
  • Проверьте холодный запуск, тёплый запуск и возврат из фона. Эти три состояния часто дают разное время срабатывания, особенно когда приложение восстанавливает данные сессии.
  • Используйте истёкшие токены, отозванные сессии и отсутствующие ID. Если ссылка ведёт на объект, которого больше нет, покажите понятное сообщение и безопасный fallback-экран.
  • Нажмите Back после восстановления. Если сначала был вход, обновление или перезагрузка данных, стек назад всё равно должен ощущаться нормально и не кидать пользователя по кругу.
  • Специально создайте конфликт модалок. Если открыта форма авторизации, deep link не должен показывать ещё один sheet поверх неё. Одновременно может быть только одна модалка — это правило, которое стоит сохранить.

Здесь важны мелочи. Если ссылка на товар открывается из холодного запуска, приложение должно действовать по порядку: восстановить авторизацию, проверить маршрут, получить нужные данные, а потом перейти к цели. Если какой-то шаг не удался, приложение должно объяснить это простым языком. "Этот объект больше недоступен" — этого достаточно. Тихий провал — нет.

И ещё одно: следите за дублирующими переходами. Обработчик ссылки, onAppear task и наблюдатель сессии могут одновременно попытаться переместить пользователя. Когда это происходит, приложение начинает выглядеть случайным. Если ваша модель состояния позволяет только одно активное изменение маршрута за раз, большинство таких багов исчезнут ещё до того, как их увидят пользователи.

Что делать дальше

Большинство ошибок навигации в SwiftUI становятся меньше, когда вы перестаёте считать их проблемой экранов. На самом деле это проблемы потока. Сначала нарисуйте на одной странице всю карту маршрутов: запуск приложения, состояние без входа, состояние с входом, модальные экраны, deep links, истёкшие сессии и восстановление после ошибки.

Этот рисунок делает две полезные вещи. Он показывает, куда приложение может пойти, и показывает, куда приложение не должно попадать никогда. Если deep link пытается открыть защищённый экран до завершения авторизации, карта маршрутов должна сделать следующий шаг очевидным, а не оставлять каждый экран догадываться.

После этого перенесите правила авторизации и deep link в один coordinator. В одном месте должны решаться вопросы: показывать ли вход, ставить ли целевой экран в очередь и нужно ли продолжить его после успешной авторизации. Когда эти правила размазаны по нескольким экранам, холодный запуск превращается в набор мелких исключений.

Полезен короткий чек-лист:

  • Описывайте каждый маршрут как данные, а не как разрозненную логику экранов.
  • Определите, какие экраны могут открываться как модалки, а какие должны жить в основном стеке.
  • Логируйте каждое решение по маршруту, включая отклонённые ссылки и неудачные попытки восстановления.
  • Тестируйте холодный запуск с валидными ссылками, истёкшими сессиями и отсутствующими данными.

Логи важнее, чем кажется многим командам. Если пользователь нажал ссылку из письма и попал не туда, вам нужна одна понятная запись: что получил app, какое состояние авторизации нашёл и почему выбрал следующий маршрут. Это резко сокращает время отладки.

Если поток всё ещё кажется запутанным, стоит получить ещё один взгляд на архитектуру, прежде чем продолжать латать экраны. Fractional CTO, такой как Oleg Sotnikov, может проверить модель маршрутов, поток авторизации и пути восстановления, а затем предложить более простую схему, которую команда сможет удерживать по мере роста приложения.

Сначала сделайте карту маршрутов. Команды часто пропускают этот шаг, а потом тратят дни на исправление симптомов вместо настоящего дерева решений.