Привет, Хабр! Меня зовут Константин Дубинко, я — Android-техлид в hh.ru. Сейчас мы заканчиваем большой переезд навигации в двух Android-приложениях — для соискателей и работодателей. В этой статье я покажу, как у нас там устроена навигация, почему мы решили отказаться от Fragment-навигации и как превратили хаотичную миграцию с «островками» новой архитектуры в управляемый процесс с метриками и понятным планом работ. Заодно расскажу, какие решения сработали, какие — нет, и что я сделал бы иначе, если бы начинал заново.
Масштаб миграции
В двух наших Android-приложениях — для соискателей и работодателей — больше 400 экранов, около тысячи навигационных переходов и примерно 70 диплинков. Часть модулей и экранов переиспользуется между приложениями, поэтому наши приложения живут внутри одного Gradle-проекта в едином репозитории. На данный момент в репозиторий контрибьютит 14 продуктовых команд, в каждой в среднем по два Android-разработчика.
Наш Android-репозиторий появился ещё в далеком 2013 году, тогда все экраны жили на отдельных Activity. В 2018 году мы перешли на подход Single Activity с экранами на базе Fragment. А в 2022 начали уходить от фрагментов в сторону Compose-first подхода. Полностью миграцию мы ещё не завершили, но большая часть работы уже проделана.
Контейнеры навигации
Для моего рассказа будет важно понятие контейнера навигации — это область интерфейса приложения, внутри которой мы переключаем экраны. Типичный пример контейнера навигации в мобильных приложениях — стек экранов: при переходе мы кладём новый экран поверх предыдущих и показываем его как активный.

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

Есть и другой тип контейнеров — multistack. Это навигация на главном экране между основными разделами приложений через нижнюю панель. По сути, при переключении вкладки мы переключаем активный стек экранов.

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

До начала миграции у нас было два типа фрагментов:
Fragment container — контейнеры, которые управляли навигацией внутри себя через
childFragmentManagerиз Fragment APIFragment screen — конечные экраны для пользователя
При этом фрагмент-контейнер мог содержать внутри себя как обычные экраны, так и другие фрагменты-контейнеры.

Мотивация для миграции
Чем активнее мы внедряли Compose, тем чаще задумывались об уходе от Fragment. Причин было несколько:
Размытие ответственности между Fragment и Compose. С Compose мы можем делать всё то же самое, что раньше делали при помощи Fragment API: реагировать на события жизненного цикла, интегрироваться с ViewModel, вызывать системные API типа работы с пермишенами. Fragment в таком случае становится избыточной обёрткой над не уступающим по возможностям Compose-миром.
Большое количество жизненных циклов. В мире Fragment у нас уже есть жизненный цикл самого фрагмента и отдельно — жизненный цикл его View. Поверх этого внутри View появляется Compose со своими рекомпозициями. Становится легче ошибиться с подписками и утечками.
Дополнительные накладные расходы из-за интеропа между Fragment/View и Compose. Основная проблема тут даже не в производительности, а в когнитивной нагрузке. Разработчикам приходилось постоянно держать в голове особенности работы сразу с двумя технологиями и не допускать багов на их пересечении.
Чем решили заменить Fragment-навигацию
В 2022 году мы начали исследовать различные библиотеки для навигации в Compose и в итоге выбрали библиотеку Modo. Об этом исследовании рассказывал мой коллега и разработчик этой библиотеки, Игорь Кареньков, в докладе на Mobius.
Одна из ключевых идей Modo — навигация в UDF-стиле. Состояние всей иерархии экранов хранится централизованно и описывается обычными структурами данных:
List — для стека экранов
Map — для multistack-навигации
Дальше мы просто вычисляем следующее состояние навигации как функцию от текущего. Такой подход позволяет достаточно гибко описывать даже сложные сценарии переходов.

