Міграція застосунку зі 170+ екранами на Navigation 3: біль, овертайми та хотфікси

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт, DOU спільното! Мене звати Тетяна, я Senior Android Engineer в компанії United Tech. Наш Android-додаток — стрімінгова платформа з понад 170 екранами, i на початку 2026 року ми повністю мігрували з Navigation 2 на Navigation 3.

У цій статті хочу поділитись досвідом цієї міграції: чому ми на неї зважились, з якими проблемами зіштовхнулись, як ловили краші після релізу, що виграли в результаті і що точно зробили б інакше.

Стаття буде корисною Android розробникам, які планують міграцію або з жахом її уявляють.

Життя з Navigation 2

Що у нас було до міграції

Наш додаток був написаний у 2021 році, навігацію закладали на основі Jetpack Compose Navigation, що на той момент було досить новим і не до кінця стабілізованим рішенням від Google.

Тому, як і будь-який поважаючий себе великий додаток, ми мали:
— legacy
— extension-функції для навігації
— свої унікальні рішення для реалізації в навігації того, що не вистачало зі стандартної бібліотеки.

Також, важливо наголосити, що Bottom Sheets у нас теж заведені в навігацію.

І тут виходить Navigation 3 з кардинально новим переосмисленням підходу до навігації.

Чому ми вирішили мігрувати

Ми почали зважувати наш tech debt проти майбутніх можливостей і в результаті прийняли рішення мігрувати.

Для нас це було не просто «оновити бібліотеку». Ми дивились на migration як на інвестицію в платформу.

По-перше, у roadmap уже були планшети та foldables, а існуюча реалізація навігації не дуже добре лягала на adaptive layouts і multi-pane сценарії.

По-друге, navigation stack поступово обростав extension-функціями і кастомною логікою. Це працювало, але кожна наступна фіча в navigation layer ставала дорожчою для підтримки.

Ми розуміли, що міграція — це ризик, велика affected area і потенційні регресії. Але відкладати означало б тільки збільшувати tech debt і зробити переїзд ще дорожчим у майбутньому.

Що нас переконало

  1. Compose-first підхід: Весь UI і навігація в додатку вже були побудовані на Compose.
  2. MVI і state-driven navigation: Nav3 розглядає navigation stack як звичайний стан. Для нашої MVI архітектури це лягало дуже органічно.

Умовно, навігація почала виглядати приблизно так:

val navigationState = rememberNavigationState(
    startKey = MainScreenPointScreen,
    topLevelKeys = setOf(MainScreenPointScreen)
)

val navigator = remember { Navigator(navigationState) }

fun navigate(key: NavKey) {
    when (key) {
       state.currentTopLevelKey -> clearSubStack()
       in state.topLevelKeys -> goToTopLevel(key)
       else -> goToKey(key)
    }
}

fun goBack(): Boolean {
    backStack.removeLastOrNull()
    return true
}

Навігація стала значно ближчою до state-driven підходу, який у нас уже використовувався в інших частинах застосунку.

3. Adaptive layouts: Бізнес-план включав підтримку планшетів та Pixel Fold

4. Google вимоги: Google дуже активно просуває adaptive layouts і великі екрани. Окрім цього, з Android 17 фіксована орієнтація екрану фактично стає legacy-підходом для більшості застосунків.

Google Play Store теж офіційно пріоритезуватиме застосунки, які підтримують великі екрани.

Тому для нас це виглядало як «або робити зараз контрольовано, або все одно повертатись до цього пізніше, але вже під більшим тиском».

Також, я б радила розробникам, які починають писати новий додаток закладати архітектуру одразу з Nav3.

Як відбувалась міграція і чи є життя після неї

Листопад 2025 рік.

Я побачила, що вийшла стабільна версія Nav3 і, як проактивна і натхненна новим підходом до навігації розробниця, створила investigate в наступний спринт.

Мета investigate була досить проста: оцінити доцільність міграції, affected area, ризики і зрозуміти, чи це взагалі реально для нашого масштабу додатку. Досить швидко стало зрозуміло: легко не буде.

Я одразу побачила головний ризик — неможливо переїздити поступово. Через відсутню зворотну сумісність між Nav2 і Nav3 переїзд можливий тільки за умови повної міграції всіх екранів. Частково переїхати неможливо.

Після investigate ми з лідами оцінили ризики, потенційні регресії і roadmap на найближчі квартали. Оскільки adaptive layouts уже були в планах, migration отримала зелене світло.

Лютий 2026 рік.

При плануванні наступного спринта мені виділили окремий спринт на міграцію, і весь цей час (два довгі й цікаві тижні, сповнені болю) я займалась виключно навігацією. Міграція відбувалась повністю вручну.

Так, я пробувала залучити AI. Але на той момент міграція виявилась занадто складною для legacy-heavy codebase. Через extension-функції, кастомні navigation-рішення і нетривіальні залежності Claude Code часто повертав неконсистентний результат. Тому замість міграції на Nav3, він повертав код на Nav2 і писав «міграцію виконано успішно, тепер проєкт працює на останній стабільній версії навігації». Але до цього питання ми ще повернемось трохи згодом.

