Android разработчики обожают DI. Куда ни посмотри, куча статей и выступлений про Dagger 2, Hilt или Koin, но очень мало про Kodein. Даже на Хабре всего пару неплохих статей, но и те от 2018 года. Но с тех пор Kodein бурно развивался, и вышло много мажорных версий, API значительно изменился.

Мы используем Kodein в приложении Drinkit и успели прочувствовать на себе плюсы и минусы этого инструмента. Я расскажу, как пользоваться Kodein, как у него дела со скоупами и многомодульностью.

Всё, чем я хочу поделиться, не уместится в одну статью, поэтому я решил посвятить первую основам АPI. В следующих планирую рассказать о KMM и Compose, целостности графа и тестах на DI и напоследок — о работе со скоупами (на реальном примере).

Итак, погнали.

Принципы Kodein

Я начну чуть-чуть издалека, но не сильно, не переживайте. В чем фишка Kodein, его основная идея? Возьму за основу доклад создателя Kodein Salomon Brys, где он объясняет, какие принципы он закладывал, когда решил создать DI фреймворк. Из этого будет понятно, почему в нём что-то сделано так, а не эдак.

Kodein создавался изначально как DI для Kotlin (в то время для Android все DI были написаны на Java). Отсюда он и получил своё название — KOtlin DEpendency INjection. Поэтому за основы были взяты новые возможности, которые давал Kotlin, в отличие от Java:

  • декларативный DSL,

  • inline функции и reified типы,

  • infix функции.

Декларативный DSL

Kotlin позволяет описывать различные структуры данных в декларативном DSL (domain-specific languages) стиле. Про это неплохо рассказано в разделе Type-safe builders в официальной документации. Это означает, что мы можем семантически построить такую конструкцию, которая опишет наш DI граф. Это позволит задать DI граф в наглядном виде и заодно, как бонус, легко может получить ленивое создание объектов. Ведь код, который мы пишем в фигурных скобочках — это лямбды, а значит, их легко хранить и вызывать позже, лениво.

Декларативный DSL по описанию DI графа в Kodein выглядит примерно вот так:

fun createDI() = DI {
    bind<CustomerRepository>() with provider {
        PhoneRepositoryImpl(
			remoteDataSource = instance(),						
			localDataSource = instance(),
		)
    }

    bind<RemoteCustomerDataSource>() with singleton {
        RemoteCustomerDataSourceImpl(
            endpoint = instance(),
            mapper = instance()
        )
    }
    ...
 }

Inline & reified

Одна из важных и мощных штук в Kotlin — inline функции и reified типы. Наверное, без этого уже никто не воспринимает современную разработку на Kotlin. Если вы не совсем знакомы с этим, посмотрите в официальной документации.

Inline функции с reified типами позволяют компилятору понимать, какой тип generic параметра реально используется.

Поэтому, если мы напишем вот такую inline функцию

public inline fun <reified T: Any> DI.Builder.bindSingleton(
	..., 
	noinline creator: DirectDI.() -> T,
): Unit

то потом мы можем смело описать создание нашего класса вот так:

bindSingleton<SmsCodeRetriever> { GoogleSmsCodeRetriever(instance()) }

В этом случае метод bindSingleton поймёт, что мы хотим взять именно тип SmsCodeRetriever. А функция instance() поймёт, какой конкретно тип она должна вернуть для GoogleSmsCodeRetriever, потому что она тоже inline с reified типом.

Infix

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

Вот такое

bind<SmsCodeRetriever>().with(singleton { GoogleSmsCodeRetriever(instance()) })

превращается в такое:

bind<SmsCodeRetriever>() with singleton { GoogleSmsCodeRetriever(instance()) }

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

Декларативный DSL, inline и reified методы и infix нотация — это не всё, на чём стоит Kodein, но это его основа.

Соединяя всё это вместе, мы можем написать вот такую строчку:

bind<CustomerRepository>() with singleton { CustomerRepositoryImpl() }

и прочитать её буквально как «Свяжи тип CustomerRepository с синглтоном класса CustomerRepositoryImpl». Это означает, что всегда, когда нам потребуется тип CustomerRepository, мы получим одну и ту же реализацию CustomerRepositoryImpl. Получается очень читаемый код на почти человеческом языке.

DI или Service Locator

Я каждый раз замечаю, что в любом разговоре Android-разработчиков про DI фреймворки ровно через 5 минут начинается срач на тему DI vs Service Locator. Поэтому я расставлю точки над i на тему «Kodein — это DI или Service Locator?».

Сперва проговорим, что означают эти понятия.

