Пишем простенький DI для Android приложения

37f45fc9f51a37ef17d0db8ab4c6dfa2.png

Сейчас практически любой проект имеет в своём составе как минимум одну библиотеку или решение для разруливания зависимостей, но далеко не каждый разработчик действительно понимает как устроены эти самые решения. Поэтому в этой статье я хотел бы прояснить некоторые базовые моменты, на которых построены такие известные библиотеки как Dagger, Hilt и Koin, а также показать на практическом примере как можно написать свое DI решение.

Золотое трио: Dagger, Hilt и Koin

В Dagger самой базовой штукой является компонент:

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

Это своего рода контейнер, содержащий в себе фабрики, которые и создают зависимости проекта.

Для чего используются фабрики спросите вы, а всё просто — для отложенного создания зависимостей (объектов) :

interface Factory {
    fun create(): T
}

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

val repository = factory.create()

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

Вернёмся к Dagger компоненту и посмотрим на сгенерированный код:

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

Для простоты я опустил ненужные детали, основная идея в том, что Dagger компонент хранит в себе список фабрик для получения зависимостей, которые обычно прописываются через @Inject аннотацию в конструкторе или объявляются в Dagger модулях. Важно добавить, что Dagger компонент может принимать на вход зависимости при инициализации, например Application контекст.

По такому принципу можно хранить зависимости в HashMap’е например:

class SomeComponent(...) {
  
    private val dependencies = hashMapOf>()

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

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

Только в таком случае придётся вручную складывать зависимости в контейнер, а потом также вручную их доставать.

Похожий способ реализован как раз в Koin, где нет кодогенерации:

val appModule {
    single { ... }
    single { ... }
}

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

val okHttpClient = koin.instance()

Модуль в Koin это практически тоже самое, что и Dagger компонент — контейнер с фабриками.

Теперь немного поговорим о такой штуке как Scope или область видимости в DI. В Dagger нет такого механизма в явном виде, но зато он есть в Hilt (кстати это одна из причин возникновения этой библиотеки):

Hilt Scopes

Hilt Scopes

Если вам нужен объект, который будет жить пока живет ViewModel, то вы помечаете его аннотацией @ViewModelComponent, если пока живет Activity — аннотацией @ActivityComponent и тд.

Здесь нет никакой магии, так как на самом деле Scope это всего лишь место, где лежит Dagger компонент:

class MainViewModel : ViewModel {

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

class MainActivity : ComponentActvity {

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

class App : Application() {

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

Из собственных наблюдений и опыта я понял, что объекты (зависимости) в большинстве случаев создаются там где они действительно нужны, то есть по дефолту в правильной области видимости (Scope), а следовательно отпадает смысл в использовании того же Hilt’а, простой пример для подтверждения:

class PostDetailViewModel : ViewModel() {

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

}

К тому же Hilt генерирует наследников для Application, Activity и других классов, в итоге результирующий кодген становится очень грязным, избыточным и более подверженным ошибкам нежели кодген от Dagger. В последнем однозначно рекомендую покопаться или глянуть мою статью на Github’е по этой теме.

Давайте подведём итоги:

  • Dagger компонент или Koin модуль это некоторый контейнер с фабриками зависимостей (объектов)

  • Scope или область видимости в DI это всего лишь место, где лежат зависимости, например если Dagger компонент положить в Application класс, то область видимости будет глобальной для Android приложения

  • Hilt усложняет результирующий код и лично по мне избыточен в проектах.

Пишем свой DI контейнер

Прежде чем начать писать свое решение хотел бы отметить что в Dagger и Koin есть возможность создавать целые графы зависимостей:

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

В итоге два модуля будут объедины в один DI контейнер и если вдруг в coreModule понадобится Application контекст например, он будет взят из appModule.

Dagger как таковой фичи по объединению компонентов не имеет (dependencies не в счёт, он работает немного по другому), но зато можно использовать модули:

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

Мы же не будем делать сложную иерархию из DI контейнеров, а сделаем один глобальный:

interface Factory {
  
    fun create() : T
  
}

object DI {

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

}

В качестве ключа для простоты я решил использовать KClass для типа объекта, простыми словами для каждого класса можно будет создать только один вариант объекта. Если вам вдруг нужно иметь два OkHttpClient’а с разными настройками, то необходимо сделать более сложный ключ, например как в Koin:

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

DI контейнер готов, теперь нужно создать фабрики, как я уже говорил их чаще всего два типа, начнём с первого:

object DI {

    ...

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

    ...
  
}

Простая фабрика, возвращает новый объект при каждом вызове метода Factory.create()

Второй тип посложнее:

object DI {

    ...

    inline fun  singleton(crossinline dependencyProducer: () -> T) {
        map[T::class] = object : Factory {
            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
            }

            
        }
    }

    ...
  
}

Создаёт объект когда это нужно и хранит на него ссылку, чтобы не пересоздавать заново, в нашем случае это полноценный Singleton, так как у нас глобальный DI контейнер. В Dagger и Koin такая штука применяется только к модулю или компоненту, а как вы уже знаете последние могут находиться где угодно: в Activity, в Application и других частях приложения.

Ну и последняя изюминка — нам нужен удобный метод для получения зависимостей из DI контейнера:

object {

    ...

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

    ...
  
}

В результате получим более менее полноценный DI контейнер:

interface Factory {
    fun create() : T
}

object DI {

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

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

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

    inline fun  singleton(crossinline dependencyProducer: () -> T) {
        map[T::class] = object : Factory {
            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
                }
            }
        }
    }

}

Тадам! Мы написали собственное DI решение, можно пилить проект!

Использование только что написанного DI

Добавим первую зависимость в DI контейнер:

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

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

Для большинства других объектов лучше использовать простые фабрики:

DI.factory {
    PostInsertUseCase(instance())
}

Обратите внимание, что фабрика ничего извне не принимает, все нужные зависимости извлекаются из DI контейнера через DI.instance() метод, в Koin кстати также сделано, это очень удобная штука.

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

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

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

Построение происходит в app модуле, а сам DI контейнер лежит в core, чтобы можно было получить зависимости в любом модуле.

Важный момент: от app модуля нельзя зависеть, это сборочный модуль, он собирает остальные модули в конечный артефакт — apk архив или aab файл.

Пришло время написать простой функционал для списка постов в отдельном модуле post_list, разумеется создаём для этого дела PostListViewModel:

internal class PostListViewModel : ViewModel() {

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

    private val _state = MutableStateFlow(persistentListOf())
    val state = _state.asStateFlow()

    private val _effect = MutableSharedFlow()
    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 даже не нужно принимать зависимости в конструкторе, это явный плюс, так как можно спокойно забыть про кастомные фабрики для ViewModel.

Также хотел бы отметить, что в нашем самописном решении нет такой штуки как механизм Scopes, напомню что в Dagger его тоже нет, и как я ранее говорил в таком случае всё зависит от места получения зависимости, в качестве примера возьмём какую-нибудь ViewModel:

class PostDetailViewModel : ViewModel() {

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

}

Как по мне так намного логичнее, чем пытаться через Hilt аннотацию обозначить нужную область видимости для PostInsertUseCase экземпляра. Безусловно есть специфичные кейсы, но это уже зависит от проекта и задач, вы всегда можете адаптировать решение, если оно изначательно было хорошо спроектировано.

Суммируем:

  1. Тяжелые зависимости такие как база данных или OkHttpClient лучше хранить в глобальной области видимости

  2. Остальные зависимости в большинстве случаев можно смело хранить через простые фабрики

  3. Намного проще и логичнее получать зависимости, когда они нужны и там где они нужны, вследствии чего отпадает использование такого механизма как Scopes.

Заключение

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

Полезные ссылки:

  1. Мой телеграм канал

  2. Исходники к статье

  3. Другие мои статьи

Пишите в комментах ваше мнение и всем хорошего кода.

© Habrahabr.ru