Writing a simple DI for an Android application

Nowadays, almost every project has at least one library or solution for managing dependencies, but not every developer really understands how these solutions are built. Therefore, in this article, I would like to clarify some basic points on which such well-known libraries as Dagger, Hilt and Koin are built, and also show a practical example of how you can write your own DI solution.

The Golden Trio: Dagger, Hilt and Koin

IN Dagger the most basic thing is the component:

@Component(modules = [DaggerModule::class])
interface DaggerComponent {
    ...
}

It is a kind of container that contains factories that create project dependencies.

What are factories used for, you ask? It's simple – for the delayed creation of dependencies (objects):

interface Factory<T> {
    fun create(): T
}

val factory = Factory {
    Repository(...)
}

val repository = factory.create()

Typically, DI libraries use two types of factories, we will return to them when we write our solution, the main thing to keep in mind is that dependencies are not created immediately, but only on demand.

Let's go back to the Dagger component and look at the generated code:

class DaggerComponent(...) {

  private val okHttpFactory = ...
  private val roomDatabaseFactory = ...

  private val viewModelFactory = ...

  fun inject(activity: MainActivity) {
      activity.viewModel = viewModelFactory.create()
  }
  
}

class App : Application() {
  
    val component by lazy { DaggerComponent(...) }
    
}

class MainActivity : ComponentActivity() {

    lateinit var viewModel: ViewModel

    override fun onCreate(...) {
        super.onCreate(...)
        (applicationContext as App).component.inject(this)
    }
  
}

For simplicity, I've omitted unnecessary details, the main idea is that a Dagger component stores a list of factories for obtaining dependencies, which are usually specified via @Inject annotation in the constructor or declared in Dagger modules. It is important to add that a Dagger component can accept dependencies during initialization, such as the Application context.

Using this principle, you can store dependencies in a HashMap, for example:

class SomeComponent(...) {
  
    private val dependencies = hashMapOf<String, Factory<*>>()

    fun add(key: String, factory: Factory<*>) {
        dependencies[key] = factory 
    }

    fun <T> instance(key: String): T {
        dependencies[key].create() as T
    }
}

class MainActivity {

    lateinit var viewModel: ViewModel
  
    override fun onCreate(...) {
        super.onCreate(...)
        viewModel = (applicationContext as App).component.instance("viewModel")
    }
  
}

Only in this case will you have to manually put the dependencies into the container, and then also manually extract them.

A similar method is implemented in Koinwhere there is no code generation:

val appModule {
    single<OkHttpClient> { ... }
    single<RoomDatabase> { ... }
}

val koin = startKoin { 
    modules(
        appModule,
        ...
    )
}.koin

val okHttpClient = koin.instance<OkHttpClient>()

A module in Koin is almost the same as a Dagger component – a container with factories.

Now let's talk a little about such a thing as Scope or visibility area in DI. Dagger does not have such a mechanism explicitly, but it is in Hilt (by the way, this is one of the reasons for the emergence of this library):

Hilt Scopes

Hilt Scopes

If you want an object that will live as long as the ViewModel lives, then you mark it with an annotation @ViewModelComponentif the Activity is still alive – annotation @ActivityComponent etc.

There is no magic here, as Scope is really just a place where the Dagger component lives:

class MainViewModel : ViewModel {

    val component by lazy { DaggerComponent(...) }
  
}

class MainActivity : ComponentActvity {

    val component by lazy { DaggerComponent(...) }
  
}

class App : Application() {

    val component by lazy { DaggerComponent(...) }
  
}

From my own observations and experience, I realized that objects (dependencies) in most cases are created where they are really needed, that is, by default in the correct scope (Scope), and therefore there is no point in using the same Hilt, a simple example to confirm:

class PostDetailViewModel : ViewModel() {

    // insertUseCase создаётся через фабрику и живёт пока 
    // не будет уничтожена PostDetailViewModel
    private val insertUseCase : PostInsertUseCase = DI.instance()

}

In addition, Hilt generates heirs for Application, Activity and other classes, as a result, the resulting codegen becomes very dirty, redundant and more prone to errors than the codegen from Dagger. I definitely recommend digging into the latter or looking at my article on Github on this topic.

Let's sum it up:

  • Dagger component or Koin module is some container with dependency factories (objects)

  • Scope or visibility area in DI is just a place where dependencies are located, for example, if you put a Dagger component in the Application class, then the visibility area will be global for the Android application

  • Hilt complicates the resulting code and, for me personally, is redundant in projects.

Writing your own DI container

