Я обогнал Google?

от автора

Навигация в Compose больше не проблема

Всем привет! Меня зовут Евгений, и я — Android-разработчик. Я не собираюсь соревноваться с Google, но, кажется, кое в чем я их все-таки обогнал.

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

Стандартный подход и его «болевые точки»

Для стандартной библиотеки Jetpack Navigation есть официальная документация и множество туториалов. Они достаточно легко гуглятся, и приводить их здесь я не вижу смысла. Давайте сразу разберемся, как это работает на практике и с какими проблемами мы сталкиваемся.

Сначала все выглядит просто. Нам нужен NavHost, в котором нужно указать startDestination, а каждый экран описывается внутри блока composable. Чтобы избежать «магических строк», мы создаем enum со списком всех экранов.

@Composable fun AppNavHost(     modifier: Modifier = Modifier,     navController: NavHostController,     startDestination: String = AppNavHostNavigationScreens.Start.route ) {     NavHost(         modifier = modifier,         navController = navController,         startDestination = startDestination     ) {         composable(             route = AppNavHostNavigationScreens.Start.route,         ) {             BonusInfoScreen(                 navController = navController,             )         }     } } 

Далее, чтобы заставить приложение переходить между экранами, необходимо вызвать navController.navigate("screen_name"). Оставлять маршруты в виде простых строк мы, конечно, не можем себе позволить во избежание опечаток и случайных изменений, поэтому создаем enum со списком всех экранов.

enum class AppNavHostNavigationScreens(val route: String) {     Settings("settings"),     Faq("faq"),     Main("main"),     BonusInfo("bonusinfo"), } 

Теперь и в NavHost, и при навигации мы просто используем элемент из enum — все красиво и хорошо работает.

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

  1. Сначала в наш enum со списком экранов придется добавить все параметры. Маршрут превращается в сложную строку, которую легко сломать.

enum class AppNavHostNavigationScreens(val route: String) {     // ...     Interest("interest?idn={idn}&currency={currency}&startYear={startYear}"), } 
  1. Затем в NavHost их нужно зарегистрировать как аргументы. Это превращается в довольно громоздкую конструкцию, где для каждого параметра нужно описать его тип и значение по умолчанию.

composable(     route = AppNavHostNavigationScreens.Interest.route,     arguments = listOf(        navArgument("idn") {           defaultValue = 0L           type = NavType.LongType           nullable = false        },        navArgument("currency") {           defaultValue = ""           type = NavType.StringType           nullable = false        },        navArgument("startYear") {           defaultValue = 0           type = NavType.IntType           nullable = false        },     ), ) 
  1. А в месте назначения — правильно прочитать и распарсить, что выглядит небезопасно и многословно.

{     InterestScreen(        navController = navController,        idn = it.arguments?.getLong("idn") ?: 0,        currency = it.arguments?.getString("currency").orEmpty(),        startYear = it.arguments?.getInt("startYear") ?: 0,     ) } 

И при этом Jetpack Navigation из коробки предоставляет инструменты только для работы со строками, числами и другими примитивными типами. А передать собственный класс данных Parcelable или Serializable просто невозможно. (Но это неточно 😉).

ЧАСТЬ 2: Первые шаги к решению и «поворотный момент»

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

  1. Создаем типизированный список аргументов с указанием типов данных и значений по умолчанию.

  2. Пишем extension-функции для безопасного чтения и записи этих аргументов.

  3. Создаем extension для навигации, который умеет работать с нашими аргументами и избавляет от рутины.

