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

от автора

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

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

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

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

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

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

interface Factory<T> {     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<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")     }    }

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

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

val appModule {     single<OkHttpClient> { ... }     single<RoomDatabase> { ... } }  val koin = startKoin {      modules(         appModule,         ...     ) }.koin  val okHttpClient = koin.instance<OkHttpClient>()

Модуль в 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<T> {        fun create() : T    }  object DI {      val map: MutableMap<KClass<*>, 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 <reified T : Any> factory(crossinline dependencyProducer: () -> T) {         map[T::class] = object : Factory<T> {             override fun create(): T {                 return dependencyProducer.invoke()             }         }     }      ...    }

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

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

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

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

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

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

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

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

Тадам! Мы написали собственное 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<AppDatabase>().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<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 даже не нужно принимать зависимости в конструкторе, это явный плюс, так как можно спокойно забыть про кастомные фабрики для 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. Другие мои статьи

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


ссылка на оригинал статьи https://habr.com/ru/articles/843258/