Service Locator — это паттерн, когда мы запрашиваем и получаем зависимости. Мы сами ответственны за получение зависимостей. Обычно есть некий контейнер, и мы запрашиваем зависимости в этом контейнере.

DI — это паттерн, когда наши классы получают зависимости откуда-то извне и, как правило, через конструктор. Наши классы при этом остаются достаточно «чистыми» и не знают ничего про тот механизм, который предоставляет зависимости.

Вернёмся к Kodein. В официальной документации специально есть такой раздел Injection & Retrieval, где говорится, как стоит воспринимать Kodein. И ответ очень простой: как хотите, так и воспринимайте!

Отмечу, что вместо слов DI и Service Locator в документации используются термины Injection и Retrieval. В этой статье я тоже буду ими пользоваться иногда.

Если кратко, то суть такая:

  • если мы инджектим зависимости, то у нас режим Injection (DI);

  • если мы запрашиваем и получаем зависимости, то у нас режим Retrieval (Service Locator)

Забегая вперёд, скажу, что мы на проекте выбрали и придерживаемся режима Injection (DI), хотя авторы Kodein рекомендуют для приложений использовать Retrieval (Service Locator), а для библиотек — Injection (DI).

JSR-330

JSR-330 — это спецификация Dependency Injection for Java. Если вы не знаете, что это такое, то, скорее всего, вы знаете, просто не знаете название. Его поддерживают многие популярные DI фреймворки (Dagger/Dagger 2, Toothpick, Guice, и др.). Если вы пользуетесь одним из них, то все аннотации типа @Inject или @Named или интерфейс Provider — это и есть тот самый JSR-330.

Хорошая новость в том, что Kodein поддерживает JSR-330. Плохая — это делается через подключение отдельного пакета org.kodein.di:kodein-di-jxinject-jvm, который работает на рефлексии, что сильно повлияет на производительность в рантайме. Рекомендуется использовать JSR-330, только если у нас уже есть большой проект с использованием JSR-330 на Java и мы постепенно мигрируем его на Kotlin и Kodein DI. А если проект на Kotlin и мы решили использовать Kodein, то в JSR-330 особо смысла нет.

Описание графа DI

Итак, перейдём к делу и рассмотрим, какое API предоставляет Kodein, чтобы описать DI граф. Есть 4 основных биндинга:

  1. provider — каждый раз создаёт новый объект

  2. singleton — создает единственный экземпляр

  3. factory — создает объект, которому нужен аргумент

  4. multiton — смесь factory и singleton. Эта фабрика создает объект для каждого аргумента будет единственный экземпляр.

val di = DI {
  // 1. Provider
  bind<String>() with provider { "Hello world" }
  
  // 2. Singleton
  bind<Repository>() with singleton { 
    RepositoryImpl(name = instance()) 
  }
  
  // 3. Factory
  bind<OrderDetailsService>() with factory { orderId -> 
    OrderDetailsServiceImpl(customerId orderId instance()) 
  }
  
  // 4. Multiton
  bind<OrderDetailsService>() with multiton { orderId -> 
    OrderDetailsServiceImpl(
      orderId = orderId, 
      repository = instance(),
    ) 
  }
}

Теги

К каждому биндингу можно добавить своей тег.

bind<String>() with provider { "Hello world" }
bind<String>(tag = "name") with provider { "My name" }

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

Synced Singleton и Multiton

По умолчанию singleton и multiton создают единственные экземпляры thread safe. Т.е. для JVM реализаций используется синхронизация между потоками. Но есть возможность это отключить и использовать не thread safe создание зависимостей.

bind<Repository>() with singleton(sync = false) { 
  RepositoryImpl(name = instance()) 
}

Здесь надо быть внимательным. Если мы используем подход Retrieval, то часто можем попадать в случаи, когда фабрика создаёт зависимость в разных потоках. Например, в IO диспатчере мы первый раз использовали поле repository, а оно было объявлено как:

private val repository by instance() 

Значит, создание объекта может быть в разных потоках.

Если мы уверены, что создание зависимостей происходит в одном и том же потоке (например, в главном), то можно использовать sync = false, чтобы оптимизировать перфоманс нашего кода.

Контейнер

Весь граф зависимостей, который мы описываем, хранится в такой структуре:

public typealias BindingsMap = Map<DI.Key<*, *, *>, List<DIDefinition<*, *, *>>>

Это ассоциативный массив, где есть составной ключ и список из DIDefinition. DIDefinition — объект содержащий информацию о фабрике, которая создаёт зависимость (provider, singleton, factory, multiton).

