Привет, меня зовут Иван Курак, я Android-разработчик приложения Ozon Job. В этой статье мы реализуем свой механизм, на котором построен Koin. Тем самым мы пройдём путь, который проходили его авторы при решении проблемы построения собственного DI.

Эта статья будет полезна тем, кто использует Koin в своих приложениях, и тем, кто хочет сделать свой простой DI. Ведь только понимание того, как что-то устроено, поможет создавать новые решения, учитывая при этом опыт прошлых разработок, и модифицировать существующие решения без трагических последствий.

Дополнительная (но от того не менее важная) цель статьи — показать, что базовый механизм, на котором построен Koin, не такой уж и страшный :)

Оглавление

Мы напишем DI для такой группы классов:

// Интерфейс для зависимости
interface Engine {
   fun start()
}


// Реализация зависимости
class ElectricEngine : Engine {
   override fun start() {
       println("Запуск электрического двигателя")
   }
}


// Реализация зависимости
class GasolineEngine : Engine {
   override fun start() {
       println("Запуск бензинового двигателя")
   }
}


// Класс, использующий зависимость
class Car(private val engine: Engine) {
   fun start() {
       engine.start()
   }
}

Всё довольно просто: у нас есть Car, и ему нужен какой-то тип двигателя.

Кратко о Koin и ServiceLocator

Что такое Koin?

Koin — это фреймворк для инъекции зависимостей, и мы можем условно сравнить его со свиньёй-копилкой, наполненной разными монетами. Когда у вас возникает потребность в деньгах, вы можете обратиться к ней, и она (недовольно хрюкнув) будет готова предоставить вам необходимую сумму. 

Аналогично, Koin «собирает» зависимости внутри себя и готов предоставить их. Когда вы запрашиваете какой-то объект, Koin выдаёт не только его, но и все связанные с ним зависимости.

Начнём с того, что Koin это реализация шаблона проектирования Service Locator. В нём центральное место отведено хранению всех зависимостей приложения в одном месте. Вместо того, чтобы каждый компонент приложения напрямую создавал свои зависимости, для получения зависимостей мы обращаемся за ними к объекту ServiceLocator. 

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

fun main() {
   ServiceLocator.save(Engine::class) { ElectricEngine() }


   //Проброс зависимости через Service Locator
   Car(engine = ServiceLocator.get(Engine::class)).start()
}


object ServiceLocator{
   private val instances = hashMapOf<KClass<out Any>, () -> Any>()


   fun save(
       сlazz: KClass<out Any>,
       definition: () -> Any
   ) {
       instances[сlazz] = definition
   }


   fun <T: Any> get(сlazz: KClass<T>,): T {
       val definition = instances[klazz] ?: error("Не найден объект")
       @Suppress("UNCHECKED_CAST")
       return definition.invoke() as T
   }
}

Что тут происходит? Местом хранения всех данных является объект ServiceLocator, под собой (в нашем примере) он имеет Map<KClass, () -> Any> и методы, чтобы сложить значения в Map.

Как различать одинаковые объекты?

Что если нам понадобится пробрасывать экземпляры GasolineEngine и ElectricEngine, причём выбор зависит от ситуации?

fun main() {
   <...>
   // Хотим ElectricEngine
   Car(engine = ServiceLocator.get(Engine::class)).start()
 // Хотим GasolineEngine
   Car(engine = ServiceLocator.get(Engine::class)).start()
}

Возможно, кто-то предложит прокидывать реализации напрямую:

Car(engine = ServiceLocator.get(ElectricEngine::class)).start()

И это будет работать, но обычно нам неизвестен наш класс реализации (допустим, мы пытаемся создать Engine в другом модуле). Если делать этот класс видимым в местах, где о нём знать не должны, ты мы будем зависеть от реализации. К тому же, если класс поменяет имя или мы захотим везде и разом начать использовать другую реализацию, то будем вынуждены пробежаться по всему коду и править всё на ходу.

Поэтому мы оставим доступ к классу по полю Engine::class, но научим наш ServiceLocator различать объекты. Для этого модернизируем наши методы сохранения и получения.

object ServiceLocator{
  
   fun save(
       ++ qualifier: String? = null, - добавилось
       сlazz: KClass<out Any>,
       definition: () -> Any
   ) 

   fun <T: Any> get(
       ++ qualifier: String? = null, - добавилось
       сlazz: KClass<T>
   ): T 
}

Если мы хотим отличать две одинаковые реализации друг от друга, то можем передать строку, которая будет своего рода уникальным ключом для одинаковых классов, но различных экземпляров. А если нам данная логика не нужна, то просто оставим null.

И теперь возникает вопрос: как хранить экземпляры в ServiceLocator? Первая мысль — создать data-класс, который внутри себя будет держать это значение.

Что-то такое, например:

data class Key<T: Any>(
   val qualifier: String?,
   val clazz: KClass<T>
)


object ServiceLocator{
   private val instances = hashMapOf<Key<out Any>, () -> Any>()
}

В чём проблема такого решения? В оптимизации. Безусловно, это будет работать. Но у нас будет много классов Key, каждый из которых содержит экземпляры классов String и KClass. А ведь они нужны просто для того, чтобы получить необходимый экземпляр из ServiceLocator.instances. Кажется, здесь есть пространство для оптимизации…

Заменим наш Key -> String и получим что-то типа такого:

object ServiceLocator{
   —- private val instances = hashMapOf<Key<out Any>, () -> Any>() - было
   ++ private val instances = hashMapOf<String, () -> Any>() - стало
}

Напишем эту функцию:

fun indexKey(
   qualifier: String?,
   clazz: KClass<*>,
): String {
   return "${clazz.java.name}:${qualifier.orEmpty()}"
}

Основное преимущество этого решения в том, что мы выделяем память только на строки, вместо хранения нескольких обьектов Key (который содержит String и KClass) При таком способе создания строки, мы не будет каждый раз создавать новую строку для один и тех же значений, а будем ее брать со StringPool. В конечном итоге это решение эквивалентно решение с Key, ведь мы будем получать для одних и тех же qualifier и KClass одну и ту же строку. Добавим это решение к ServiceLocator.

object ServiceLocator{
   private val instances = hashMapOf<String, () -> Any>()

   fun save(
       qualifier: String? = null,
       clazz: KClass<out Any>,
       definition: () -> Any
   ) {
       val indexKey = indexKey(qualifier, clazz) - расчет ключа
       instances[indexKey] = definition
   }

   fun <T: Any> get(
       qualifier: String? = null,
       clazz: KClass<T>
   ): T {
       val indexKey = indexKey(qualifier, clazz) - расчет ключа
       val factory = instances[indexKey] ?: error("Не найден”)
	  ...
   }


}

И, запустив наше решение, мы получим работающий код:

fun main() {
   ServiceLocator.save(
       qualifier = "Electric",
       clazz = Engine::class,
       factory = { ElectricEngine() }
   )
   ServiceLocator.save(
       qualifier = "Gasoline",
       clazz = Engine::class,
       factory = { GasolineEngine() }
   )


   Car(engine = ServiceLocator
       .get(
           qualifier = "Electric",
           clazz = Engine::class))
       .start()


   Car(engine = ServiceLocator
       .get(
           qualifier = "Gasoline",
           clazz = Engine::class))
       .start()
}

Создание концепции модулей и удаление объектов из Map

Теперь возникает вопрос о читаемости и независимости мест, откуда мы можем наполнять наш ServiceLocator. Например, у нас есть сетевые зависимости и зависимости для локальных сторов. Мы не хотим в одном и том же месте перечислять их все, а хотим сделать наш код понятнее для тех, кто будет смотреть его потом. И здесь, вроде бы, проблем нет: ничто не мешает в двух разных файлах (или модулях) создать такие классы:

//Один файл
class CacheDep{
  
   fun loadDependencies(){
      ServiceLocator.save(clazz = Cache) { CacheImpl() }
   }
}


//Второй файл
class RemoveDep{

   fun loadDependencies(){
       ServiceLocator.save(clazz = Remote) { RemoteImpl() }
   }
}

Но с подобным  решением сразу появляется два вопроса: 

  1. Что будет, если мы попросим зависимость Remote до того, как вызвали RemoveDep.loadDependencies()

  2. Что будет, если наш RemoteImpl() внутри себя будет делать инъекцию какой-нибудь сетевой зависимости (например, API от Retrofit или HttpClient от Ktor), чтобы делать сетевые запросы, но в сам ServiceLocator никто не клал эти сетевые зависимости?

Ответ простой — падение с ошибкой. Чтобы избавиться от такой проблемы, необходимо правило, которое позволит нам загрузить все наши зависимости в ServiceLocator до того, как мы попытаемся получить какую-нибудь зависимость оттуда. 

Сделать это можно при запуске приложения. Но мы не хотим чтобы какие-то классы, помеченные как internal (видимые на уровне модуля) или private (видимые на уровне файла), были видны там, где не должны. Поэтому мы создадим абстракцию, которая внутри себя будет вызывать ServiceLocator.save для каждой переменной, которую мы хотим сложить в ServiceLocator. Назовём мы этот класс Module, и добавим соответствующий метод в ServiceLocator.

class Module { ... }


object ServiceLocator{
   <...> 
   fun loadModules(list: List<Module>){
      
   }
}

Остаётся вопрос, чем будет являться Module? Что же давайте думать. Что если сделать Module отдельным интерфейсом, а его реализации и будут ответственны за наполнение SerivceLocator?

object ServiceLocator{
  
   <...>
   fun loadModules(list: List<Module>){
       list.forEach {module: Module -> 
           module.loadDependencies()
       }
   }
}


interface Module {
  
   fun loadDependencies()
}


class EngineModule: Module {
   override fun loadDependencies() {
       ServiceLocator.save(clazz = Engine::class) { ElectricEngine() }
   }
}

Это будет работать, но наш модуль берёт на себя лишнюю ответственность. При такой реализации он не просто собирает зависимости, но также отвечает за то, как их положить в ServiceLocator. Ничем не лучше прошлого примера: модули всё так же напрямую работают с ServiceLocator, разве что мы создали единое место, где эти модули будут вызваны.


В идеале хотелось бы скрыть возможность явно наполнять наш ServiceLocator. Ведь он является сердцем нашего механизма по работе с зависимостями и работа с ServiceLocator напрямую с отсутствием строгого правила о месте его наполнения может выстрелить нам в ногу. И как гласит закон Мерфи:

«Если что-то может пойти не так, оно пойдёт не так в самое неподходящее время».

Сделаем наш Module классом и создадим промежуточный Map внутри модулей. Затем в методе loadModules просто переберём данные и запишем значения из них в Map, внутрь ServiceLocator.

object ServiceLocator{
   <...>  

   fun loadModules(list: List<Module>){
       list.forEach { module: Module ->
           module.mappings.forEach { (indexKey, factory) ->
               instances[indexKey] = factory
           }
       }
   }
}


class Module {

   //Так промежуточная Map называется в Koin
   val mappings = hashMapOf<String, () -> Any>()

   fun save(
       qualifier: String? = null,
       clazz: KClass<out Any>,
       definition: () -> Any
   ) {
       val indexKey = indexKey(qualifier, clazz)
       mappings[indexKey] = definition
   }

}


val engineModule = Module()
   .apply {
       save(
           clazz = Engine::class,
           definition = { ElectricEngine() }
       )
   }

Мы убрали реализацию метода save из ServiceLocator и реализовали её в классе Module.Теперь наши модули действительно являются просто способом наполнения ServiceLocator, и ничем более. А ServiceLocator занимается только загрузкой и получением значений по ключу.

Способ, которым мы создали наш engineModule, наталкивает на мысль, что можно сделать момент инициализации модуля более читаемым. Для этого напишем простой DSL.

fun myModule(block: Module.() -> Unit): Module {
   return Module().apply(block)
}

val engineModule = myModule {
   save(clazz = Engine::class) { ElectricEngine() }
}

Стало более читаемо. В такой реализации есть один мини-бонус: внутри нашего Map (Module.mapping) будут лежать все ключи, по которым мы сохраняли значения в ServiceLocator. Достав эти ключи, мы, не напрягаясь, можем добавить функцию удаления из ServiceLocator всех лямбд, что были вставлены в него из определённого модуля.

fun unLoadModules(list: List<Module>){
   list.forEach { module: Module ->
       module.mappings.keys.forEach { key ->
           instances.remove(key)
       }
   }
}

И никаких проблем!

Стоит сказать, что Koin позволяет динамически добавлять и удалять какие-то модули, и тем самым идёт в обход идеи сложить всё сразу. Но при этом на вас ложится вся ответственность за то, чтобы та или иная зависимость была найдена.

Возможность создания Singleton

Сейчас наш ServiceLocator умеет только собирать лямбда-функции, которые могут нам создать экземпляр. Но мы также можем захотеть иметь один экземпляр на протяжении жизни приложения. Назовём его Singleton. Как это сделать? Первая идея  — иметь отдельный Map для синглтонов. Что-то вроде этого:

object ServiceLocator {
  <...>
   private val instances = hashMapOf<String, () -> Any>()
   private val singleInstances = hashMapOf<String, Any>()
  <...>
}

Но как наполнять этот Map? И как управлять сразу двумя Map? Например, если в двух Mapбудет одинаковый ключ, то какую Map использовать? У нас появляется несколько источников, за которыми надо следить. И неизвестно, а понадобится ли нам третий Map? А ведь нам надо просто получать единый экземпляр для определённой реализации, и мы пойдем путём создания класса Provider, который заменит наши лямбды в ServiceLocator.

Сейчас объясню. Создадим этот класс и чуть-чуть изменим ServiceLocator:

object ServiceLocator {
   <...>
   private val instances = hashMapOf<String, Provider>()
   <...>
}

//В Koin данный класс носит имя InstanceFactory
abstract class Provider(
   private val definition: () -> Any
) {
   protected fun create(): Any {
       return definition.invoke()
   }
   abstract fun get(): Any
}

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

Напишем реализацию для Singleton-зависимостей:

class SingletonProvider(definition: () -> Any): Provider(factory){
   private var instance: Any? = null
  