Міграція на Nav3 зайняла в мене довгих два тижні з щоденними овертаймами. Відмова від відповідальності: ні, мене не змушувала компанія. Так, мені б виділити стільки часу, скільки потрібно. Так, я пишу цей текст добровільно 😄.

Цікавий факт: Gemini і Claude Code оцінили таку міграцію в 6–8 тижнів роботи на команду. У підсумку я зробила її сама за два тижні.

Овертайми. Чому?

У такого масштабного рефактору є головний недолік — merge conflicts. Ми свідомо вирішили не зупиняти розробку на час міграції, оскільки це невиправдано дорого коштувало б бізнесу. Команда продовжувала працювати над новими фічами, а я паралельно робила міграцію.

А це означає одну дуже веселу річ: поки я мануально перебираю кожен екран в додатку, мої колеги в цей самий час продовжують писати код. І створювати нові екрани і фічі. На старій навігації. Так, я намагалась домовитись, щоб вони на цей час пішли в відпустку — не вийшло.

Тому я дуже швидко зрозуміла: краще якомога швидше закінчити цю міграцію, оскільки, чим довше вона триває, тим дорожче і важче буде фіксити merge conflicts.

Неочікуване

Bottom Sheets

Під час планування міграції я не врахувала один важливий момент — Bottom Sheets. В Nav3 не було готово рішення для того, щоб показувати Bottom Sheets через навігацію. Мені довелось писати свою, не дуже гарну, обгортку навколо entry<>, передавати в metadata інформацію про те, що це Bottom Sheet і якщо так, то показувати цей екран не через NavDisplay, а через ModalBottomSheetLayout (Material 3).

val NavEntry<out NavKey>.isBottomSheet: Boolean
    get() = metadata["isBottomSheet"] as? Boolean == true

inline fun <reified T : NavKey> EntryProviderScope<NavKey>.bottomSheetEntry(
    noinline content: @Composable (T) -> Unit
) {
    entry<T>(
        metadata = mapOf("isBottomSheet" to true),
        content = content
  )
}

NavDisplay(
    entries = navigationState.toEntries(mainEntryProvider)
        .filter { it.isBottomSheet.not() },
    onBack = { navigator.goBack() },
)

Це не було ідеальним рішенням — швидше тимчасовий workaround, який дозволив рухатись далі. Зараз вже є більш правильні підходи через SceneStrategy, і ми пізніше замінили це рішення.

Deeplinks

Окремим бонусом міграції стала можливість нормально переосмислити deeplinks.

Так історично склалося, що Deeplinks у нас в додатку були структуровані не найкращим чином, і не завжди було очевидно, який саме екран їх обробляє.

Під час міграції я перенесла це в одне місце і зробила логіку більш централізованою. Це дало просту, але дуже відчутну вигоду: тепер значно легше зрозуміти, що саме відбувається з кожним діплінком.

Приклад різниці:

Було:

composable(
    deepLinks = "$uri/${ScreenName.PROFILE.value}/?$PROFILE_ID={$PROFILE_ID}" +
          "&$EVENT_CONTEXT={$EVENT_CONTEXT}" +
          "&$EVENT_ANCHOR={$EVENT_ANCHOR}" +
          "&$ENTRY_POINT={$ENTRY_POINT}" +
          "&$STREAM_ID={$STREAM_ID}",
    route = profileRoute(),
    arguments = profileArguments(),
) {
    ProfilePreview(
        //profile params
    )
}

Стало (ідея):

@Serializable
data class ProfilePreviewPointScreen(
    override val deepLinkPath: String = "${ScreenName.PROFILE.value}"
) : NavKey,
    DeepLinkable

entry<ProfilePreviewPointScreen>(
    metadata = BottomSheetSceneStrategy.bottomSheet()
) { entry ->
    ProfilePreview(
        //profile params
    )
}

Це не виглядає як великий рефактор, але в реальності сильно спростило підтримку і дебаг.

Стабілізація

Повна регресія, стабілізація і багфікс зайняли приблизно тиждень-півтора. Чесно, я думала, що ще місяць будемо ловити баги і стабілізувати, але моєму ліду про це не кажіть. Завдяки автотестам ми відловити дуже багато проблем, які мануально було б дуже складно відтворити, але користувачі б точно написали про це відгук.

Реліз

Стабілізували, відрегресували, зарелізили, відкрили крашлітику, розкатали на 1% юзерів, взялись за серце і стали чекати. Чекати довго не довелось, почали прилітати краші.

Проблема з Back navigation

Щоб зрозуміти природу цих крашів потрібно розглянути back навігацію. Існує два варіанти такої навігації:
1. Звичайна back навігація: з поточного екрану при натисканні back ми повертаємо користувача на попередній екран

2. Back navigation на 2+ екранів назад. Наприклад, коли при блокуванні користувача в чаті, потрібно повернути юзера назад на декілька екранів:

Для першого випадку все працювало стабільно. Для другого випадку ми передавали через навігацію параметр entryPoint: NavKey (після міграції), щоб знати на який саме екран повернути юзера у випадку блокування.

