Как стать автором
Обновить

Я обогнал Google?

Время на прочтение15 мин
Количество просмотров5.4K

Навигация в 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 или нет — на ваше усмотрение. 😉

Теги:
Хабы:
+6
Комментарии6

Публикации

Работа

Ближайшие события