   override fun get(): Any {
       synchronized(this){
           if (instance == null) {
               instance = create()
           }
       }
       return instance!!
   }

}

При обращении к методу get мы проверяем, создан ли экземпляр. И либо создаём и отдаём, либо просто отдаём.

И реализуем такой же для Factory-зависимостей:

class FactoryProvider(definition: () -> Any): Provider(factory){
   override fun get(): Any = create()
}

Да, что может быть проще простого пересоздания экземпляра при каждом обращении.

Теперь метод get внутри ServiceLocator будет выглядеть так же просто, как и до этого. Один источник хранения всех возможных переменных, и при обращении к нему через get мы даже не имеем понятия, как именно он был создан.



fun <T: Any> get(
   qualifier: String? = null,
   clazz: KClass<T>
): T {
   val indexKey = indexKey(qualifier, clazz)
   @Suppress("UNCHECKED_CAST")
   return instances[indexKey]?.get() as? T
			?: error("Не найдена реализация")
}

В классе Module заменим на save методы для создания Singleton и Factory:

val mappings = hashMapOf<String, Provider>()

fun factory(
   qualifier: String? = null,
   clazz: KClass<out Any>,
   definition: () -> Any
) {
   val indexKey = indexKey(qualifier, clazz)
   mappings[indexKey] = FactoryProvider(definition)
}

fun single(
   qualifier: String? = null,
   clazz: KClass<out Any>,
   definition: () -> Any
) {
   val indexKey = indexKey(qualifier, clazz)
   mappings[indexKey] = SingletonProvider(definition)
}

Теперь мы можем использовать метод factory для создания зависимости, который будет пересоздавать значение при каждой новой попытке создать экземпляр. А если нам нужен singleton на всё время жизни приложения, то используем метод single.

Теперь перепишем это всё на inline-функции, чтобы стало ещё удобнее. 

inline fun <reified T: Any> factory(
   qualifier: String? = null,
   noinline definition: () -> Any
) {
   val indexKey = indexKey(qualifier, T::class)
   mappings[indexKey] = FactoryProvider(definition)
}


inline fun <reified T: Any> singleton(
   qualifier: String? = null,
   noinline definition: () -> Any
) {
   val indexKey = indexKey(qualifier, T::class)
   mappings[indexKey] = SingletonProvider(definition)
}
Лирическое отступление про inline-функции

Чтобы понять, что сейчас произошло, проведу краткий ликбез по inline-функциям. Начнём вот с такого примера:

fun main() {
   addFour(16) { result ->
       println(result + 1)
   }
}


fun addFour(value: Int, callback: (Int) -> Unit){
   callback(value + 4)
}

Что здесь происходит? 

  • Мы вызвали функцию addFour, передав ей какое-то значение (16).

  • Внутри функции мы прибавили к значению число 4. 

И вернули этот результат в callback назад в функцию, из которой мы изначально вызвали метод addFour.

Чтобы стало ещё понятнее, вот так этот код выглядит на Java (чуть изменён для понимания, но без потери смысла):

static class Callback{

   public void invoke(int value) {
       System.out.println(value);
   }
}

public static final void main() {
   addFour(16, new Callback());
}

public static final void addFour(int value, Callback callback) {
   callback.invoke(value + 4);
}

Вот что мы получим при преобразовании из Kotlin в Java.

А теперь чуть изменим наш код, пометив метод addFour модификатором inline.

fun main() {
   addFour(16) { result ->
       println(result)
   }
}


inline fun addFour(value: Int, callback: (Int) -> Unit){
   callback(value + 4)
}

Вроде бы ничего не поменялось. Посмотрим, как будет выглядеть наш Java-код (чуть изменён для понимания, но без потери смысла):

public static final void main() {
   int value = 16;
   int result = value + 4;
   System.out.println(result);
}

Как видите, мы избавились от лишнего вызова метода addFour. Весь код был встроен вместо вызова нашего метода.

А теперь разберёмся в noInline. Для этого опять немного модернизируем наш код.

var timeVarious: ((Int) -> Unit)? = null


fun main() {
   addFour(
       value = 16,
       callback = { result -> println(result) },
       secondCallback = { result -> println("second $result") }
   )
}


inline fun addFour(
   value: Int,
   callback: (Int) -> Unit,
   noinline secondCallback: (Int) -> Unit
){
   callback(value + 4)
   timeVarious = secondCallback
   secondCallback(value + 4)
}

Мы добавили ещё один метод, но пометили его как noinline. Также мы добавили переменную timeVarious, в которую сохраняем нашу noinline-лямбду. Зачем? Расскажу чуть позже.
Как сейчас будет выглядеть Java-код (чуть изменён для понимания, но без потери смысла):

static class SecondCallback{

   public void invoke(int value) {
       System.out.println(“second” + value);
   }
}

static SecondCallback timeVarious;

public static final void main() {
   int value = 16;
   SecondCallback secondCallback = new SecondCallback();
   int result = value + 4;
   System.out.println(result);
   secondCallback.invoke(result);
}

Весь callback, который не был помечен как noinline, полностью встроился вместо вызова функции addFour. А secondCallback создал специальный класс, как в примере без inline-функции.

Inline-функция позволяет весь код внутри встроить вместо вызова. А noinline позволяет отменить встраивание определённых callback. Зачем это нужно?
Специально для этой цели и была создана переменная timeVarious. Благодаря тому, что noinline-функция не встроилась, мы можем с ней обращаться как с отдельным объектом и даже куда-то сохранить. А с первым callback такое действие не дозволено.

inline fun addFour(
   value: Int,
   callback: (Int) -> Unit,
   noinline secondCallback: (Int) -> Unit
){
   timeVarious = callback - // ошибка
   callback(value + 4)
   timeVarious = secondCallback
   secondCallback(value + 4)
}

Теперь обсудим ещё одну фишку inline-функций, а именно — reified. Для начала создадим вот такую переменную:

val cacheName = mapOf<KClass<*>, String>(
   Int::class to "Int"
)

Просто по KClass будем получать строку, которая является именем класса. Как получить такую строку из Map? Довольно просто:

fun main() {
   val name = cacheName[Int::class]
}

Подобный синтаксис выглядит не очень привлекательно. Что же, попробуем вынести в отдельную функцию:

fun main() {
   val name = superGet<Int>()
}


fun <T: Any> superGet(): String? {
   return cacheName[T::class] // - ошибка
}

Но мы видим проблему: нашей функции просто негде взять экземпляр KClass во время выполнения кода ведь при переходе из функции в функцию наши generic типы стираются. На помощь может прийти inline-функция, ведь в месте вызова (в методе main) мы точно знаем, что хотим получить Int::class. Сделаем нашу функцию inline:

inline fun <T: Any> superGet(): String? {
   return cacheName[T::class] // - ошибка
}

Но это всё равно приводит нас к ошибке. Несмотря на то, что функция встроилась, информация о конкретном типе всё равно стерлась в момент вставки, исходя из расчёта, что вам этот тип внутри функции попросту не нужен. Поэтому существует ключевое слово reified, которое сообщает компилятору, что необходимо сохранить тип класса и после встраивания. При добавлении одного ключевого слова:

inline fun <reified T: Any> superGet(): String? {
   return cacheName[T::class]
}

мы получим после компиляции вот такой код:

public static final void main() {
  String name = (String)getCacheName().get(
     Reflection.getOrCreateKotlinClass(Integer.class) 
  );
}

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

val engineModule = myModule {
   factory { ElectricEngine() } // ошибка
}

Проблема в том, что компилятору не хватает информации о типе. И он требует явно прописать это руками в <>. Это правильная подсказка, ведь сейчас у нас нет строгого правила, что если мы хотим создавать экземпляр ElectricEngine, то нам нужно именно попросить передать какой-то экземпляр типа Engine или сам ElectricEngine.

Объясню на примере. Наша текущая реализация позволяет сделать так:

val engineModule = myModule {
   factory<Car> { ElectricEngine() }
}

И при попытке получить Car мы получим ошибку, ведь ElectricEngine не является ни наследником, ни реализацией класса Car. Что же, давайте это исправим. Пример будет для метода factory, но для single всё работает аналогично:

object ServiceLocator {
   <....>
   private val instances = hashMapOf<String, Provider<*>>()
   <....>
}


class Module {


   //Так промежуточная Map называется в Koin
   val mappings = hashMapOf<String, Provider<*>>()


   inline fun <reified T> factory(
       qualifier: String? = null,
       —- noinline definition: () -> Any - было
       ++ noinline definition: () -> T - стало
   ) {
       val indexKey = indexKey(qualifier, T::class)
       mappings[indexKey] = FactoryProvider(definition)
   }
   <.....>
}


class FactoryProvider<T>(
     —- factory: () -> Any - было
     ++ factory: () -> T - стало
): Provider<T>(factory){
   override fun get() = create()
}


//В Koin данный класс носит имя InstanceFactory
abstract class Provider<T>(
   —- private val definition: () -> Any - было
   ++ private val definition: () -> T - стало
) {


   //Также поменялись возвращаемые типы
   protected fun create(): T {
       return definition.invoke()
   }
   abstract fun get(): T
}

Использовать везде generic у нас не получится. Мы вводим строгое правило, какими классами и лямбда-функциями мы можем наполнять наш ServiceLocator. Теперь пример просто не скомпилируется, и студия нам это подскажет:

val engineModule = myModule {
   factory<Car> { ElectricEngine() } // ошибка
}

Попробуем теперь создать экземпляр класса Car:

val carModule = myModule {
   single {
       Car( /* Нужен параметр*/ )
    }
}

Мы видим явную проблему: для создания Car нужен экземпляр Engine. А решается это просто:

single {
   Car(ServiceLocator.get(clazz = Engine::class))
}

Здесь мы опять руками напрямую общаемся с ServiceLocator, а хотелось бы что-то простое.

single {
   Car(engine = get())
}

Но есть нюанс, о котором стоит сообщить. Дело в том в самом Koin: при вызове метода get мы ищем ближайший Koin scope (имеется в виду область, в рамках которой можно получить какие-то зависимости, но при выходе из неё или удалении этой области все ссылки на зависимости удаляются). И если не удалось найти ближайший scope, то зависимости ищутся в rootScope (именно в него по умолчанию складываются наши переменные, созданные через single и factory). Сам scope служит дополнительным параметром для создания ключа, по которому ищутся значения в ServiceLocator.

Но в нашем примере мы не будет работать с такими фичами как scope. Вместо этого мы будет брать зависимости напрямую у ServiceLocator, но реализуем работу с ним так же же красиво, как и со scope. То есть получим похожий способ поиска зависимостей при наполнении ServiceLocator.

Начнём с того, что, как и в примере с созданием методов factory и single, сделаем наш метод get лямбда-функцией.

object ServiceLocator {
   private val instances = hashMapOf<String, Provider<*>>()

   inline fun <reified T> get(
       qualifier: String? = null
   ): T {
       val indexKey = indexKey(qualifier, T::class)
       return instances[indexKey]?.get() as? T
				?: error("Не найдена реализация")
   }

Студия сообщает об ошибке при обращении к переменной instances, поскольку она private. Код из лямбда-функции после встраивания в место вызова не сможет обращаться к чужим приватным переменным, поэтому пойдём на такую хитрость:

object ServiceLocator {
   private val _instances = hashMapOf<String, Provider<*>>()
   val instances: Map<String, Provider<*>> = _instances
}

Проблема решена наполовину, теперь мы можем создать наш экземпляр.

single {
   Car(ServiceLocator.get())
}

Но ещё не идеально. Чтобы решить оставшуюся половину проблемы, мы пойдём в наш лямбда-параметр definition и немного его модернизируем.

inline fun <reified T> factory(
   qualifier: String? = null,
   —- noinline definition: () -> T — было
   ++ noinline definition: ServiceLocator.() -> T - стало
) {
   val indexKey = indexKey(qualifier, T::class)
   mappings[indexKey] = FactoryProvider(definition)
}

Также нужно во всех местах, где мы работали с нашей лямбдой, сделать замену. Хм… не слишком удобно при каждом изменении везде переписывать эту функцию. Поэтому создадим typealias (то есть ёмкое слово, которое при компиляции, подставит в места, где используется всё, что идёт после =).

typealias Definition<T> = ServiceLocator.() -> T

И заменим везде, чтобы было однородно:

inline fun <reified T> factory(
   qualifier: String? = null,
—- noinline definition: ServiceLocator.() -> T - было 
++ noinline definition: Definition<T> - стало
) {
   val indexKey = indexKey(qualifier, T::class)
   mappings[indexKey] = FactoryProvider(definition)
}


//////


class FactoryProvider<T>(
—- definition: ServiceLocator.() -> T - было
++ definition: Definition<T> - стало
): Provider<T>(definition){
   override fun get() = create()
}

Что же, теперь осталось в месте, где мы вызываем лямбду, прокинуть ServiceLocator, а именно в метод create класса Provider<T>.

//В Koin данный класс носит имя InstanceFactory
abstract class Provider<T>(
   private val definition: Definition<T>
) {
   protected fun create(): T {
      // имеет вид ServiceLocator.() -> T, и проброс нашего ServiceLocator как первого параметра здесь — это хитрость чтобы работать с таким лямбдами
       return definition.invoke(ServiceLocator)
   }
}

И мы добились того, чего хотели.

singleton {
   Car(get())
}

Как бонус, давайте реализуем ещё метод inject:

inline fun <reified T> inject(
   qualifier: String? = null
): Lazy<T> = lazy {
   ServiceLocator.get(qualifier)
}
Немного про lazy

Короткий пример про то, что такое lazy, если вдруг есть сомнения, как это работает. Будет считать вот такой код:

val repository by lazy { ExampleRepository() }

fun call(){
   repository.loadData()
}

эквивалентным такому (просто раскрыли, на что заменяется by в коде):

val repository = lazy { ExampleRepository() }

fun call(){
   repository.value.loadData()
}

Думаю, при таком виде становится понятно, что при первом обращении к value переменная просто будет инициализирована. В простом примере это может выглядеть так (упрощённый пример):

object UNINITIALIZED_VALUE


fun <T> lazy(block: () -> T): Lazy<T> = object : Lazy<T> {
   private var initializer: (() -> T)? = block
   private var _value: Any? = UNINITIALIZED_VALUE


   override val value: T
       get() {
           if (_value === UNINITIALIZED_VALUE) {
               _value = initializer!!()
               initializer = null
           }
           @Suppress("UNCHECKED_CAST")
           return _value as T
       }


   override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE
}

Настоящая lazy-обёртка чуть сложнее, так как поддерживает пару режимов для создания переменной. Что тут происходит?

При первом обращении к value мы проверяем переменную _value, чтобы понять, была ли она уже инициализирована. Если нет — инициализируем и возвращаем. А если да, то просто возвращаем сохранённое значение.

Это всё. Теперь мы можем сделать так:

val engine: Engine by inject()

И наш код будет работать.

Проброс параметров

Отойдём от машины и её двигателя. Представим, что мы находимся на экране со списком товаров. Кликая по какому-нибудь из них мы открываем другой экран с детальной информацией об этом товаре. И хотим при этом сразу создавать вот такую ViewModel:

class ProduceViewModel(
   val productId: String // Id определенного продукта
): ViewModel() {
   <...>
}

Но как нам это сделать внутри модуля? Ведь productId нам будет известен только в момент открытия. Реализуем механизм, чтобы иметь возможность передавать часть каких-то параметров именно в момент инициализации. Для этого создадим класс ParametersHolder, внутри него и будут лежать параметры, которые должны нам быть известны в момент инициализации.

class ParametersHolder(
   val _values: List<Any>
) {

   @Suppress("UNCHECKED_CAST")
   operator fun <T> get(i: Int) = _values[i] as T
  
}

Ключевое слово operator позволяет обращаться к элементам класса ParametersHolder, как мы привыкли это делать у массивов и списков через []

val parametersHolder = ParametersHolder(listOf<Any>(<....>))
val first = parametersHolder[0] // вернет первый параметр

И дальше нам осталось для метода get и всех последующих методов в цепочке вызова добавить возможность прокидывать параметры:

inline fun <reified T> get(
   qualifier: String? = null,
   noinline parameters: (() -> ParametersHolder)? = null - лямда для создания ParametersHolder
: T {
   val indexKey = indexKey(qualifier, T::class)
   return instances[indexKey]?.get(parameters) as? T
			?: error("Не найдена реализация")
}


class FactoryProvider<T>(factory: Definition<T>): Provider<T>(factory){

   override fun get(
   ++ parameters:  (() -> ParametersHolder)? - добавилось
   ) = create(parameters)
   
}


//В Koin данный класс носит имя InstanceFactory
abstract class Provider<T>(
   private val factory: Definition<T>
) {
   protected fun create(
   ++ parameters:  (() -> ParametersHolder)? - добавилось
   ): T {
       //тут пока остановимся
       return factory.invoke(ServiceLocator)
   }
   
   abstract fun get(parameters: (() -> ParametersHolder)?): T
}

Мы неспроста сделали это действие как функцию () -> ParametersHolder: теперь мы можем лениво инициализировать список наших параметров при инициализации через inject. Осталось только добавить в нашу лямбду список параметров:

typealias Definition<T> = ServiceLocator.(ParametersHolder) -> T

abstract class Provider<T>(
   private val definition: Definition<T>
) {
   protected fun create(parameters:  (() -> ParametersHolder)?): T {
       val parametersHolder = parameters?.invoke() ?: ParametersHolder(emptyList())
       return definition.invoke(ServiceLocator, parametersHolder)
   }
}

Мы получили возможность прокидывать параметры из места вызова get.

class ProduceViewModel(
   val productId: String // Id определенного продукта
): ViewModel() {
   <...>
}


//место, где создается наша ViewModel
val viewModel: ProduceViewModel by viewModels { 
 	ServiceLocator.get(
   		parameters = { ParametersHolder(listOf("ownProdictId")) }
    )
}


// или через inject, предварительно добавив в него поле с лямбдой parameters
val viewModel: ProduceViewModel by inject(
    parameters = { ParametersHolder(listOf("ownProdictId")) }
)


//как это выглядит внутри Module
factory<ProduceViewModel> { parametrHolder ->
   ProduceViewModel(parametrHolder[0]) // под индексом 0 лежит наш "ownProdictId"
}

Ну вот и всё. Каждый шаг сам по себе не такой и сложный, но в сумме мы получаем довольно гибкий и удобный механизм.

Выводы

В этой статье мы рассмотрели механизм работы Koin, написав свою реализацию. Некоторые механизмы не были рассмотрены (например, создание отдельных scope), но и изученного нам хватило, чтобы создать легковесное решение для инъекции зависимостей в приложениях на языке Kotlin.

Чего мы добились: 

  1. Наше решение предоставляет удобный и гибкий механизм для управления зависимостями в приложениях на Kotlin.

  2. Мы можем легко передавать параметры в зависимости при их создании, что повышает гибкость и настраиваемость приложения. 

  3. Решение поддерживает различные виды хранения зависимостей, такие как Singleton и Factory, и при желании можно добавить новые. 

  4. Механизм обеспечивает лёгкость конфигурирования и настройки зависимостей через простой и понятный DSL. 

  5. Наше решение позволяет легко создавать singleton-объекты, которые могут быть доступны во всём приложении и обеспечивают единую точку доступа к ресурсам. 

  6. Оно обеспечивает хорошую производительность и минимальные расходы за счёт отложенной инициализации зависимостей и их кэширования.

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


  1. OTL
    30.09.2024 11:36
    +1

    Спасибо за статью! Было бы интересно почитать про Dagger


  1. aamonster
    30.09.2024 11:36

    Почему для 90% авторов статей DI – это обязательно DI-контейнер? Это же не самый употребительный и не самый полезный (хотя иногда нужный) вариант DI.


    1. Inkompetent Автор
      30.09.2024 11:36

      Цель данной статьи попробовать воссоздать Koin с нуля, точнее базовый его механизм, поскольку Koin - ServiceLocator без DI-контейнера не обойтись


  1. kamikaz-e
    30.09.2024 11:36

    Ребята, не стоит вскрывать эту тему. Вы молодые, шутливые, вам все легко. Это не то. Это не Dagger2 и даже не Hilt. Сюда лучше не лезть. Серьезно, любой из вас будет жалеть. Лучше закройте тему и забудьте, что тут писалось. Я вполне понимаю, что данным сообщением вызову дополнительный интерес, но хочу сразу предостеречь пытливых - стоп. Остальные просто не найдут.


  1. Gluber
    30.09.2024 11:36

    Осталось понять, что такое DI


    1. Inkompetent Автор
      30.09.2024 11:36

      Внедрение зависимости (англ. Dependency injection, DI) — процесс предоставления внешней зависимости программному компоненту.