Before I start writing my solution, I would like to note that Dagger and Koin have the ability to create entire dependency graphs:

val koin = startKoin { 
    modules(
        appModule,
        coreModule,
        ...
    )
}.koin

As a result, two modules will be combined into one DI container and if suddenly in coreModule you will need an Application context, for example, it will be taken from appModule.

Dagger as such does not have a feature for combining components (dependencies do not count, it works a little differently), but you can use modules:

@Component(
    moduls = [
        AppModule::class,
        CoreModule::class,
        ...
    ]
)
interface DaggerComponent { ... }

We will not make a complex hierarchy of DI containers, but we will make one global one:

interface Factory<T> {
  
    fun create() : T
  
}

object DI {

    val map: MutableMap<KClass<*>, Factory<*>> = mutableMapOf()

}

For the sake of simplicity, I decided to use as a key KClass for the object type, in simple words, for each class you can create only one version of the object. If you suddenly need to have two OkHttpClient's with different settings, then you need to make a more complex key, for example, like in Koin:

// помимо KClass можно добавить Qualifier и scoped Qualifier 
fun indexKey(clazz: KClass<*>, typeQualifier: Qualifier?, scopeQualifier: Qualifier): String {
    val tq = typeQualifier?.value ?: ""
    return "${clazz.getFullName()}:$tq:$scopeQualifier"
}

The DI container is ready, now we need to create factories, as I already said, there are usually two types, let's start with the first:

object DI {

    ...

    // reified нужен чтобы получить KClass<*> для ключа хэш-таблицы
    inline fun <reified T : Any> factory(crossinline dependencyProducer: () -> T) {
        map[T::class] = object : Factory<T> {
            override fun create(): T {
                return dependencyProducer.invoke()
            }
        }
    }

    ...
  
}

Simple factory, returns a new object on each method call Factory.create()

The second type is more complicated:

object DI {

    ...

    inline fun <reified T : Any> singleton(crossinline dependencyProducer: () -> T) {
        map[T::class] = object : Factory<T> {
            private var _dependency: T? = null

            /* 
            распространённый паттерн для создания потокобезопасного 
            Singleton'а объекта, называется Double-checked locking
            
            вообще можно обойтись без паттерна, если уверены на 100%
            что код будет выполняться только на главном потоке
            */
            override fun create(): T {
                _dependency?.let { return it }
                synchronized(this) {
                    _dependency?.let { return it }
                    val dependency = dependencyProducer.invoke()
                    _dependency = dependency
                    return dependency
                }
            }

            // вариант без паттерна Double-checked locking
            override fun create(): T {
                _dependency?.let { return it }

                val dependency = dependencyProducer.invoke()
                _dependency = dependency
                return dependency
            }

            
        }
    }

    ...
  
}

Creates an object when needed and stores a reference to it so as not to recreate it again, in our case it is a full-fledged Singleton, since we have a global DI container. In Dagger and Koin, such a thing is applied only to a module or component, and as you already know, the latter can be located anywhere: in Activity, in Application and other parts of the application.

Well, the last highlight is that we need a convenient method for getting dependencies from the DI container:

object {

    ...

    @Suppress("UNCHECKED_CAST")
    inline fun <reified T> instance(): T {
        return map[T::class]?.create() as T
    }

    ...
  
}

As a result, we get a more or less complete DI container:

interface Factory<T> {
    fun create() : T
}

object DI {

    val map: MutableMap<KClass<*>, Factory<*>> = mutableMapOf()

    @Suppress("UNCHECKED_CAST")
    inline fun <reified T> instance(): T {
        return map[T::class]?.create() as T
    }

    inline fun <reified T : Any> factory(crossinline dependencyProducer: () -> T) {
        map[T::class] = object : Factory<T> {
            override fun create(): T {
                return dependencyProducer.invoke()
            }
        }
    }

    inline fun <reified T : Any> singleton(crossinline dependencyProducer: () -> T) {
        map[T::class] = object : Factory<T> {
            private var _dependency: T? = null

            override fun create(): T {
                _dependency?.let { return it }
                synchronized(this) {
                    _dependency?.let { return it }
                    val dependency = dependencyProducer.invoke()
                    _dependency = dependency
                    return dependency
                }
            }
        }
    }

}

Ta-dam! We have written our own DI solution, we can start cutting the project!

Using the DI you just wrote

Let's add the first dependency to the DI container:

DI.singleton {
    Room.databaseBuilder(
        applicationContext,
        AppDatabase::class.java,
        AppDatabase.NAME
    ).build()
}