  4. Бонусом добавляем логирование, чтобы видеть, куда происходит навигация, и отслеживать потенциальные ошибки.

Давайте реализуем этот план по шагам.

Шаг 1: Создаем типизированные аргументы

Начнем с самого главного — избавимся от «магических строк» в качестве ключей для аргументов. Вместо них мы создадим sealed class, который будет централизованно хранить всю информацию о каждом аргументе: его имя, тип и значение по умолчанию.

sealed class NavArgNames<T(     val name: String,     val argType: NavType<T,     val argDefaultValue: T? = null ) {     data object PhoneNumber : NavArgNames<String?("phoneNumber", NavType.StringType, "")     data object FaqType : NavArgNames<String?("faqType", NavType.StringType, "")     // ... и другие возможные аргументы } 

Шаг 2: Extension-функции для работы с аргументами

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

fun setNavArgs(vararg args: NavArgNames<*): String {     return args.joinToString(separator = "&", prefix = "?") {         "${it.name}={${it.name}}"     } } 

Затем, создадим extension, который превращает наш типизированный аргумент в NamedNavArgument, понятный для NavHost:

fun NavArgNames<*.getArgument(): NamedNavArgument {     return navArgument(this.name) {         defaultValue = argDefaultValue         type = argType     } } 

И самое главное — напишем extension для NavBackStackEntry, который будет безопасно извлекать и приводить тип нашего аргумента, используя sealed class в качестве ключа.

inline fun <reified T NavBackStackEntry.getArgument(type: NavArgNames<T): T {     return when (type.argType) {         NavType.StringType - this.arguments?.getString(type.name) as T         NavType.IntType - this.arguments?.getInt(type.name) as T         NavType.LongType - this.arguments?.getLong(type.name) as T         // ... и так далее для всех остальных примитивных типов         else - type.argDefaultValue as T     } } 

Примечание: Ключевые слова inline и reified позволяют нам работать с обобщенным типом T внутри функции почти как с реальным типом, что делает получение аргумента типобезопасным и избавляет от множества проблем с приведением типов.

Шаг 3: Создаем extension для навигации

Теперь, когда у нас есть все вспомогательные «кирпичики», мы можем собрать их вместе в одной удобной extension-функции для NavHostController. Эта функция станет нашей единой точкой входа для всех переходов в приложении. Она будет принимать специальный объект NavigationAction, сама конструировать маршрут и применять настройки popUpTo.

fun <T NavHostController.navigateSafety(     action: NavigationAction, ) {     val baseRoute = action.toScreen.route.split("?").first()     val route = when {         action.args.isNotEmpty() - {             baseRoute.plus(                 action.args.joinToString(                     separator = "&",                     prefix = "?",                 ) { "${it.first.name}=${it.second}" }             )         }           else - baseRoute     }       this.navigate(         route = route     ) {         if (action.popUpTo != null) {             popUpTo(action.fromScreen.route) {                 inclusive = action.inclusive             }         }     } } 

Шаг 4: Бонусом добавляем логирование

В качестве приятного бонуса, давайте добавим немного логирования в нашу функцию navigateSafety, чтобы в Logcat было видно все детали переходов. Для этого просто добавим в самое начало функции следующий блок кода:

// Этот код вставляется в начало функции navigateSafety val logText = buildString {     append(" ---------------------------------------------------------------------------------\n")     append("| Navigation action: ${action.javaClass.simpleName}\n")     append("| ${action.fromScreen} - ${action.toScreen}\n")     if (action.args.isNotEmpty()) {         append("| ${action.args.joinToString("&") { "${it.first.name}=${it.second}" }}\n")     }     action.popUpTo?.let {         append("| popUpTo: $it\n")     }     if (action.inclusive) append("| is inclusive: ${action.inclusive}\n")     append(" ---------------------------------------------------------------------------------") } Timber.d(logText) 

Теперь при каждом вызове navigateSafety в Logcat мы будем видеть подробный и понятный отчет:
Фрагмент кода

--------------------------------------------------------------------------------- | Navigation action: OpenInterestScreen | Main - Interest | idn=123&currency=USD&startYear=2025 | popUpTo: Main  --------------------------------------------------------------------------------- 

Промежуточные итоги

Итак, что мы имеем? В целом, выглядит неплохо, не так ли? Теперь, чтобы при создании экрана передать в него данные, нужно всего лишь добавить его в наш sealed class NavArgNames, потом в NavHost, описать все его аргументы и их типы с помощью наших extension-функций.

Вам нравится?

Мне — нет!

Да, стало гораздо легче и безопаснее, чем было. Но я все еще не вижу здесь красивого и удобного инструмента. Это все еще «сделай сам» с кучей рутинной работы, где легко ошибиться.

ЧАСТЬ 3: Магия кодогенерации

А что, если не писать этот код вообще?

Эта рутина и навела меня на мысль: ведь весь этот код для регистрации экранов, аргументов и навигации подчиняется одному и тому же алгоритму. А что, если написать инструмент, который будет генерировать весь этот код за нас?

К счастью, у Kotlin есть прекрасный инструмент для кодогенерации — KSP (Kotlin Symbol Processing). С ним мой план кардинально изменился:

1. Нам нужно получить от разработчика список экранов с их аргументами.

Для этого создаем простую аннотацию @KoGenScreen. (Я очень скромный, поэтому назвал библиотеку своим именем 😉).

У этой аннотации всего три параметра:

val startDestination: Boolean = false, val navHostName: String = "AppNavHost", val animation: NavigationAnimation = NavigationAnimation.None, 
  • startDestination — флаг, указывающий, что этот экран является стартовым в графе навигации.

  • navHostName — название хоста. Это нужно в тех случаях, если вам требуется несколько независимых графов навигации. Все экраны будут группироваться по этому признаку. Если не задавать его, все будет в одном хосте по умолчанию.

  • animation — тип анимации перехода (про это расскажу позже).

2. Получив эти данные на этапе компиляции, мы можем начать генерировать код. Нам нужно создать несколько вещей:

  • Список экранов в виде enum.

  • Готовый NavHost.

  • Все extension-функции, которые мы писали руками ранее.

  • NavigationAction — специальный класс для управления переходами (про него тоже чуть позже).

Как работает кодогенерация

Генерация Enum со списком экранов

Список экранов — достаточно простая задача. Генератор берет все ваши Composable-функции, отмеченные аннотацией @KoGenScreen, убирает из названия слово «Screen» (просто мне так захотелось) и использует оставшуюся часть как имя для элемента enum. Внутрь этого элемента помещается строка route со всеми переменными, нужными для экрана.

enum class AppNavHostNavigationScreens(override val route: String): kz.evko.navigation.routes.RouteScreenType {     Main("main"),     Second("second?title={title}"),     Third("third?screenNumber={screenNumber}&screenColor={screenColor}"),     Fourth("fourth?screenColor={screenColor}&titleColor={titleColor}&title={title}"), } 

Генерация NavHost

NavHost сгенерировать чуть сложнее. Сначала KSP-процессор ищет экран с флагом startDestination. Если не находит — просто берет первый попавшийся экран в качестве стартового. Впрочем, это не принципиально, так как вы всегда можете переопределить стартовый экран при вызове NavHost.

Далее генератор описывает каждый экран ровно так, как мы это делали вручную (разницу в анимации мы обсудим позже).

Для экрана без аргументов код получается простым:

composable(     route = AppNavHostNavigationScreens.Main.route, ) {     MainScreen(         navController = navController,     ) } 

Если же аргументы есть, генератор создает более сложный код с их регистрацией:

composable(     route = AppNavHostNavigationScreens.Second.route,     arguments = listOf(         navArgument("title") {             defaultValue = ""             type = NavType.StringType             nullable = false         },     ), ) {     SecondScreen(         navController = navController,         title = it.arguments?.getString("title").orEmpty(),     ) } 

Работа с типами данных

Здесь стоит подробнее осветить тему типов. Jetpack Navigation нативно поддерживает передачу следующих типов: String, Boolean, Int, Long, Float и их Array-версии. Моя библиотека также поддерживает List для этих типов, автоматически преобразуя их в массивы и обратно для вашего удобства.

А что же с остальными типами, спросите вы? А вот и главная магия: все, что не входит в этот список (например, ваши собственные классы данных), автоматически сериализуется в JSON-строку при передаче и десериализуется обратно в объект на целевом экране.

Генерация вспомогательных функций

Теперь, чтобы использовать всю эту красоту, необходимы extension-функции. Их мы тоже сгенерируем автоматически.

Функция для безопасного перехода:

fun NavHostController.navigateSafety(     action: kz.evko.navigation.routes.NavigationAction,     popUpTo: kz.evko.navigation.routes.RouteScreenType? = null,     inclusive: Boolean = false, ) {     Log.d("NavigateSafety", action.navigationLog(popUpTo, inclusive))      navigate(action.route) {         popUpTo?.let {             popUpTo(it.route) {                 this.inclusive = inclusive             }         }     } } 

И функция для безопасного возврата на предыдущий экран:

fun NavHostController.popBackSafety() {     if (previousBackStackEntry != null) {         Log.d(             "PopBackSafety",             kz.evko.navigation.routes.navigationBackLog(                 fromScreen = this@popBackSafety.currentDestination?.route                     ?.split("?")?.firstOrNull()?.capitalize(Locale.current),                 toScreen = this@popBackSafety.previousBackStackEntry?.destination?.route                     ?.split("?")?.firstOrNull()?.capitalize(Locale.current)             )         )          popBackStack()     } } 

ЧАСТЬ 4: Продвинутые возможности и результат

Безопасная передача аргументов: NavigationAction

Это, пожалуй, та часть, над которой я ломал голову дольше всего. Мне отчаянно хотелось создать что-то, похожее на Safe Args из мира XML, — инструмент, который ловит ошибки передачи аргументов еще на этапе компиляции (compile time), а не в crash log-ах, отчетах от QA (ребята, я вас обожаю) или в гневных отзывах клиентов.

Идея проста: нам нужен класс, который будет инкапсулировать в себе все аргументы, необходимые для перехода на конкретный экран. Этот класс будет наследоваться от одного общего предка.

Как это работает

Генератор создает базовый класс NavigationAction:

open class NavigationAction(     val route: String, ) 

А дальше для каждого экрана генерируется свой класс-наследник.

  • Если у экрана нет аргументов, создается легковесный data object.

  • Если аргументы есть, создается обычный class. (Помним про оптимизацию: нам не нужны методы equals, hashCode и toString для этих классов, поэтому используется class, а не data class).

Пример для экрана без аргументов:

data object ActionToMain : NavigationAction(     route = "main", ) 

Пример для экрана с аргументами:

class ActionToSecond(     title: String, ) : NavigationAction(     route = "second?title=$title", ) 

Особые случаи: NavController и ViewModel

Есть два типа параметров, которые обрабатываются особым образом:

  1. NavHostController: Он автоматически исключается из списка аргументов в NavigationAction, так как мы можем напрямую передать его из NavHost в каждую Composable-функцию экрана.

  2. ViewModel: Обычно при использовании DI-фреймворков мы привязываем ViewModel прямо в аргументах функции: MyScreen(viewModel: MyViewModel = koinViewModel()). Генератор также распознает это и исключает ViewModel из NavigationAction.

Но в качестве бонуса я добавил возможность автоматизировать и это! Вы можете в build.gradle передать аргумент viewModelInjector. Он принимает значения koin или hilt, и в этом случае реализация ViewModel будет подставлена в NavHost автоматически, и вам даже не придется писать ее на экране.

  • Без авто-инъекции: SecondViewModel = koinViewModel()

  • С использованием авто-инъекции: SecondViewModel

Работа с Nullable-типами

И последнее, но не менее важное: библиотека корректно обрабатывает nullable-аргументы, передавая и получая их без проблем.

А что с анимациями? NavigationAnimation

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

В моей библиотеке этот процесс устроен максимально просто и гибко. В первой версии доступно 5 готовых типов анимации: Fade, SlideLeft, SlideRight, SlideUp, SlideDown.

Управлять ими можно на двух уровнях:

  • Глобальная анимация по умолчанию. Чтобы не указывать анимацию для каждого экрана, вы можете задать ее один раз для всего модуля. Для этого в файле build.gradle вашего модуля нужно добавить параметр defaultAnimation в настройки KSP.

  • Индивидуальная анимация для экрана. Если для какого-то конкретного экрана нужна особая анимация, вы можете легко переопределить глобальную настройку, указав нужный тип прямо в аннотации @KoGenScreen(animation = ...) на этом экране.

Генератор сам подставит в NavHost весь необходимый код. Вот пример кода, который генератор создает для анимации SlideLeft:

enterTransition = {     androidx.compose.animation.slideIn(         initialOffset = {             androidx.compose.ui.unit.IntOffset(it.width, 0)         }     ) }, exitTransition = {     androidx.compose.animation.slideOut(         targetOffset = {             androidx.compose.ui.unit.IntOffset(-it.width, 0)         }     ) }, popEnterTransition = {     androidx.compose.animation.slideIn(         initialOffset = {             androidx.compose.ui.unit.IntOffset(-it.width, 0)         }     ) }, popExitTransition = {     androidx.compose.animation.slideOut(         targetOffset = {             androidx.compose.ui.unit.IntOffset(it.width, 0)         }     ) } 

Финальный штрих: Возвращаем результат с экрана (NavigationResult)

Иногда возникает задача, которую стандартная навигация решает не очень элегантно: как вернуть результат с одного экрана на предыдущий? Например, пользователь на экране B выбрал какой-то элемент, и нам нужно обновить экран A этим выбором после возвращения.

Чтобы и этот процесс максимально упростить, в библиотеке есть механизм NavigationResult.

Шаг 1: Определяем ключ для результата

Для начала, мы снова используем sealed class, чтобы типобезопасно описать ключ и тип данных, которые мы хотим вернуть.

// Этот класс можно создать в любом месте проекта sealed class NavigationResultValues<T(override val key: String, override val defaultValue: T) :     NavigationResultKey<T {     data object ShowToast : NavigationResultValues<Boolean("showToast", false) } 

Шаг 2: Возвращаем результат

Теперь с экрана, который должен вернуть данные (экран B), мы вызываем нашу popBackSafety функцию, передавая в нее специальный объект BackStackData.

// Пример вызова на экране B backClick = {     navController.popBackSafety(         backStackData = BackStackData(NavigationResultValues.ShowToast, true)     ) } 

Шаг 3: Получаем результат

А на предыдущем экране (экран A), который ожидает результат, мы «ловим» его с помощью getResultData внутри LaunchedEffect.

// Пример получения результата на экране A LaunchedEffect(Unit) {     if (navController.getResultData(NavigationResultValues.ShowToast) == true) {         Toast.makeText(context, "It's a toast from nav result", Toast.LENGTH_SHORT).show()     } } 

Благодаря такой конструкции, getResultData возвращает нам данные нужного типа, и мы можем безопасно с ними работать.

Как это работает «под капотом»?

Вся магия происходит в двух extension-функциях, которые также генерируются автоматически.

Во-первых, мы немного дополняем наш popBackSafety, чтобы он умел записывать данные в SavedStateHandle предыдущего экрана:

fun NavHostController.popBackSafety(backStackData: BackStackData?) {     // ... (старый код с логированием)      // Добавляется обработка результата     backStackData?.let {         previousBackStackEntry?.savedStateHandle?.set(it.data.key, it.value)     }     popBackStack() } 

Во-вторых, добавляется новый extension для чтения этого результата:

fun <T NavHostController.getResultData(     data: kz.evko.navigation.helpers.NavigationResultKey<T,     clearData: Boolean = true, ): T? {     val result = this.currentBackStackEntry?.savedStateHandle?.get(data.key) as T?     if (clearData) this.currentBackStackEntry?.savedStateHandle?.remove<T(data.key)     return result } 

ЧАСТЬ 5: Финал, руководство и заключение

Так как же теперь выглядит навигация?

Итак, к чему мы пришли после всех этих улучшений?

После однократной настройки проекта все, что вам теперь нужно сделать для добавления нового экрана в граф навигации, — это поставить над его Composable-функцией одну аннотацию @KoGenScreen.

Вот и все.

// Просто создаем Composable-функцию экрана, описывая все нужные ей параметры @KoGenScreen @Composable fun MyAwesomeScreen(     // NavController будет предоставлен автоматически     navController: NavHostController,     // Этот параметр будет автоматически превращен в обязательный аргумент навигации     myArgument: String,     // ViewModel будет обработан и исключен из списка аргументов     viewModel: MyViewModel = koinViewModel() ) {     // ... ваш UI ... } 

После следующей сборки проекта KSP автоматически сгенерирует для вас класс ActionToMyAwesome со всеми необходимыми параметрами (myArgument в нашем случае). Вам больше не нужно переживать, что вы или ваш коллега забудете передать какой-то параметр или передадите его с неверным типом — проект просто не скомпилируется.

Мы получили safe args, причем такой, который даже не нужно объявлять в XML.

Ограничения и особенности

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

  1. Это KSP, и он работает во время сборки. Это создает определенный порядок действий: вы создаете новую Composable-функцию, ставите аннотацию, собираете проект, и только после этого появляются сгенерированный класс Action и другие компоненты, которые можно использовать в коде.

  2. Сгенерированный код находится в вашем модуле. Из-за некоторых особенностей KSP, extension-функции и NavHost помещаются не в саму библиотеку, а генерируются прямо в вашем проекте. Это значит, что при первоначальной настройке вам нужно добавить хотя бы один экран с аннотацией и собрать проект, чтобы эти функции появились.

  3. KSP иногда «сходит с ума». Это крайне редкое явление, но иногда кэш KSP может «засориться», и генерация перестает корректно работать. Если вы столкнулись с необъяснимым поведением, стандартное лечение — полная очистка проекта (например, через ./gradlew clean) и пересборка.

  4. Необходимость ручного подключения зависимостей. Моя библиотека является удобной надстройкой над стандартным Jetpack Navigation, а не его полной заменой. Поэтому для ее работы вам потребуется самостоятельно подключить в свой проект саму библиотеку навигации: androidx.navigation:navigation-compose. Полный список необходимых зависимостей вы найдете в документации на GitHub.

Руководство по установке и настройке

Библиотека опубликована в Maven Central (за что огромное спасибо моим друзьям-девопсам, без них я бы не справился!). Чтобы начать ей пользоваться, нужно выполнить несколько простых шагов по настройке.

Шаг 1: Подключаем плагин KSP

Сначала убедитесь, что плагин KSP подключен к вашему проекту.
В файле build.gradle.kts корневого проекта:

plugins {     // ...     id("com.google.devtools.ksp") version "2.0.0-1.0.21" apply false } 

В файле build.gradle.kts вашего модуля:

plugins {     // ...     id("com.google.devtools.ksp") } 

Шаг 2: Добавляем зависимости

В dependencies вашего модуля добавьте все необходимые зависимости: саму библиотеку Jetpack Navigation, вашу библиотеку и ее KSP-процессор.

dependencies {     // Сама библиотека Jetpack Navigation     implementation("androidx.navigation:navigation-compose:2.7.7")      // Наша библиотека     implementation("io.github.eugenprog:navigation-compose:1.0.0")     ksp("io.github.eugenprog:navigation-compose:1.0.0") } 

Важно: Всегда проверяйте последние актуальные версии библиотек. Версии для implementation и ksp вашей библиотеки должны совпадать.

Шаг 3: Настраиваем кодогенерацию

В build.gradle.kts вашего модуля добавьте блок ksp для настройки генератора.

ksp {     arg("packageName", "com.myawesome.project")     arg("defaultAnimation", "slideLeft")     arg("viewModelInjector", "koin") } 

Объяснение параметров:

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

  • defaultAnimation (опциональный) — анимация по умолчанию для всех переходов. Принимает значения: slideLeft, slideRight, slideUp, slideDown, fade, none.

  • viewModelInjector (опциональный) — для автоматической подстановки ViewModel. Принимает значения: koin, hilt.


Вот и все! После этих настроек и первой сборки проекта вы можете начать пользоваться библиотекой, наслаждаться приятными отзывами клиентов и в освободившееся время пить кофе.

P.S. Вашим ПМ необязательно знать, что работы стало меньше. 😉

Вместо заключения

Спасибо, что дочитали до конца!

Это лишь первая версия библиотеки, и я планирую ее активно развивать: исправлять ошибки (если они найдутся) и делать ее еще удобнее для коллег-андроидщиков. Я сам использую ее уже в трех проектах и наслаждаюсь ее прекрасной работой.

Полный демо-проект, а также документацию по использованию вы можете найти в репозитории на GitHub.

Вывод, обогнал я Google или нет — на ваше усмотрение. 😉


ссылка на оригинал статьи https://habr.com/ru/articles/918562/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *