Context receivers — новые extension functions
Думаю, не раскрою большой секрет, что Ozon разработал энное количество мобильных приложений: для покупателей, для продавцов, банк и т. д. В каждом из них требуется авторизация. Для этого существует наша команда Ozon ID с SDK собственного производства. Частью команды Ozon ID являюсь я — Android-разработчик с непомерной любовью к синтаксическому сахару Kotlin.
Введение
Поговорим сегодня про context receivers — фиче Kotlin, про которую я узнал давно, но смог найти применение лишь пару месяцев назад. Расскажу о том, что такое context receivers, где их можно использовать, и, конечно же, про «успешный успех» — минус 60% самописного DI в Ozon ID SDK. Но обо всём по порядку.
Функции расширения
В коде Ozon ID SDK есть следующее расширение для ComponentActvity
.
inline fun ComponentActvity.collectWhenStarted(
data: Flow,
crossinline collector: (T) -> Unit
) {
lifecycleScope.launchWhenStarted {
data.collect {
collector.invoke(it)
}
}
}
Ремарка
Я знаю, что существует более надёжный способ collect«ить Flow на ui-потоке. Например, с помощью flowWithLifecycle
. Но в нём больше параметров, что будет только отвлекать от основной темы.
Реализовано расширение collectWhenStarted
исключительно для удобства сбора данных с множества Flow
из ViewModel
внутри Activity
. Ниже пример использования этого расширения.
class AuthFlowActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
collectWhenStarted(viewModel.backStack) { onBackStackChanged(it) }
collectWhenStarted(viewModel.navigationEvents) { onNavigationEvent(it) }
...
}
}
Удобно? В общем-то да. Вызов бесспорно короче, чем без использования расширения. Идеально? Отнюдь. Лично мне хотелось бы видеть вызов collectWhenStarted
примерно следующим образом:
class AuthFlowActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
viewModel.backStack.collectWhenStarted { onBackStackChanged(it) }
viewModel.navigationEvents.collectWhenStarted { onNavigationEvent(it) }
...
}
}
Вызвать collect*
у Flow
более интуитивно, чем collect*
у Activity
, не правда ли?
Для того чтобы реализовать улучшенный вариант расширения collectWhenStarted
, технически нам понадобится, чтобы механизм методов-расширений мог принимать 2 receiver-параметра. К сожалению, JetBrains пока не реализовали такую возможность в языке Kotlin… Или реализовали?
Реализация через scope
На самом деле есть такой подход, как scope. При таком подходе метод-расширение реализуется в новом классе. В случае с Activity
решение на scope можно применить следующим образом.
interface LifecycleOwnerScope : LifecycleOwner {
fun Flow.collectWhenStarted(collector: (T) -> Unit) {
lifecycleScope.launchWhenStarted { collect { collector.invoke(it) } }
}
}
Имплементируем в Activity
интерфейс LifecycleOwnerScope
, и готово — у Flow
появляется расширение collectWhenStarted
. При этом реализация интерфейса LifecycleOwnerScope
внутри Activity
не требуется за счёт «реализации по умолчанию».
class AuthFlowActivity : AppCompatActivity(), LifecycleOwnerScope {
// ^ Добавили scope ^
override fun onCreate(savedInstanceState: Bundle?) {
... // collectWhenStarted берётся из LifecycleOwnerScope
viewModel.backStack.collectWhenStarted { onBackStackChanged(it) }
viewModel.navigationEvents.collectWhenStarted { onNavigationEvent(it) }
...
}
}
Подход вполне жизнеспособный. Но, как по мне, не без недостатков. Например, чтобы расширение collectWhenStarted
заработало нужно собственноручно отнаследоваться от LifecycleOwnerScope
. Это не так удобно, как глобальные расширения, которые IDE услужливо сама подсказывает при вводе имени.
Давайте уже перейдём к context receivers.
Context receivers спешат на помощь
Context receivers — это концепт, фича языка Kotlin. Context receivers добавлены в язык как инструмент, позволяющий преодолеть ограничения extension-функций. Технически context receivers, как и extension-функции, компилируются в статический метод с дополнительным this
-параметром. То есть context receivers являются очередным синтаксическим сахаром Kotlin, но, конечно же, круче и «слаще», чем extension.
Context receivers появились аж в Kotlin 1.6.20. Вместе с context receivers в язык добавили новое ключевое слово context
. Фича в версии Kotlin 1.9.22 всё ещё экспериментальная, поэтому если попытаться сходу ей воспользоваться, то IDE выдаст следующее сообщение.
The feature «context receivers» is experimental and should be enabled explicitly
Для того чтобы context receivers заработали, нужно дополнить файл build.gradle
модуля, в котором будет использован context
.
tasks.withType().configureEach {
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
}
}
Теперь можно использовать context
в коде. Приступим. Будем модифицировать расширение поэтапно.
Шаг 1: Перенести receiver-параметр ComponentActvity
в аргументы context
.
context (ComponentActvity)
inline fun /*ComponentActvity.*/collectWhenStarted(
data: Flow,
crossinline collector: (T) -> Unit
) {
lifecycleScope.launchWhenStarted {
data.collect {
collector.invoke(it)
}
}
}
Байт-код
Байт-код скомпилированной функции с context receivers получается точно такой же, как и его прямой аналог, реализованный через extension. К сожалению, декомпилировать байт-код с context receivers у меня не вышло. Может, кто-нибудь из читателей подробнее расследует данный вопрос и приведёт свои примеры в комментариях.
Ниже приведена сигнатура декомпилированной из байт-кода extension-функции. Напомню, что байт-код идентичен аналогу на context receivers.
public static final void collectWhenStarted(
@NotNull ComponentActivity $this$collectWhenStarted,
@NotNull final Flow data,
@NotNull final Function1 collector
)
Код скомпилируется и выполнится, как и на прежней реализации через расширение. Но есть один нюанс, на котором стоит остановиться. Дело в том, что context receivers — это не extension (ваш капитан Очевидность). Функции с context receivers нельзя вызвать, как будто это метод класса. Пример в сниппете ниже.
// Реализация через extension
activity.collectWhenStarted() // компилятор позволяет вызвать функцию-расширение `collectWhenStarted`, как будто это метод класса
// Реализация через context receivers
activity.collectWhenStarted() // Ошибка: Unresolved reference: collectWhenStarted
with(activity) { // apply, run тоже подойдут
collectWhenStarted() // Функцию с context можно вызвать только внутри "контекста"
}
Шаг 2: Перенести аргумент data: Flow
в receiver функции
context (ComponentActvity)
inline fun Flow.collectWhenStarted(
/*data: Flow,*/
crossinline collector: (T) -> Unit
) {
lifecycleScope.launchWhenStarted {
/*data.*/collect {
collector.invoke(it)
}
}
}
Вуаля! Готово. Теперь у Flow
внутри (иначе говоря, «в контексте») ComponentActvity
появится метод collectWhenStarted
.
class AuthFlowActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
... // powered by context receivers
viewModel.backStack.collectWhenStarted { onBackStackChanged(it) }
viewModel.navigationEvents.collectWhenStarted { onNavigationEvent(it) }
...
}
}
We need to go deeper
Приоткрою ещё немного информации относительно ключевого слова context
:
context
может принимать более одного аргумента. Например,context (classA, classB)
.context
можно прописывать над классами.
Прямо как аннотация
@Component(dependencies = [...])
в Dagger, не правда ли?
Начну разбор этих пунктов с последнего — context
над классом. В качестве основы для примеров возьму DI из Ozon ID SDK.
internal class RootModule(
val application: Application
)
context(RootModule)
internal class ChildModule {
val dependency by lazy {
Dependency(
/*this@RootModule.*/application,
...
)
}
}
Как видно из примера выше, внутри ChildModule
можно обратиться к application
, как-будто это свойство ChildModule
. При этом к свойству можно обратиться и через this
— this@RootModule.application
. Пригодится на случай конфликта имён context receivers или ради внесения ясности в вопрос «откуда взялась эта зависимость?».
Продолжим погружение. Рассмотрим, как создавать зависимости с context receivers.
context(RootModule)
internal class SubChildModule
context(RootModule)
internal class ChildModule {
...
val subChildModule by lazy {
// with не нужен, уже в нужном контексте
SubChildModule()
}
}
val childModule = with(rootModule) {
// Нужен with для создания
ChildModule()
}
Для того чтобы создать объект класса с context receivers, нужно, чтобы вызов конструктора происходил в требуемом контексте. Контекст можно задать следующим образом:
создать экземпляр внутри класса, требуемого в context receiver;
создать экземпляр внутри независимого класса, в context receivers которого есть нужный класс. Например, как создан модуль
SubChildModule
внутриChildModule
;создать внутри scope-функции c лямбдой-расширением в качестве аргумента (with, apply, run). Подойдут и собственные функции с аналогичным параметром.
Теперь рассмотрим создание объектов с несколькими context receivers.
context(RootModule, RepositoryModule, NetworkModule, CookieModule)
internal class MultiContextModule
val multiContextModule = with(rootModule) {
with(repositoryModule) {
with(networkModule) {
with(cookieModule) {
MultiContextModule()
}
}
}
}
Как можно заметить, код создания экземпляра класса с несколькими context receivers может оказаться не столь изящным, как хотелось бы. Но это поправимо, потому что context
можно прописывать к лямбда-аргументам. Возьмём за основу код with
из стандартной библиотеки Kotlin и обогатим его context
.
// Пример из stdlib
@kotlin.internal.InlineOnly
public inline fun with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}
// with context receivers
@OptIn(ExperimentalContracts::class)
@Suppress("SUBTYPING_BETWEEN_CONTEXT_RECEIVERS")
internal inline fun with(c1: T1, c2: T2, block: context(T1, T2) () -> R): R {
contract { // ^^^^^^^
callsInPlace(block, InvocationKind.EXACTLY_ONCE) // см. сюда
}
return block(c1, c2)
}
В примере выше к типу лямбды block
добавлено ключевое слово context
. Параметры context
— generic-типы. Подобных with
можно написать больше под нужное количество context receivers. В свою очередь, это позволяет нам значительно уменьшить сдвиг кода вправо при создании MultiContextModule
.
context(RootModule, RepositoryModule, NetworkModule, CookieModule)
internal class MultiContextModule
val multiContextModule = with(
rootModule,
repositoryModule,
networkModule,
cookieModule
) {
MultiContextModule()
}
Итоги
Подведём черту под тем, что мы сегодня узнали. В этом нам поможет файл KEEP по context receivers.
Context receivers — это механизм, призванный расширить возможности extension-функций. Причём, как мы видели на примере с
Flow
, именно расширить, а не заменить.Context receivers можно прописывать как над функциями и свойствами, так и над классами.
Context receivers позволяют использовать более одного receiver-аргумента.
Context receivers непонятно где и когда применять. Как минимум у меня пока не сложилось чёткого мнения, чтобы я мог однозначно сказать «вот тут extension, тут передать аргументом, а тут обязательно context». В видео в конце статьи можете найти размышления на эту тему. Я же пока в данном вопросе буду придерживаться исключительно технического момента.
Заключение
Про context receivers я узнал почти 2 года назад из видео от Jetbrains, не смог придумать им никакого полезного применения и отложил на дальнюю полку знаний про Kotlin. Однако пару месяцев назад мне довелось посмотреть доклад с Droidcon, который помог открыть глаза на всю мощь данного механизма. И тут понеслось: рефакторинг DI в Ozon ID SDK, доклад внутри мобильной команды и в результате эта статья. Надеюсь, что у меня получилось донести до читателей мощь context receivers и подтолкнуть их на дальнейший поиск применимости этой фичи.