Ключ состоит из 4 полей:

  • тип контекста. Контекст — это класс, который становится доступен нашей фабрике. Об этом мы поговорим позже, когда будем разбирать скоупы;

  • аргумент. Об этом мы говорили, когда речь шла о factory и multiton фабриках;

  • тип типа. Это просто наш тип биндинга;

  • тег. Об этом мы тоже выше говорили.

public data class Key<in C : Any, in A, out T: Any>(
            val contextType: TypeToken<in C>,
            val argType: TypeToken<in A>,
            val type: TypeToken<out T>,
            val tag: Any?
    ) 

Таким образом, ключ собирается из четырёх полей. А значение — это список DIDefinition (фабрик). Здесь хранится именно весь список, чтобы потом показать ошибку, что такие-то биндинги зарегистрированы для этого ключа.

Как получить зависимость

Получить зависимость очень просто. Если в нужен инстанс какого-то объекта, то нужно просто написать слово instance(), и всё.

Если мы инжектим в конструктор, то выглядеть это может примерно так:

bind<CustomerRepository>() with singleton {
    CustomerRepositoryImpl(
        authRepository = instance(),
        remoteDataSource = instance(tag = REMOTE),
        localDataSource = instance(tag = LOCAL),
        customerMapper = instance(tag = country.formatter),
    )
  }

В instance() можно передать tag или arg. tag — это тег, который мы указывали при описании графа DI, а arg — это аргумент, если наша фабрика factory или multiton.

Если все instance() без дополнительных параметров, то будет вот такой код:

bind<CustomerRepository>() with singleton {
    CustomerRepositoryImpl(
        customerEndpoint = instance(),
        countryDataSource = instance(),
        tokensRepository = instance(),
        customerMapper = instance()
    )
  }

Можно переписать вот так:

bind<CustomerRepository>() with singleton{ new(::CustomerRepositoryImpl) }

Или, если добавить ещё одну обёрточку, даже вот так:

// Обёрточка. Придётся такую написать для разного количества аргументов
inline fun <C : Any, reified R : Any,
    reified T1, reified T2, reified T3, reified T4> DI.BindBuilder<C>.providerOf(
  crossinline constructor: (T1, T2, T3, T4) -> R,
): Provider<C, R> = provider { new(constructor) }

// в итоге можно так писать
bind<CustomerRepository>() with providerOf(::CustomerRepositoryImpl)

Если тип биндинга и реализация — один и тот же тип, то можно и того проще:

bindProviderOf(::CustomerRepositoryImpl)

Если мы используем Retrieval (Service Locator) подход, то получение зависимостей может выглядеть примерно вот так:

internal class PaymentDataSourceImpl(di: DIAware) : 
  MenuPaymentDataSource, DIAware by di {

    private val paymentStatusService by instance<PaymentStatusService>()
    private val paymentService by instance<PaymentDataSource>()
    ...

Здесь надо отметить 2 момента:

  1. Всё лениво. И это, пожалуй, главное объективное преимущество этого подхода. С другой стороны, оно же может и стрельнуть потом нам в ногу (расскажу в другой статье, про тестирование целостности DI графа).

  2. Появился интерфейс DIAware. Он и позволяет писать конструкции типа by instance<…>().

Остановимся на DIAware. DIAware мы помечаем класс, который знает про DI. Это простой интерфейс, который выглядит вот так:

public interface DIAware {
    public val di: DI
    public val diContext: DIContext<*> get() = AnyDIContext
    public val diTrigger: DITrigger? get() = null
}

Как видно, главное — это реализовать public val di: DI.

Как правило, мы хотим сделать базовый DI в приложении:

class MyApp : Application(), DIAware {

    override val di = DI {
        import(androidXModule(this@MyApp))
        import(analyticsModule())
        import(paymentModule())
        ...
    }

Чтобы создать DI, мы пишем DI { … }. И потом добавляем модули, которые предоставляют зависимости. Чтобы создать модуль, пишем функцию:

fun paymentModule() = DI.Module("paymentModule") {
    bind<PaymentEndpoint>() with singleton { ... }
    bind<PaymentMapper>() with singleton { ... }
    bind<PaymentRepository>() with singleton { ... }
    ... 
}

Контекст и скоупы

Куда ж статья про DI и без скоупов? Скоупы — это суперудобный инструмент, если нам нужны скоупы.

Обычно под скоупами понимают сущность, которая имеет время начала и время конца жизни и работает в определённом контексте.

Скоуп в Kodein выглядит вот так:

public interface Scope<in C> {
    public fun getRegistry(context: C): ScopeRegistry
}

C — это тип контекста, он может быть любой. ScopeRegistry — это сущность, которая по ключу может создавать или возвращать зависимости внутри скоупа. Говоря простым языком, здесь это считается как обёртка либо над ассоциативным массивом или просто единственной парой «ключ-значение». Для этого есть 2 стандартные реализации StandardScopeRegistry и SingleItemScopeRegistry. Скорее всего, их будет достаточно.

Самый популярный полуготовый скоуп — это WeakContextScope. С его помощью можно создавать стандартный скоуп, который будет держать контексты и зависимости, пока сам контекст будет жить. Т.е. в этом случае мы сами управляем жизненным циклом контекста. Я считаю, это довольно удобный способ. Давайте поясню на примере.

Мы можем создать вот такой скоуп:

val customerScope: Scope<CustomerContext> = WeakContextScope.of()

а потом запрашивать зависимости таким образом:

bind<PortfolioRepository>() with scoped(customerScope).singleton {
       PortfolioRepositoryImpl(
           userNameProvider = { context.profileName }
       )
   }

И пока есть объект customerScope — мы получим зависимость в скоупе. Когда мы его удалим из своих данных, и когда его не станет (GC соберёт его), то больше не будет такого ScopeRegistry в скоупе.

Для любителей скоупов, которые переживают поворот экрана, тоже есть готовое решение — ActivityRetainedScope.

Его использовать очень просто:

// Описываем зависимость в скоупе ActivityRetainedScope
bind<ProductPresenter>() with scoped(ActivityRetainedScope).provider {
    ProductPresenterImpl(...)
}

// Магически получаем зависимость во фрагменте, 
// которая переживаем смену конфигурации
class ProductFragment : ... {
    ...
    private val viewPresenter by instance<ProductPresenter>()

В Kodein также есть такие мощные инструменты, как Context translators и Context finder. Именно с их помощью можно сделать так, как показано в коде выше: находясь во Fragment, получить контекст Activity, не указывая это явно. Про это всё можно более подробнее прочитать в официальной документации про контексты и скоупы.

Многомодульность

Т.к. эта статья про Kodein с упором на Android-разработку, поговорим сразу о том, как работать с многомодульностью.

В Kodein есть 2 основные сущности:

  • DI — можем под этим понимать граф зависимостей как аналог компонента в Dagger.

  • Module — это модуль, который описывает создание зависимостей. DI может включать в себя несколько модулей.

Также мы можем создавать дочерние DI. Это можно сравнить по аналогии с SubComponent в Dagger. С помощью методов subDI()/extend()/addExtend() можно создать дочерние DI.

В Android-приложении удобно выносить отдельные модули или даже дочерние DI в gradle модули. Это может выглядеть вот так:

У нас есть базовый CommonDI, у него есть зависимости, предоставляемые модулями module1 и module2. Это всё лежит в Gradle модуле. И у нас есть другие «фичёвые» Gradle модули, каждый из который имеет свой FeatureDI, созданный как дочерний от CommonDI. В этом случае FeatureDI1 и FeatureDI2 будут иметь доступ ко всем зависимостям CommonDI. По умолчанию хранимые биндинги (такие как singleton) не копируются, и у дочернего DI будет доступ к тому же объекту, что и у родителя. Но при желании это можно переопределить через параметр copy: Copy, установив ему значение Copy.All.

Заключение

Мы прошлись по всем основам API Kodein:

  • в чём суть, зачем он был вообще создан;

  • DI ли это или Service Locator (напомню, что ответ — как хотите);

  • для любителей Dagger 2 подсветили, что JSR-330 поддерживается;

  • разобрались, как описывать граф, какие есть основные биндинги;

  • посмотрели, как работает контейнер внутри, из каких элементов состоит;

  • поняли, как получать зависимости;

  • вздохнули с облегчением, когда узнали, что есть скоупы;

  • прошлись по API и узнали, как создавать subDI. Это может быть полезно для разнесения DI в разные Gradle модули.

На мой взгляд, API у Kodein очень гибкий и многофукнциональный, можно делать разные штуки на любой вкус. Более подробно всё можно узнать в официальной документации, она довольно классная и понятная.

«Так удобен ли Kodein?» — спросите вы. Ответ субъективный, я предложу каждому решить самому. По своему опыту могу сказать, что это точно production ready инструмент, который выполняет свою задачу. А плюсы и минусы я расскажу в будущих статьях, где опишу, с какими трудностями мы столкнулись.

Если вам лень читать такие большие статьи, как эта, подписывайтесь на мой ТГ канал Мобильное чтиво. Там я делюсь своими мыслями, в основном про Android-разработку, но не только.

А новостями мобильной разработки в Dodo в коротком формате мы делимся в Dodo Mobile.

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


  1. Mort
    18.08.2023 09:40
    +2

    Спасибо за статью