Привет, Хабр! Меня зовут Константин Дубинко, я — 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 API

  • Fragment screen — конечные экраны для пользователя

При этом фрагмент-контейнер мог содержать внутри себя как обычные экраны, так и другие фрагменты-контейнеры.

Мотивация для миграции

Чем активнее мы внедряли Compose, тем чаще задумывались об уходе от Fragment. Причин было несколько:

  1. Размытие ответственности между Fragment и Compose.  С Compose мы можем делать всё то же самое, что раньше делали при помощи Fragment API: реагировать на события жизненного цикла, интегрироваться с ViewModel, вызывать системные API типа работы с пермишенами. Fragment в таком случае становится избыточной обёрткой над не уступающим по возможностям Compose-миром.

  2. Большое количество жизненных циклов. В мире Fragment у нас уже есть жизненный цикл самого фрагмента и отдельно — жизненный цикл его View. Поверх этого внутри View появляется Compose со своими рекомпозициями. Становится легче ошибиться с подписками и утечками.

  3. Дополнительные накладные расходы из-за интеропа между Fragment/View и Compose. Основная проблема тут даже не в производительности, а в когнитивной нагрузке. Разработчикам приходилось постоянно держать в голове особенности работы  сразу с двумя технологиями и не допускать багов на их пересечении.

Чем решили заменить Fragment-навигацию

В 2022 году мы начали исследовать различные библиотеки для навигации в Compose и в итоге выбрали библиотеку Modo. Об этом исследовании рассказывал мой коллега и разработчик этой библиотеки, Игорь Кареньков, в докладе на Mobius

Одна из ключевых идей Modo — навигация в UDF-стиле. Состояние всей иерархии экранов хранится централизованно и описывается обычными структурами данных:

  • List — для стека экранов

  • Map — для multistack-навигации

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

UDF-цикл работы с навигацией
UDF-цикл работы с навигацией

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

Приведу небольшой пример, на что мы начали пересаживаться с фрагментов. В 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-элементы, а уже потом — контейнеры, в которых они находятся.

Схема миграции View -> Compose. Источник: https://developer.android.com
Схема миграции View -> Compose. Источник: https://developer.android.com

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

Первый шаг миграции: добавление Modo к фрагментам вложенных экранов
Первый шаг миграции: добавление Modo к фрагментам вложенных экранов

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

Миграция фрагмента-контейнера на Modo
Миграция фрагмента-контейнера на Modo

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

Продолжаем добавлять поддержку Modo для фрагментов на следующем уровне навигации
Продолжаем добавлять поддержку Modo для фрагментов на следующем уровне навигации
Мигрируем контейнер следующего уровня навигации на Modo
Мигрируем контейнер следующего уровня навигации на 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.

Переключение внутренней реализации роутера через feature toggle (Fragment/Modo)
Переключение внутренней реализации роутера через feature toggle (Fragment/Modo)

В итоге 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, поверх которых наложился и продуктовый редизайн. Сложность миграций в таком случае не складывается, а умножается друг на друга. Из-за этого разработка замедляется, а фичи становятся тяжелее и дороже. 

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

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

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

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

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

Комментарии (0)