Як зробити навігацію в Jetpack Compose зручнішою

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Привіт! Мене звати Вадим, я Android Developer у Welltech, ми займаємось розробкою мобільних застосунків у категорії Health&Fitness. Я хотів би вам розказати про наш досвід роботи із навігацією у Jetpack Compose.

Стаття буде корисною для тих, хто тільки починає вивчати Compose або кого не влаштовує стандартне рішення від Google — Navigation Сomponent for Jetpack Compose.

Історія

За часів View з’явилась бібліотека Navigation Component (далі NC) і стала дефакто стандартом, переважна більшість розробників, включаючи нас, використовувала його і «горя не знала». З появою Compose Google також адаптував NC і рекомендував до використання.

Коли ми починали писати новий проєкт на Compose, то звісно обрали NC для побудови нашої навігації, оскільки досвід роботи з бібліотекою вже був. Виглядало все звично: ті самі NavController, NavHost. Відмінністю NavHost став його опис в коді замість xml файлу.

Новим же став підхід до навігації: тепер замість згенерованих ідентифікаторів екранів у класі R ми маємо задати шлях із вказанням аргументів (якщо наявні) у вигляді String:

NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(/*...*/) }
    /*...*/
}

//navigate
navController.navigate("home")

Ми одразу створили sealed class для зручної роботи, щоб винести перелік екранів, логіку створення шляху з аргументами, також отримання переданих аргументів із Bundle:

sealed class Destination(
val path: String,
 /*...*/
) {
   object Home : Destination("home",  /*...*/)
   /*...*/
}

NavHost(
navController = navController, 
startDestination = Destination.Home.path
) {
    composable(Destination.Home.path) { HomeScreen(/*...*/) }
    /*...*/
}

//navigate
navController.navigate(Destination.Home.path)

Перші незручності почались, коли нам знадобилось передавати аргументи. У NC для View фреймворку аргументи описувались в XML (примітивні типи, Serializable, Parcelable). При збірці генерувався клас із цими аргументами і потрібно було тільки створити екземпляр з потрібними даними і передати NavController. А от в Compose світі нас чекало розчарування...

Передача аргументів в NC for Compose і недоліки даної реалізації

Щоб передавати аргументи, нам потрібно:

  1. описати аргументи, які може приймати екран (ключ, тип);
  2. змінити шлях екрана з вказанням ключів аргументів;
  3. навігуючись, потрібно створити шлях з потрібними параметрами;
  4. отримати передані аргументи із Bundle по ключу.
composable(
   "other_screen/{param1}", //path with args
   arguments = listOf(
       navArgument("param1") { type = NavType.StringType }
   ) //args descriptions
) { backStackEntry ->
   //obtain our args
   val param1 = backStackEntry.arguments?.getString("param1")
   OtherScreen(param1)
}

//navigate (with param1="hello_world")
navController.navigate("other_screen/hello_world")

Ми перенесли в окремий клас логіку створення url-екрану, перерахування аргументів, а також отримання значень аргументів із Bundle. Отримали такий вигляд:

sealed class Destination(
val path: String, 
val args: List<NamedNavArgument>
) {
    object OtherScreen : Destination(
        path = "other_screen/{param1}",
        args = listOf(
            navArgument("param1") {
                type = NavType.StringType
                defaultValue = null
                nullable = true
            }
        )
    ) {

        class Arguments(val param1: String?)

        fun getRoute(arguments: Arguments): String {
            return "other_screen/${arguments.param1}"
        }

        fun getArguments(bundle: Bundle): Arguments {
            return Arguments(
                param1 = bundle.getString("param1")
            )
        }
    }
}

Перший час користуватись цим було досить зручно. Наш опис графа і навігація були такими:

NavHost(
   navController = navController,
   startDestination = Destination.Home.path
) {
   composable(
       route = Destination.OtherScreen.path,
       arguments = Destination.OtherScreen.args
   ) { backStackEntry ->

       val args = Destination.OtherScreen.getArguments(
           bundle = requireNotNull(backStackEntry.arguments)
       )

       OtherScreen(param1 = args.param1)
   }
}


//navigate
val route: String = Destination.OtherScreen.getRoute(          
  args = Destination.OtherScreen.Arguments(param1 = param1)
)

navController.navigate(route)