data class BlockUserRoute(
    val entryPoint: NavKey
)

Це призвело до крашу з помилкою:
Fatal Exception: kotlinx.serialization.SerializationException.
У Nav3 всі параметри, які передаються через навігацію, мають бути serializable.
NavKey у нас serializable не був, тому довелось зробити обгортку:

@Serializable
open class NavStateSerializable : NavKey

Зробили хотфікс і з другого разу вже успішно зарелізились.

Проактивність то є добре, але не завжди

Пам’ятаєте я писала про те, що переводила все вручну? Пробувала з АІ, але не вийшло. Так от, через півтора місяці після релізу Google зробив Claude Code скіли для міграції на Nav3.

Мені стало цікаво, наскільки Claude Code допоміг би мені. Я перемкнулась на гілку до міграції і спробувала мігрувати за допомогою Claude Code. Впевнено можу сказати, що це скоротило б мені тиждень роботи. Тобто, міграція відбулась би вдвічі швидше. Так, extensions і legacy треба було б вручну переводити і витратити чимало часу на перевірку змін після Claude Code, але те, що Claude Code оптимізував би дуже багато монотонної роботи, яку я робила вручну — беззаперечний факт.

До того ж, вже є рішення від Google для Bottom Sheets — якраз через SceneStrategy. Ми вже замінили своє рішення на BottomSheetSceneStrategy. Такий підхід є набагато зручнішим.

Таким чином, якби я не поспішила мігрувати, а почекала б пару місяців, то з Claude Code і готовим рішенням для Bottom Sheets міграція пройшла б трішки легше і вдвічі швидше в плані розробки. Я думаю, що час на стабілізацію і багфікс не сильно змінився б.

Висновки та уроки

Bottom Sheets...знову

Оскільки наші Bottom Sheets знаходяться в navigation stack, є один важливий нюанс.
Подивіться уважно на цей сценарій:

Bottom Sheet, який заведений в навігацію, теж є в стеку навігації (що очевидно). Але, у випадку навігації з Bottom Sheet на наступний екран, якщо його не закрити вручну, то при навігації назад, Bottom Sheet покажеться знову, бо він теж є в стеку. Тому, перед тим, як навігуватись на інший екран, Bottom Sheet треба закрити вручну. А про це дуже легко забути.

Завжди можна зробити extension для цього, але це все одно може бути неочевидним, особливо, для нових розробників.

Коли я б НЕ радила мігрувати

Я б дуже радила зважити необхідність переходу на Nav3 у наступних випадках:
1. Fragment-based архітектура: Перед міграцією доведеться позбутись Fragments. Це може означати дуже серйозний рефактор з великою кількістю ризиків.

2. Жорсткі дедлайни: Навіть якщо у вас уже Compose navigation, міграція створює величезну affected area і може дорого коштувати бізнесу через регресії.

3. Власне navigation рішення: Якщо ваше кастомне рішення вже стабільно працює — міграція може бути просто невиправданою.

4. Games: якщо ваш додаток — це гра і ви жорстко залежите від орієнтації екрану (здебільшого альбомної, в даному випадку), Google дозволяє зробити виняток і залишити орієнтацію фіксованою

Переваги

Попри всі проблеми, я вважаю, що міграція була абсолютно виправданою і в результаті ми виграли, а саме:

1. Multi-pane UI: тепер adaptive layouts для нас не є проблемою. Ми з легкістю можемо це реалізувати, просто вказавши відповідний sceneStrategy для конкретного екрану

2. Передбачувана навігація: Navigation stack став значно прозорішим і простішим для дебагу

3. Deeplinks: Під час міграції я переписала багато legacy і привела deeplinks до нормальної структури

Підсумок

Весь шлях міграції можна підсумувати приблизно так:

Команда: 1 активна, ще не вигорівша розробниця
Час розробки: 2 тижні
Фікс merge conflicts: 1 день
Affected Area: 302 файли, або простіше кажучи — весь додаток
Тестування і стабілізація: ~1,5 тижні
Hotfix: 1 штука

Що важливо — ми оцінювали успішність міграції через кілька практичних критеріїв:

  1. стабільність navigation flow після релізу (краші і navigation-related регресії);
  2. швидкість інтеграції нових екранів у нову навігацію;
  3. можливість нормально будувати adaptive layouts без додаткових обхідних рішень;
  4. дебаг navigation stack (чи стало простіше відтворювати сценарії).

Після міграції найбільш відчутні зміни були в щоденній розробці:

  1. нові екрани стало простіше підключати до навігації без додаткових extension-функцій;
  2. navigation stack став більш передбачуваним і його стало легше дебажити;
  3. частина legacy логіки просто зникла;

Nav3 виявився значно ближчим до state-driven підходу, а adaptive layouts тепер не виглядають як окремий великий проєкт.

Але якщо ви теж плануєте міграцію — закладайте більше часу на стабілізацію, не недооцінюйте Bottom Sheets і дуже уважно перевіряйте serialization у навігації.

Сподіваюсь, мій досвід допоможе вам уникнути моїх помилок і зробити міграцію швидше та безболісніше!

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі