Думаю, не раскрою большой секрет, что Ozon разработал энное количество мобильных приложений: для покупателей, для продавцов, банк и т. д. В каждом из них требуется авторизация. Для этого существует наша команда Ozon ID с SDK собственного производства. Частью команды Ozon ID являюсь я — Android-разработчик с непомерной любовью к синтаксическому сахару Kotlin.

Введение

Поговорим сегодня про context receivers — фиче Kotlin, про которую я узнал давно, но смог найти применение лишь пару месяцев назад. Расскажу о том, что такое context receivers, где их можно использовать, и, конечно же, про «успешный успех» — минус 60% самописного DI в Ozon ID SDK. Но обо всём по порядку.

Функции расширения

В коде Ozon ID SDK есть следующее расширение для ComponentActvity.

inline fun <T> ComponentActvity.collectWhenStarted(
    data: Flow<T>,
    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 <T> Flow<T>.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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    kotlinOptions {
        freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
    }
}

Теперь можно использовать context в коде. Приступим. Будем модифицировать расширение поэтапно.   

Шаг 1: Перенести receiver-параметр ComponentActvity в аргументы context.

context (ComponentActvity)
inline fun <T> /*ComponentActvity.*/collectWhenStarted(
    data: Flow<T>,
    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 <T> Flow<T>.collectWhenStarted(
    /*data: Flow<T>,*/
    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:

  1. context может принимать более одного аргумента. Например, context (classA, classB).

  2. 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, нужно, чтобы вызов конструктора происходил в требуемом контексте. Контекст можно задать следующим образом:

  1. создать экземпляр внутри класса, требуемого в context receiver;

  2. создать экземпляр внутри независимого класса, в context receivers которого есть нужный класс. Например, как создан модуль SubChildModule внутри ChildModule;

  3. создать внутри 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 <T, R> 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 <T1, T2, R> 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 и подтолкнуть их на дальнейший поиск применимости этой фичи.

Комментарии (7)


  1. Rusrst
    26.03.2024 11:20
    +1

    Я конечно все понимаю, но launch when started deprecated, зачем такие жертвы, надо вроде их наоборот убирать.


    1. maks1596 Автор
      26.03.2024 11:20
      +1

      Да. Всё верно. Устарели и надо переходить на актуальное API. Например, flowWithLifecycle, который я упомянул в самом начале в блоке "Ремарка"


  1. Libra_by
    26.03.2024 11:20
    +2

    Хорошая статья. Спасибо.


  1. Spinoza0
    26.03.2024 11:20
    +3

    Прикольно, осталось только придумать, где применить)


    1. maks1596 Автор
      26.03.2024 11:20
      +1

      Помимо того, что описал в статье, context receivers неплохо показали себя в связке с ViewBinding + разметка с merge.

      Если include-ить файлы разметки с тэгом merge, то для них нужно вручную inflate-ить ViewBinding. И до их вьюх не достать через родительский биндинг) А с context receivers можно провернуть следующее:

      context(ParentBinding, MergedIdleBinding, )
      fun bindData(data: Data) {
          parentLoaderView.isVisible = data.isLoading
          mergedTitleTextView.setText(data.title)
      }


  1. master_king
    26.03.2024 11:20
    +1

    Технически context receivers, как и extension-функции, компилируются в статический метод 

    Extension-функции это обычные функции только первый параметр будет параметр типа на которого пишите данную функцию.
    Если объявит Extension-функцию внутри класса то оно будет обычной функцией данного класса (можно будет обращаться к внутренним полям данного класса). Оно будет статической только если объявили его вне класса (но это уже не свойство Extension-функции а все функции обявленные вне класса будут статическими).


    1. maks1596 Автор
      26.03.2024 11:20

      Спасибо за уточнение! Пока писал не вспомнил про то, что extension не обязательно будет вне класса) Пожалуй, уберу "статический" из фразы, чтобы никого не запутать)