Ещё один важный плюс: если нам не хватало какой-то функциональности, мы могли быстро доработать её под свои задачи, благодаря разработке и поддержке библиотеки внутри компании.
Приведу небольшой пример, на что мы начали пересаживаться с фрагментов. В Modo каждый экран описывается сущностью Screen. Она позволяет:
передавать параметры экрана прямо в конструктор с ограничением — параметры, как и в случае с фрагментами, должны быть сериализуемыми для корректного пересоздания приложения после смерти процесса
переопределять метод Content как точку входа в UI экрана
интегрироваться с ViewModel
подписываться на события жизненного цикла экрана
@Parcelize internal class SampleScreen( val params: ScreenParams, override val screenKey: ScreenKey = generateScreenKey(), ) : Screen { @Composable override fun Content(modifier: Modifier) { // наш способ работы с DI: ProvideScope( bindings = { bindInstance(params) } ) { // подключение ViewModеl к экрану: val viewModel = rememberViewModel<SampleViewModel>() val state by viewModel.collectUiState() viewModel.collectUiEvents { event -> /* ... */} // отрисовка контента экрана ScreenContent(state) // подписка на события жизненного цикла OnLifecycleEvent( onResume = { /* ... */ }, onPause = { /* ... */ }, ) } } }
Навигацией в Modo мы управляем через класс NavContainer, который связан с определенным уровнем навигации. Его можно заинжектить, например, во ViewModel экрана. Ниже — несколько примеров навигационных действий:
переход на следующий экран
замена текущего
возврат к экрану по условию
произвольное изменение навигационного состояния
@InjectConstructor class SampleViewModel( private val navContainer: StackNavContainer, ) : ViewModel() { fun onSomethingDone() { // переход на следующий экран: navContainer.forward(SampleScreen()) // замена текущего экрана: navContainer.replace(SampleScreen()) // возврат на экран по критерию: navContainer.backTo { pos, screen -> (screen as? SampleScreen)?.params.id == ”target_screen" } // произвольное действие над состоянием: navContainer.dispatch { oldState -> val stack: List<Screen> = oldState.stack StackState(stack.shuffled()) } } }
В тот момент решение выглядело вполне рабочим, поэтому мы запустили миграцию.
Первые шаги миграции
Для начала мы написали адаптер для наших фрагментов, который позволял переходить из фрагментов в мир Compose-навигации. Начали применять его для тех экранов, которые на тот момент уже были реализованы на Compose. По сути, Fragment стал обёрткой над Compose-сценариями. Это позволило встроить новую навигацию в существующую Fragment-иерархию.
Например, так выглядел контейнер для сценария авторизации:
internal class AuthFlowFragment : BaseFragment() { companion object { fun newInstance(authRequestParams: AuthRequestParams) = AuthFlowFragment().withParams(authRequestParams) } init { // точка входа в Modo навигацию: modoPlugin( initialScreenProvider = { AuthFlowRootScreen(getParams()) }, contentDecoration = { content -> MagritteTheme(content) } ) } }
На старте схема выглядела вполне рабочей, но довольно быстро начали появляться проблемы.
Параллельная миграция и UI-фреймворка, и навигации — это уже достаточно трудоёмкий процесс. Но у нас поверх этого наложился ещё и продуктовый редизайн. Мы не хотели переписывать экраны на Compose со старым дизайном, чтобы потом не переделывать их ещё раз под новый.
Поэтому возникла зависимость одних работ от других: сначала нам было необходимо дождаться нового дизайна для экранов, затем перевести эти экраны на Compose в новом дизайне, после чего мигрировать их навигацию на Modo. Из-за этого миграция шла медленно и неравномерно. В приложении начали появляться «островки» новой навигации посреди старой Fragment-инфраструктуры.
Также мы столкнулись с проблемами на стыке двух технологий. Рассмотрим на примере диплинков — сквозных сценариев, проходящих через наше приложение, которые открывают цепочку экранов в разных навигационных контейнерах. Раньше наши дилпинки описывались довольно просто: списком экранов, которые нужно открыть друг за другом. Но после начала миграции часть экранов уже работала на новой навигации, а часть — на старой. Из-за этого обработка диплинков начала распадаться и стала менее очевидной. Похожие проблемы появились и с обработкой кнопки «назад», которую нужно было синхронизировать сразу между двумя навигационными системами.
Но самые болезненные проблемы были связаны с самой библиотекой. На момент начала миграции Modo была довольно молодой и ещё не успела нормально обкататься в больших production-приложениях. Нам не хватало best practices, документации и готовых решений под многие сценарии. Параллельно начали появляться запросы на доработки самой библиотеки.
Ниже — реальные цитаты из карточек с одного из наших платформенных ретро в начале внедрения Modo:
Для того, чтобы вернуть результат в верхнеуровневый Screen, нужно каждый раз делать паблишеры (например, для показа снекбара)
Все ещё, чтобы открыть диалог на Modo первым экраном, нужно сначала положить в стек пустой Screen
Сложная работа с диалогами на стыке Modo и Fragment: необходимо подкладывать в стек какой-то искусственный экран под диалог
Нет экспертизы в Modo, а там баги
И это была ещё не последняя проблема. На старте у миграции не было ни чёткого плана, ни понятных сроков.
Формирование стратегии миграции
Из-за всех этих проблем с миграцией разработка экранов ощутимо замедлилась, и мы решили сформировать чёткую стратегию миграции.
За основу мы взяли bottom-up подход, который Google рекомендует для постепенного перехода с View на Compose. Суть в том, чтобы сначала переводить самые глубоко вложенные UI-элементы, а уже потом — контейнеры, в которых они находятся.

Этот принцип мы перенесли на иерархию наших навигационных контейнеров и экранов. В первую очередь фокусировались на самых вложенных экранах и локальных навигационных сценариях. Для Fragment-экранов добавляли Modo Screen через адаптер:

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

Повторяем этот процесс, пока вся навигация не переедет на Compose + Modo:



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

Проблема была вот в чём. Как только все фрагменты внутри такого контейнера начинали под капотом отображать Modo-экраны, сам контейнер тоже нужно было переводить на Modo. То есть нам нужно было разом убрать множество фрагментов-обёрток и переделать навигационные вызовы на Modo.
Смешивать Fragment-навигацию и Modo внутри одного контейнера мы не могли.
Распараллелить этот шаг было довольно сложно. В процессе могло бы возникнуть большое количество конфликтов с основной веткой разработки, так как в глобальные навигационные контейнеры регулярно добавляются новые экраны и навигационные вызовы. Но главная проблема была даже не в этом. Одним неосторожным изменением можно было легко сломать навигацию на проде, а откат такого рефакторинга оказался бы болезненным.
Тогда мы посмотрели на наши существующие абстракции. При работе с фрагментами мы активно использовали интерфейс NavScreen. NavScreen — это сущность нашей навигации, которая описывает точку назначения в навигации.
interface NavScreen { override fun getActivityIntent(context: Context?): Intent? = null override fun getFragment(): Fragment? = null }
Точкой назначения мог быть экран как на базе Activity, так и на базе Fragment. Идея для NavScreen когда-то была взята из библиотеки Cicerone. Все вызовы навигации в глобальных контейнерах на фрагментах оперировали этой сущностью.
И тут появилась идея: почему бы не добавить в NavScreen поддержку Modo-экранов? Тогда один и тот же NavScreen мог бы возвращать Fragment-реализацию, либо Modo Screen. Вот пример для экрана резюме:
class ResumeNavScreen( private val params: ResumeProfileParams ) : NavScreen { override fun getFragment(): Fragment { return ResumeFragment.newInstance(params) } override fun getModoScreen(): Screen { return ResumeRootScreen(params = params) } }
А вот и пример навигационного перехода на какой-либо экран с использованием NavScreen:
fun openResume(resumeId: ResumeId) { // получаем NavScreen из фичи Resume: val navScreen = ResumeFacade().api.getResumeNavScreen( ResumeProfileParams(resumeId) ) // навигируемся на данный экран: rootRouter.openScreen(navScreen) }
Как видим из примера выше, навигационные переходы на нужный NavScreen у нас были реализованы через Router – сущность, отвечающая за реализацию навигационного перехода в рамках своего контейнера. Раньше Router внутри контейнера работал только с FragmentManager. В рамках миграции у него появилась альтернативная реализация для Modo, а выбор между ними можно было делать прямо в рантайме через feature toggle.

В итоге NavScreen и Router стали общей абстракцией над конкретной реализацией навигации.
В качестве фичетоггла мы завели полноценный A/B-эксперимент. При попадании пользователя в эксперимент мы прямо в рантайме переключали один из наших глобальных контейнеров из режима навигации на фрагментах в режим навигации на Modo.
class RootNavigationWithModoExperiment : Experiment { companion object { val isUserAffected by lazy { // под капотом данного вызова проверяем попадание пользователя в эксперимент // через нашу систему для AB-тестов RootNavigationWithModoExperiment().isUserAffected() } } override val key = "an_applicant_root_with_modo" override val description = "Переключает корневую навигацию соискательского приложения на Modo" }
Это решение довольно быстро себя оправдало:
Мы переиспользовали существующий код навигационных вызовов и избежали массового рефакторинга
Начали делать миграцию итеративно и под фичетогглом. Мы смогли безопасно заливать в основную ветку частично переведённые контейнеры и продолжать миграцию параллельно силами разных продуктовых команд
Получили возможность раскатывать миграцию под техническим A/B-экспериментом — аккуратно, контролируемо и с возможностью быстрого отката без перевыпуска приложения
Добавление фичетоггла оказалось полезным — не зря постелили соломку. Когда A/B-эксперимент начал раскатываться на первых пользователей, у нас периодически случался краш на уровне ComposeView. Это немного повлияло на продуктовые метрики: заметили небольшие просадки, остановили эксперимент, разобрались с проблемами и раскатили заново уже успешно.
Когда миграция стала управляемой
В какой-то момент стало понятно, что миграцией невозможно нормально управлять без метрик. Поэтому мы написали скрипт, который анализировал кодовую базу и считал:
сколько у нас ещё остается фрагментов
сколько из них являются обёртками над Modo
какую долю от всех экранов составляют экраны на Modo
По этим метрикам мы подготовили дашборд для отслеживания прогресса миграции:


Метрики позволили встроить миграцию в наш внутренний процесс квартального целеполагания, прозрачно формулировать промежуточные цели и управлять приоритетом работ:

Благодаря метрикам, мы начали лучше понимать оставшийся объём миграции и планировать сроки. А также начали фокусировать продуктовые команды на миграции путем квартального целеполагания. В какой-то момент миграция перестала быть хаотичным процессом «между продуктовыми задачами». Стало гораздо понятнее, что происходит, сколько работы ещё осталось, и где мы тормозим.
Сейчас мы уже перевели все локальные навигационные сценарии на Modo. Также мигрировали глобальные контейнеры нижней навигации в обоих приложениях через абстракцию над навигацией и feature toggle. Остались последние экраны в рутовых контейнерах. В данный момент добиваем именно их и планируем завершить миграцию в ближайшем время.
Выводы и грабли
За время миграции мы собрали достаточно граблей. В конце хочу коротко рассказать, чему нас научил этот переезд.
Первые грабли, на которые мы наступили: слишком рано выбрали технологию и недооценили стоимость внедрения собственного решения.
Когда мы исследовали Compose-навигацию в 2022 году, библиотек уже было много, но ни одна из них ещё нормально не обкаталась в больших production-приложениях. В итоге мы не стали ждать и выбрали вариант, который было проще адаптировать под себя. Это решение в итоге стоило нам довольно большого количества времени на доработки библиотеки и поиск подходов.
С тех пор мы стали гораздо внимательнее подходить к большим архитектурным изменениям и внедрили ADR (Architectural Decision Records). Теперь каждое серьёзное архитектурное решение фиксируем отдельно: зачем вообще его принимаем, какие есть альтернативы и что будет, если ничего не менять. После этого подключаем к обсуждению всех, кого изменение может затронуть. А заодно сохраняем контекст решений на будущее — чтобы через пару лет не гадать, почему вообще пошли именно этим путём.
Вторые грабли — попытка тащить сразу несколько связанных друг с другом больших миграций. В нашем случае это переход с View на Compose и переход с Fragment на Modo, поверх которых наложился и продуктовый редизайн. Сложность миграций в таком случае не складывается, а умножается друг на друга. Из-за этого разработка замедляется, а фичи становятся тяжелее и дороже.
Сейчас я бы по возможности разделял такие миграции и проводил их последовательно, а не параллельно.
Третьи грабли — начали миграцию без явной стратегии и понятного процесса. Из-за этого миграция долгое время происходила хаотично: без нормальных сроков, прозрачного прогресса и понимания, сколько ещё осталось работы.
Со временем стало понятно, что для больших миграций нужны понятный роадмап, метрики, промежуточные цели и способы ускорения. Иначе миграция становится неуправляемой и долгой. В нашем случае помогло то, что уже были абстракции для навигации, не слишком привязанные к конкретной технологии. Благодаря этому получилось проводить миграцию постепенно, а не переписывать всё разом.
Надеюсь, выводы из нашего опыта будут полезны и вам — особенно, если тоже планируете большой технологический переезд.
А если уже проходили через что-то подобное — делитесь в комментариях опытом миграций между технологиями и тем, насколько гладко они прошли.