It is better to store objects such as a database in the global scope to effectively reuse the database connection and not open it at every sneeze.

For most other objects, it's better to use simple factories:

DI.factory {
    PostInsertUseCase(instance())
}

Note that the factory does not accept anything from the outside, all the necessary dependencies are extracted from the DI container via DI.instance() method, by the way, this is also done in Koin, it’s a very convenient thing.

We have learned how to add dependencies, all that remains is to build a full-fledged graph. To do this, we need to take all the dependency factories from other modules and put them in our DI container:

class App : Application() {

    override fun onCreate() {
        super.onCreate()

        // строим граф зависимостей
        DI.initAppDependencies(applicationContext)
        DI.initCoreDependencies()
    }

}

// модуль app
fun DI.initAppDependencies(applicationContext: Context) {
    singleton {
        Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            AppDatabase.NAME
        ).build()
    }
    factory { instance<AppDatabase>().postDao() }
}

// модуль core
fun DI.initCoreDependencies() {
    factory {
        PostInsertUseCase(instance())
    }
    factory {
        PostDeleteUseCase(instance())
    }
    factory {
        PostFetchAllUseCase(instance())
    }
    factory {
        PostFetchByIdUseCase(instance())
    }
}

The construction takes place in app module, and the DI container itself is located in coreso that you can get dependencies in any module.

Important point: you cannot depend on the app module, it is an assembly module, it assembles other modules into the final artifact – an apk archive or an aab file.

It's time to write a simple functionality for the list of posts in a separate module post_listof course we create things for this PostListViewModel:

internal class PostListViewModel : ViewModel() {

    // вот таким элегантным способом мы получаем нужную зависимость
    private val fetchAllUseCase: PostFetchAllUseCase = DI.instance()
    private val fetchDeleteUseCase: PostDeleteUseCase = DI.instance()

    private val _state = MutableStateFlow(persistentListOf<PostModel>())
    val state = _state.asStateFlow()

    private val _effect = MutableSharedFlow<PostListEffect>()
    val effect = _effect.asSharedFlow()

    fun handleEvent(event: PostListEvent) {
        when(event) {
            is PostListEvent.FetchAll -> handleEvent(event)
            is PostListEvent.Delete -> handleEvent(event)
            is PostListEvent.View -> handleEvent(event)
            is PostListEvent.Add -> handleEvent(event)
        }
    }

    private fun handleEvent(event: PostListEvent.FetchAll) = viewModelScope.launch {
        _state.value = fetchAllUseCase.execute().toPersistentList()
    }

    private fun handleEvent(event: PostListEvent.Delete) = viewModelScope.launch {
        fetchDeleteUseCase.execute(event.model)
        handleEvent(PostListEvent.FetchAll)
    }

    private fun handleEvent(event: PostListEvent.View) = viewModelScope.launch {
        _effect.emit(PostListEffect.View(event.model))
    }

    private fun handleEvent(event: PostListEvent.Add) = viewModelScope.launch {
        _effect.emit(PostListEffect.Add)
    }

}

PostListViewModel you don't even need to accept dependencies in the constructor, this is a clear plus, since you can easily forget about custom factories for ViewModel.

I would also like to note that in our self-written solution there is no such thing as the Scopes mechanism, let me remind you that Dagger does not have it either, and as I said earlier, in this case everything depends on the location of the dependency, as an example, let's take some ViewModel:

class PostDetailViewModel : ViewModel() {

    // объект insertUseCase будет жить пока жива PostDetailViewModel 
    private val insertUseCase : PostInsertUseCase = DI.instance()

}

As for me, this is much more logical than trying to designate the required scope of visibility through Hilt annotation PostInsertUseCase instance. Of course, there are specific cases, but this already depends on the project and tasks, you can always adapt the solution if it was initially well designed.

To sum it up:

  1. Heavy dependencies such as database or OkHttpClient are best kept in the global scope.

  2. The remaining dependencies in most cases can be safely stored through simple factories

  3. It is much simpler and more logical to get dependencies when they are needed and where they are needed, as a result of which the use of a mechanism such as Scopes is eliminated.

Conclusion

The article turned out to be quite informative and I hope you learned something new. The most important thing is to try to come up with your own solutions and try to implement them, even if you do not fully understand what you are doing: library design is a very useful skill, especially if you like to do project architecture or have a desire to move in this area.

Useful links:

  1. My telegram channel

  2. Sources to the article

  3. My other articles

Write your opinion in the comments and good code to everyone.

Similar Posts

Leave a Reply

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