Навигация в 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 — все красиво и хорошо работает.
Но эта идиллия длится ровно до тех пор, пока нам не понадобится передать данные между экранами — а такая задача возникает довольно часто. Стандартный подход превращает ее в настоящее испытание:
Сначала в наш enum со списком экранов придется добавить все параметры. Маршрут превращается в сложную строку, которую легко сломать.
enum class AppNavHostNavigationScreens(val route: String) {
// ...
Interest("interest?idn={idn}¤cy={currency}&startYear={startYear}"),
}
Затем в 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
},
),
)
А в месте назначения — правильно прочитать и распарсить, что выглядит небезопасно и многословно.
{
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: Первые шаги к решению и "поворотный момент"
Я задался целью все это упростить и обезопасить: писать меньше кода и получать более надежный результат. В итоге родился простой план из нескольких шагов:
Создаем типизированный список аргументов с указанием типов данных и значений по умолчанию.
Пишем
extension
-функции для безопасного чтения и записи этих аргументов.Создаем
extension
для навигации, который умеет работать с нашими аргументами и избавляет от рутины.Бонусом добавляем логирование, чтобы видеть, куда происходит навигация, и отслеживать потенциальные ошибки.
Давайте реализуем этот план по шагам.
Шаг 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¤cy=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
Есть два типа параметров, которые обрабатываются особым образом:
NavHostController
: Он автоматически исключается из списка аргументов вNavigationAction
, так как мы можем напрямую передать его изNavHost
в каждую Composable-функцию экрана.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.
Ограничения и особенности
Но, как и у любого инструмента, у моего подхода есть свои особенности, о которых будет честно рассказать сразу.
Это KSP, и он работает во время сборки. Это создает определенный порядок действий: вы создаете новую
Composable
-функцию, ставите аннотацию, собираете проект, и только после этого появляются сгенерированный классAction
и другие компоненты, которые можно использовать в коде.Сгенерированный код находится в вашем модуле. Из-за некоторых особенностей KSP,
extension
-функции иNavHost
помещаются не в саму библиотеку, а генерируются прямо в вашем проекте. Это значит, что при первоначальной настройке вам нужно добавить хотя бы один экран с аннотацией и собрать проект, чтобы эти функции появились.KSP иногда "сходит с ума". Это крайне редкое явление, но иногда кэш KSP может "засориться", и генерация перестает корректно работать. Если вы столкнулись с необъяснимым поведением, стандартное лечение — полная очистка проекта (например, через
./gradlew clean
) и пересборка.Необходимость ручного подключения зависимостей. Моя библиотека является удобной надстройкой над стандартным 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 или нет — на ваше усмотрение. 😉