Але як показав час, цей підхід має доволі вагомі недоліки:

  1. бойлерплейт (коли у проєкті багато екранів — це починає набридати);
  2. можна банально зробити одруківку (у шляху або назві аргументу);
  3. оскільки шлях парситься як Uri — потрібно слідкувати за тим, щоб String параметри, які передаються, були без спеціальних символів. Або закодувати в формат Url (наприклад, за допомогою URLEncoder.encode);
  4. рано чи пізно вам знадобиться передати Serializable або Parcelable. Це звісно можливо зробити, попередньо серіалізувавши об’єкт у String, але погодьтеся, писати кожен раз серіалізацію не дуже цікаве заняття.

Compose-destinations

Йшов час, ми мирилися з вищеописаними незручностями, допоки одного дня не натрапили на цікаву бібліотеку — «Compose-destinations». Після прочитання документації стало зрозуміло — це воно!

Для себе виділили наступні переваги:

  1. Це не окрема бібліотека для навігації, а надбудова над navigation-сompose, основною задачею якої є кодогенерація.
  2. Оскільки це тільки надбудова, її легко використати у вже існуючому проєкті, де використовується NC. Для нових проєктів поріг входження низький у випадку, якщо вже є досвід зі стандартним NC.
  3. Не потрібно вручну описувати граф навігації (додавши анотацію @Destination на composable функцію екрана і бібліотека його згенерує).
  4. Не потрібно думати за те, як передати Serializable/Parcelable.
  5. Аргументи, які передаються у String форматі кодуються у формат Url.
  6. Аргументи, які потрібні на певному екрані просто перераховують як параметри composable функції. Додатково можна вказати і NavController — тоді буде прокинуто потрібний контролер для управління навігацією.
  7. Відпадає потреба щось міняти в описі графів (У нас цей файл був немаленьким — близько 1000 рядків, щось шукати в ньому — ще те задоволення).

Є й декілька мінусів:

  1. Використовується кодогенерація, а це збільшує час збірки проєкту (для нашого проєкту це не критичний мінус, до того ж не помітили значного впливу).
  2. Розробляється і підтримується однією особою, тож невідомо, що буде з проєктом в майбутньому.

Міграція на Compose-destinations

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

@RootNavGraph
@Destination
@Composable
fun OtherScreen(
    param1: String?,
    param2: Boolean,
    navigator: DestinationsNavigator
) { /*...*/ }

Зауважу, що @RootNavGraph використовується для позначення, що наш екран відноситься до дефолтного графу. Якщо у вас більше ніж один граф — створіть свою анотацію, наслідувавши її від @NavGraph, і використайте її.

DestinationsNavigator — це бібліотечна абстракція над NavController, ви можете використовувати і звичайний NavController.

Після того, як додали новий екран, потрібно зібрати проєкт, щоб спрацювала кодогенерація. Потім зможемо використати згенерований граф:

DestinationsNavHost(navGraph = NavGraphs.root)

Лаконічно, чи не так? Далі розглянемо, як же навігуватись на певний екран і передавати аргументи:

val navigateToOtherScreen = { param1: String?, param2: Boolean ->
        navigator.navigate(
            OtherScreenDestination(param1, param2)
        )
    }

OtherScreenDestination — також згенерований клас, екземпляр якого ми створюємо з аргументами і передаємо NavController/DestinationsNavigator.

Висновок

У даній статті ми розглянули використання бібліотеки, що значно покращує (суб’єктивна думка) використання navigation-compose. Можливо це рішення не усім підійде, адже я показав лише простий кейс використання, проте, як мінімум, бібліотека варта уваги. Ми з командою були б надзвичайно раді, якби нам хтось її показав перед початком нашої розробки, тому можливо, що для когось тут вона стане відкриттям.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному2
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

А є приклади застосунків, бажано вільних, на цьому вашому Compose? Хочу потицяти, наскільки воно юзабельне.

Бо той же Flutter виглядає як сира поробка рівня графічних бібліотек для написання ігор; у FluffyChat, приміром, стрічаються дитячі проблеми штибу непереносу рядків, які в англійській локалізації влазять, а в инших раптово можуть пробивати екран і висіти у повітрі. Та й узагалі виглядає ненативно й аляпувато — хоча це поки штатний софт Android не переведений на Flutter поступово (втім, для мене «нативним» завжди буде софт часів Gingerbread, чи принаймні Jelly Bean із Holo ×D).

Відвал мозку і злам UX... я очікую, що плюсики спойлери розгортатимуть, а вони замість прапорців там.

Бібліотека

Compose-destinations

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

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