Як зробити навігацію в 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 і недоліки даної реалізації
Щоб передавати аргументи, нам потрібно:
- описати аргументи, які може приймати екран (ключ, тип);
- змінити шлях екрана з вказанням ключів аргументів;
- навігуючись, потрібно створити шлях з потрібними параметрами;
- отримати передані аргументи із 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)
Але як показав час, цей підхід має доволі вагомі недоліки:
- бойлерплейт (коли у проєкті багато екранів — це починає набридати);
- можна банально зробити одруківку (у шляху або назві аргументу);
- оскільки шлях парситься як Uri — потрібно слідкувати за тим, щоб String параметри, які передаються, були без спеціальних символів. Або закодувати в формат Url (наприклад, за допомогою URLEncoder.encode);
- рано чи пізно вам знадобиться передати Serializable або Parcelable. Це звісно можливо зробити, попередньо серіалізувавши об’єкт у String, але погодьтеся, писати кожен раз серіалізацію не дуже цікаве заняття.
Compose-destinations
Йшов час, ми мирилися з вищеописаними незручностями, допоки одного дня не натрапили на цікаву бібліотеку — «Compose-destinations». Після прочитання документації стало зрозуміло — це воно!
Для себе виділили наступні переваги:
- Це не окрема бібліотека для навігації, а надбудова над navigation-сompose, основною задачею якої є кодогенерація.
- Оскільки це тільки надбудова, її легко використати у вже існуючому проєкті, де використовується NC. Для нових проєктів поріг входження низький у випадку, якщо вже є досвід зі стандартним NC.
- Не потрібно вручну описувати граф навігації (додавши анотацію @Destination на composable функцію екрана і бібліотека його згенерує).
- Не потрібно думати за те, як передати Serializable/Parcelable.
- Аргументи, які передаються у String форматі кодуються у формат Url.
- Аргументи, які потрібні на певному екрані просто перераховують як параметри composable функції. Додатково можна вказати і NavController — тоді буде прокинуто потрібний контролер для управління навігацією.
- Відпадає потреба щось міняти в описі графів (У нас цей файл був немаленьким — близько 1000 рядків, щось шукати в ньому — ще те задоволення).
Є й декілька мінусів:
- Використовується кодогенерація, а це збільшує час збірки проєкту (для нашого проєкту це не критичний мінус, до того ж не помітили значного впливу).
- Розробляється і підтримується однією особою, тож невідомо, що буде з проєктом в майбутньому.
Міграція на 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. Можливо це рішення не усім підійде, адже я показав лише простий кейс використання, проте, як мінімум, бібліотека варта уваги. Ми з командою були б надзвичайно раді, якби нам хтось її показав перед початком нашої розробки, тому можливо, що для когось тут вона стане відкриттям.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів