how to make your own DI in 10 minutes

Hello, my name is Ivan Kurak, I am an Android developer of the Ozon Job application. In this article we implement our mechanism on which Koin is built. Thus, we will go through the path that its authors took when solving the problem of building their own DI.

This article will be useful for those who use Koin in their applications and those who want to make their own simple DI. After all, only understanding how something works will help create new solutions, taking into account the experience of past developments, and modify existing solutions without tragic consequences.

An additional (but no less important) goal of the article is to show that the basic mechanism on which Koin is built is not so scary 🙂

Table of contents

We will write a DI for a group of classes like this:

// Интерфейс для зависимости
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()
   }
}

It's quite simple: we have Carand it needs some type of engine.

Briefly about Koin and ServiceLocator

What is Koin?

Koin is a dependency injection framework, and we can loosely compare it to a piggy bank filled with different coins. When you need money, you can turn to her, and she (with a dissatisfied grunt) will be ready to provide you with the necessary amount.

Likewise, Koin “collects” dependencies internally and is ready to provide them. When you request an object, Koin returns not only it, but also all the dependencies associated with it.

Let's start with the fact that Koin is an implementation of the Service Locator design pattern. It focuses on storing all application dependencies in one place. Instead of each application component creating its dependencies directly, we access the object to obtain dependencies ServiceLocator.

A simple implementation for our example might look like this:

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
   }
}

What's going on here? The storage location for all data is the object ServiceLocatorunderneath (in our example) it has Map<KClass, () -> Any> and methods to add values ​​into Map.

How to distinguish identical objects?

What if we need to forward instances GasolineEngine And ElectricEngineand the choice depends on the situation?

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

Perhaps someone will suggest passing implementations directly:

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

And this will work, but usually we don't know our implementation class (let's say we're trying to create Engine in another module). If we make this class visible in places where we shouldn't know about it, we will be implementation dependent. In addition, if the class changes its name or we want to start using a different implementation everywhere and at once, we will be forced to go through the entire code and edit everything on the go.

Therefore, we will leave access to the class by field Engine::classbut let's teach our ServiceLocator distinguish objects. To do this, we are modernizing our methods of preservation and acquisition.

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

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

If we want to distinguish two identical implementations from each other, we can pass a string, which will be a kind of unique key for the same classes, but different instances. And if we don’t need this logic, then we’ll just leave it null.

And now the question arises: how to store instances in ServiceLocator? The first thought is to create a data class that will hold this value inside itself.

Something like this:

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


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

What is the problem with this solution? In optimization. Surely this will work. But we will have many classes Keyeach of which contains instances of classes String And KClass. But they are needed simply in order to obtain the necessary copy from ServiceLocator.instances. Looks like there's room for optimization here…

We will replace ours Key -> String and we get something like this:

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

Let's write this function:

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

The main advantage of this solution is that we allocate memory only for strings, instead of storing multiple objects Key (which contains String And KClass) With this method of creating a string, we will not create a new string for the same values ​​every time, but will take it from the StringPool. Ultimately this solution is equivalent to the solution with Keybecause we will receive for the same qualifier And KClass the same line. Let's add this solution to 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("Не найден”)
	  ...
   }


}

And when we run our solution, we get working code:

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()
}

Creating a Module Concept and Removing Objects from a Map

Now the question arises about readability and independence of places from where we can fill our ServiceLocator. For example, we have network dependencies and dependencies for local stores. We don't want to list them all in one place, but want to make our code clearer for those who look at it later. And here, it seems, there are no problems: nothing prevents you from creating the following classes in two different files (or modules):

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


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

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

But with such a solution, two questions immediately arise:

  1. What happens if we ask for dependency Remote before they called RemoveDep.loadDependencies()?

  2. What will happen if our RemoteImpl() will inject some network dependency internally (for example, API from Retrofit or HttpClient from Ktor) to make network requests, but no one has put these network dependencies into the ServiceLocator itself?

The answer is simple – it crashes with an error. To get rid of such a problem, we need a rule that will allow us to load all our dependencies into ServiceLocator before we try to get any dependency from there.

This can be done when starting the application. But we don't want any classes marked as internal (visible at module level) or private (visible at the file level) were visible where they should not have been. Therefore, we will create an abstraction that will internally call ServiceLocator.save for each variable that we want to add to ServiceLocator. Let's call this class Moduleand add the corresponding method to ServiceLocator.

class Module { ... }


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

The question remains what it will be Module? What should we think? What if I do Module a separate interface, and its implementations will be responsible for filling 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() }
   }
}

This will work, but our module takes on unnecessary responsibility. With this implementation, it not only collects the dependencies, but is also responsible for how to put them in ServiceLocator. No better than the previous example: the modules still work directly with ServiceLocator, unless we created a single place where these modules will be called.

Ideally, I would like to hide the ability to explicitly fill our ServiceLocator. After all, it is the heart of our mechanism for working with dependencies and working with ServiceLocator directly with the lack of a strict rule about where to fill it can shoot us in the foot. And as Murphy's law says:

“If anything can go wrong, it will go wrong at the worst possible time.”

Let's make ours Module class and create an intermediate Map inside modules. Then in the method loadModules just go through the data and write the values ​​from them into Mapinside 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() }
       )
   }

We have removed the implementation of the method save from ServiceLocator and implemented it in the class Module.Now our modules are really just a way to fill ServiceLocatorand nothing more. A ServiceLocator deals only with loading and retrieving values ​​by key.

The way we created ours engineModulesuggests that it is possible to make the moment of module initialization more readable. To do this, let's write a simple DSL.

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

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

It has become more readable. There is one mini-bonus in this implementation: inside our Map (Module.mapping) will contain all the keys for which we stored values ​​in ServiceLocator. Having obtained these keys, we can, without straining, add the function of deleting from ServiceLocator all lambdas that were inserted into it from a specific module.

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

And no problems!

It is worth saying that Koin allows you to dynamically add and remove some modules, and thereby bypasses the idea of ​​adding everything at once. But at the same time, you bear all the responsibility for ensuring that this or that dependence is found.

Possibility of creating Singleton

Now ours ServiceLocator It can only assemble lambda functions that can create an instance for us. But we may also want to have one instance for the life of the application. Let's call him Singleton. How to do this? The first idea is to have a separate Map for singletons. Something like this:

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

But how to fill this Map? And how to manage two at once Map? For example, if in two Mapthere will be the same key, then which one Map use? We have several sources emerging that we need to keep an eye on. And it is unknown whether we will need a third Map? But we just need to get a single instance for a specific implementation, and we will go by creating a class Providerwhich will replace our lambdas in ServiceLocator.

I'll explain now. Let's create this class and change it a little 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
}

We made it abstract for a reason. It is the heirs of this class who will decide how exactly we will create and obtain dependencies.

Let's write an implementation for Singleton dependencies:

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

}

When calling a method get we check if the instance has been created. And we either create and give away, or we just give away.

And let’s implement the same for Factory dependencies:

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

Yes, what could be simpler than simply re-creating the instance each time it is accessed.

Now the method get inside ServiceLocator will look just as simple as before. One source for storing all possible variables, and when accessed through get we don't even have a clue how exactly it was created.



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("Не найдена реализация")
}

In class Module replace the methods for creating with save Singleton And 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)
}

Now we can use the method factory to create a dependency that will recreate the value every time a new attempt is made to create an instance. And if we need singleton for the entire lifetime of the application, then we use the method single.

Now let’s rewrite all this into inline functions to make it even more convenient.

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)
}
Lyrical digression about inline functions

To understand what just happened, I’ll give you a brief introduction to inline functions. Let's start with this example:

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


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

What's going on here?

  • We called the function addFourpassing it some value (16).

  • Inside the function we added the number 4 to the value.

And returned this result to callback back to the function from which we originally called the method addFour.

To make it even clearer, this is what the code looks like in Java (slightly modified for clarity, but without losing the meaning):

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);
}

This is what we get when converting from Kotlin to Java.

Now let's change our code a little by marking the method addFour modifier inline.

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


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

It seems like nothing has changed. Let's see what our Java code will look like (slightly modified for understanding, but without losing the meaning):

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

As you can see, we got rid of the unnecessary method call addFour. All the code was inlined instead of calling our method.

Now let's figure it out noInline. To do this, we again slightly modernize our code.

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)
}

We added one more method, but labeled it as noinline. We also added a variable timeVariousinto which we save our noinline lambda. For what? I'll tell you a little later.
What the Java code will look like now (slightly changed for understanding, but without losing the meaning):

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);
}

All callbackwhich was not marked as noinlinecompletely inlined instead of calling a function addFour. A secondCallback created a special class, as in the example without an inline function.

An inline function allows you to embed all the code inside instead of calling it. A noinline allows you to un-embed certain callback. Why is this necessary?
A variable was created specifically for this purpose timeVarious. Due to the fact that the noinline function is not built in, we can treat it as a separate object and even save it somewhere. And with the first callback such action is not permitted.

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

Now let’s discuss another feature of inline functions, namely – reified. First, let's create the following variable:

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

Just by KClass we will receive a string that is the name of the class. How to get such a string from Map? Quite simple:

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

This syntax doesn't look very attractive. Well, let's try to put it in a separate function:

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


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

But we see a problem: our function simply has nowhere to get an instance KClass during code execution, after all, when moving from function to function, our generic types are erased. An inline function can come to the rescue, because at the place of the call (in the method main) we know exactly what we want to get Int::class. Let's make our function inline:

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

But this still leads us to an error. Despite the fact that the function was built in, information about the specific type was still erased at the time of insertion, based on the calculation that you simply did not need this type inside the function. That's why the keyword exists reifiedwhich tells the compiler to preserve the class type after inlining. When adding one keyword:

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

After compilation we will get this code:

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

If you try to use such lambdas, the studio will scold you:

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

The problem is that the compiler lacks type information. And he demands to explicitly register this manually in <>. This is a good hint, because right now we don't have a strict rule that if we want to create an instance ElectricEnginethen we need to ask to transfer some instance of the type Engine or himself ElectricEngine.

I'll explain with an example. Our current implementation allows us to do this:

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

And when trying to get Car we will get an error because ElectricEngine is neither an inheritor nor an implementation of the class Car. Well, let's fix this. An example will be for the method factorybut for single everything works the same way:

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
}

Use everywhere generic We won't succeed. We introduce a strict rule regarding what classes and lambda functions we can populate our ServiceLocator. Now the example simply won’t compile, and the studio will tell us this:

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

Let's now try to create an instance of the class Car:

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

We see a clear problem: to create Car need a copy Engine. And the solution is simple:

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

Here again we communicate directly with our hands ServiceLocator, but I would like something simple.

single {
   Car(engine = get())
}

But there is a nuance that is worth reporting. The point is in Koin itself: when calling the method get we are looking for the nearest one Koin scope (meaning an area within which you can get some dependencies, but when you leave it or delete this area, all references to the dependencies are deleted). And if you couldn't find the nearest one scopethen dependencies are looked for in rootScope (this is where our variables created via single And factory). Myself scope serves as an additional parameter for creating a key by which values ​​are searched in ServiceLocator.

But in our example we will not work with such features as scope. Instead, we will take dependencies directly from ServiceLocatorbut we implement working with it just as beautifully as with scope. That is, we get a similar way of searching for dependencies when filling ServiceLocator.

Let's start with the fact that, as in the example with creating methods factory And singlelet's make our method get lambda function.

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("Не найдена реализация")
   }

Studio reports an error when accessing a variable instancesbecause she private. The code from the lambda function, after being embedded in the call site, will not be able to access other people’s private variables, so let’s use this trick:

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

The problem is half solved, now we can create our instance.

single {
   Car(ServiceLocator.get())
}

But it's not perfect yet. To solve the remaining half of the problem, we'll go to our lambda parameter definition and modernize it a little.

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

We also need to make a replacement in all the places where we worked with our lambda. Hmm… it’s not very convenient to rewrite this function everywhere every time you change. Therefore, let's create typealias (that is, a capacious word that, when compiled, will be substituted in places where everything that comes after = is used).

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

And replace it everywhere to make it uniform:

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()
}

Well, now all that remains is in the place where we call the lambda, throw ServiceLocatornamely in the method create class Provider<T>.

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

And we achieved what we wanted.

singleton {
   Car(get())
}

As a bonus, let's implement another method inject:

inline fun <reified T> inject(
   qualifier: String? = null
): Lazy<T> = lazy {
   ServiceLocator.get(qualifier)
}
A little about lazy

A short example about what it is lazyif you suddenly have doubts about how it works. The following code will count:

val repository by lazy { ExampleRepository() }

fun call(){
   repository.loadData()
}

equivalent to this (just revealed what by is replaced with in the code):

val repository = lazy { ExampleRepository() }

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

I think with this view it becomes clear that the first time value is accessed, the variable will simply be initialized. In a simple example it might look like this (simplified example):

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
}

Real lazy-the wrapper is a little more complicated, as it supports a couple of modes for creating a variable. What's going on here?

When you first contact value we are checking the variable _valueto see if it has already been initialized. If not, we initialize and return. And if so, then we simply return the saved value.

This is all. Now we can do this:

val engine: Engine by inject()

And our code will work.

Forwarding parameters

Let's step away from the car and its engine. Let's imagine that we are on a screen with a list of products. By clicking on any of them, we open another screen with detailed information about this product. And at the same time we want to immediately create something like this ViewModel:

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

But how can we do this inside a module? After all productId We will only know at the time of discovery. We are implementing a mechanism to be able to transfer some of the parameters exactly at the moment of initialization. To do this, let's create a class ParametersHolderinside it will be the parameters that should be known to us at the time of initialization.

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

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

The operator keyword allows you to access members of a class ParametersHolderas we are used to doing with arrays and lists via [].

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

And then all we have to do is add the ability to pass parameters for the get method and all subsequent methods in the call chain:

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
}

We made this action as a function for a reason () -> ParametersHolder: We can now lazily initialize our parameter list on initialization via inject. All that remains is to add a list of parameters to our lambda:

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)
   }
}

We now have the ability to pass parameters from where we call 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"
}

Well, that's all. Each step in itself is not that complicated, but in total we get a fairly flexible and convenient mechanism.

Conclusions

In this article, we examined the mechanism of Koin by writing our own implementation. Some mechanisms were not considered (for example, creating separate scopes), but what we learned was enough to create a lightweight solution for dependency injection in Kotlin applications.

What we have achieved:

  1. Our solution provides a convenient and flexible mechanism for managing dependencies in Kotlin applications.

  2. We can easily pass parameters to dependencies when creating them, which increases the flexibility and customizability of the application.

  3. The solution supports various types of dependency storage, such as Singleton And Factoryand if desired, you can add new ones.

  4. The mechanism provides ease of configuration and configuration of dependencies through a simple and understandable DSL.

  5. Our solution makes it easy to create singleton-objects that can be accessed throughout the application and provide a single point of access to resources.

  6. It provides good performance and minimal costs due to lazy initialization of dependencies and their caching.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *