Ручной DI на Котлине? Легко! Ну, почти…

от автора

Все мы знаем, что Dagger — бич современного общества стандарт индустрии, если это касается Dependency Injection. Все мы знаем, что Dagger хоть и является мощным фреймворком, но сборка проекта с ним занимает довольно много времени, Dagger — страшный сон для многих. А что если отказаться от него? Но в пользу чего? Koin и другие сервис локаторы — так себе идея, ведь весь injection происходит в рантайме и рано или поздно приложение из-за этого упадет. Может быть писать все руками?

Именно так я подумал и решил реализовать ручной DI в своем небольшом проекте.

Дисклеймер

Моя реализация не претендует на роль лучшей или даже хорошей. Мои исходники не являются идеальными и далеко не всегда следуют советам дядюшки Боба, не надо задавать вопросы по типу «А почему у тебя есть WallpaperProvider, WallpaperManager и WallpaperRepository

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

Итерация 1: один Gradle модуль, один граф

Концепция такова: у нас есть единый граф для всего приложения, в котором есть все зависимости приложения. Он хранится в application-классе, что дает нам единственность графа для всего приложения. (Почти) все зависимости создаются только по надобности: достигается это путем делегата lazy. Зависимости, которые нуждаются в activity(PermissionHandler), изначально имеют Noopреализацию. Они доставляются только при создании Activity. Естественно, это несет за собой и минусы: что если кто-то возьмет Noopреализацию до замены на нормальную? Это хороший (и пока не решенный) вопрос.

class Graph(private val app: Application) {     val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }      var permissionHandler: PermissionHandler = PermissionHandler.NoopPermissionHandler     val navController by lazy { createMaterialMotionNavController(app) }     val wallpaperRepository: WallpaperRepository by lazy { WallpaperRepositoryImpl(app, navController) }          // ... }  class WallManApp: Application() {     val graph by lazy { Graph(this) }     override fun onCreate() {         super.onCreate()         // ...     } }

Зависимости без начальной реализации создаются внутри activity. Так же мы прокидываем граф через CompositionLocal в compose для дальнейшего использования:

class MainActivity : ComponentActivity() {     private val graph by lazy {         (this.application as WallManApp).graph     }     override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         graph.apply {             permissionHandler = AndroidPermissionHandler(this@MainActivity)             // ...         }                  setContent {             CompositionLocalProvider(LocalGraph provides graph) {                 // ...             }         }     } }

Как создаются вьюмодели? Очень просто! Создается extension-функция над графом для создания вьюмодели. Это дает нам разгрузить граф от ненужных функций. Так же провайдить зависимости становится легче, когда эта функция лежит в одном файле с самой вьюмоделью:

fun Graph.MainViewModel() = MainViewModel(wallpapersRepository)  class MainViewModel(     private val repo: WallpapersRepository ) : ViewModel() {   // ... }

Очень элегантно, правда? Все зависимости видны в конструкторе, а их внедрение не доставляет трудностей.

Как же доставить вьюмодель в ui? Здесь тоже все довольно просто. Создаем Composable функцию для предоставления зависимостей:

@Suppress("UNCHECKED_CAST") class ViewModelFactory(val viewModel: () -> ViewModel) : ViewModelProvider.Factory {     override fun <T : ViewModel> create(modelClass: Class<T>): T {         return viewModel() as T     } }  @Composable inline fun <reified T : ViewModel> viewModel(noinline block: @DisallowComposableCalls Graph.() -> T): T {     val graph = LocalGraph.current     return androidx.lifecycle.viewmodel.compose.viewModel(factory = remember { ViewModelFactory { graph.block() } }) } 

Этот подход создает жесткую связь между ui и графом, поэтому мы разделяем экран на две функции: с вьюмоделью и без нее. В моем случае это MVI архитектура:

@Composable fun MainScreen(modifier: Modifier = Modifier) {     val viewModel = viewModel { MainViewModel() }     val state by viewModel.state.collectAsStateWithLifecycle()     MainScreen(state, modifier) }  @Composable private fun MainScreen(   state: MainViewModel.MainScreenState,    modifier: Modifier = Modifier ) {   // ... }

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

fun Graph.WallpaperDetailsViewModel(wallpaperHashCode: Int) =     WallpaperDetailsViewModel(wallpaperHashCode, /* ... */)  class WallpaperDetailsViewModel(     private val wallpaperHashCode: Int,     // ... ) : ViewModel() {     // ... }  @Composable fun WallpaperDetailsScreen(wallpaperHashCode: Int, modifier: Modifier = Modifier) {     val viewModel = viewModel { WallpaperDetailsViewModel(wallpaperHashCode) }     val state by viewModel.state.collectAsStateWithLifecycle()     WallpaperDetailsScreen(state, modifier) }

Однако, такой подход имеет свои недостатки. При росте проекта становится все труднее поддерживать один Gradle-модуль, поэтому следует разделять фичи на отдельные модули.

Исходники итерации 1: gitlab.

Итерация 2: разделение на Gradle модули, 1 граф

Вот здесь все становится интереснее. Так как все фичи разделены на разные модули, мы как-то должны связать их в один граф. Для этого мы разделяем наш начальный граф на интерфейс и реализацию(в :di:api и :di:impl, например). Соответственно все фичи делятся на :api, :impl и :ui(опционально) примерно как на диаграмме:

Зависимости между модулями

Зависимости между модулями

Как было сказано ранее, все фичи делятся на несколько модулей:

  • :api — интерфейсы и extension-фунции/проперти к этим интерфейсам(чтоб жизнь слаще была)

  • :impl — реализации интерфейсов из :api

  • :ui — опционально, графический интерфейс фичи. Может зависеть от :di:api для внедрения зависимостей во вьюмодели. Этот модуль не зависит от :impl!

В остальном все остается тем же самым.

И снова минусы: при росте проекта граф может сильно разрастись, поэтому его нужно разделить на подграфы.

Исходники итерации 2: gitlab.

Итерация 3: разделение графа на модули

Чтобы основной граф не выглядел так страшно, можно разделить его на feature-модули:

interface Graph {     val coreModule: CoreModule     val wallpapersModule: WallpapersModule     // ... }

Тогда реализация модуля будет выглядеть так:

interface WallpapersModule: CoreModule {     val wallpapersRepository: WallpapersRepository     // ... }  class WallpapersModuleImpl(     coreModule: CoreModule,     application: Application ) : WallpapersModule,     CoreModule by coreModule {   // ... }

Теперь мы можем менять зависимости вьюмодели с графа на соответствующий модуль:

fun WallpapersModule.MainViewModel() = MainViewModel(   wallpapersRepository )  class MainViewModel(     private val repo: WallpapersRepository, ) : ViewModel() {   // ... }

Как сделать так, чтобы можно было запустить фичу без всего графа? Убрать граф из зависимостей фичи. Здесь есть 2 стула варианта.

Вариант 1: прокидывание модуля через Compose

Для каждого модуля будем добавлять CompositionLocal, который будет проброшен где-то сверху compose-дерева.

Рядом с WallpapersModule добавляем соответствующий CompositionLocal:

interface WallpapersModule: CoreModule {     val wallpapersRepository: WallpapersRepository }  val LocalWallpapersModule =    compositionLocalOf<WallpapersModule> {      error("WallpapersModule is not provided")   }

Чтобы CompositionLocals всех модулей подтянулись, нужно создать специальный провайдер для них в :di:api и вставить его в наше Activity:

@Composable fun ProvideGraphModules(content: @Composable () -> Unit) {     val graph = LocalGraph.current     CompositionLocalProvider(         LocalCoreModule provides graph.coreModule,         LocalWallpapersModule provides graph.wallpapersModule,         // ...     ) {         content()     } }  class MainActivity : ComponentActivity() {     private val graph by lazy {         (application as WallManApp).graph     }      override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)          setContent {             CompositionLocalProvider(LocalGraph provides graph) {                 ProvideGraphModules {                     // ...                 }             }         }     } }

Чтобы вызвать эту вьюмодель, нам нужно сменить прошлый вызов новой функцией в :my_feature:ui:

@Composable inline fun <reified T : ViewModel> viewModel(noinline block: @DisallowComposableCalls WallpapersModule.() -> T): T {     return viewModelWithReceiver(LocalWallpapersModule.current, block) }

И создать viewModelWithReceiver в :core:api:

@Composable inline fun <reified T : ViewModel, R> viewModelWithReceiver(     receiver: R,     noinline block: @DisallowComposableCalls R.() -> T ): T {     return androidx.lifecycle.viewmodel.compose.viewModel(factory = remember {         ViewModelFactory {             receiver.block()         }     }) }

С помощью этих изменений мы можем убрать зависимость :di:api из :my_feature:ui. Это дает нам возможность запускать фичу без создания всего графа.

Опять минусы: можно забыть запровайдить какой-нибудь модуль в ProvideGraphModules, из-за чего приложение может крашнуться. Не очень благоприятный поворот событий.

Исходники варианта 1: gitlab.

Вариант 2: использовать context receivers

Этот вариант гарантирует нам предоставление зависимостей.

Что такое этот ваш context receivers?

Context receivers многим похожи extension-функции, но имеют смысловые и функциональные отличия. На примерах:

fun Logger.allLogsByTag(tag: String): List<String> {     return allLogs().filter { it.tag == tag } }  context(Logger) fun Storage.storeAppState() {     log("Storage", "Starting storing...")     val logs = allLogsByTag("tag")     // ... }

Функция allLogsByTag может выполнять операцию над Logger, в то время как storeAppState может выполняться только в том скоупе, где есть Logger.

Подробнее можно почитать на сайте Jetbrains

На момент написания статью context receivers на стадии prototype. Пока эта функция ограничена Kotlin/JVM, а Jetbrains не рекомендуют использовать ее в продакшене:

The feature is a prototype available only for Kotlin/JVM. With -Xcontext-receivers
enabled, the compiler will produce pre-release binaries that cannot be
used in production code. Use context receivers only in your toy
projects. We appreciate your feedback in YouTrack.

Если вы готовы к context receivers, то подключаем флаг в build.gradle.kts:

tasks.withType(KotlinCompile::class.java) {     kotlinOptions.freeCompilerArgs += listOf("-Xcontext-receivers") }

Убираем из composable-функции вьюмодели какие-либо зависимости от графа:

@Composable inline fun <reified T : ViewModel> viewModel(noinline block: @DisallowComposableCalls () -> T): T {     return androidx.lifecycle.viewmodel.compose.viewModel(factory = remember {         ViewModelFactory {             block()         }     }) }

Добавляем контекст в функцию с вызовом вьюмодели в ui:

context(WallpapersModule) @Composable fun WallpaperDetailsScreen(wallpaperHashCode: Int, modifier: Modifier = Modifier) {     val viewModel = viewModel { WallpaperDetailsViewModel(wallpaperHashCode) }     val state by viewModel.state.collectAsStateWithLifecycle()     WallpaperDetailsScreen(state, modifier) }

В месте вызова(в навигации, например) добавляем блок with для добавления контекста:

with(graph.wallpapersModule) {     composable("WallpaperDetails/{hashcode}") {         val hashCode = ...         WallpaperDetailsScreen(hashCode)     }     // ... }

Так как под капотом context receivers — еще один (или несколько) параметр в функции, а модули не являются стабильными, то посмотрим, что нам выдал compose об этой функции:

restartable scheme("[androidx.compose.ui.UiComposable]") fun WallpaperDetailsScreen(   unstable _context_receiver_0: WallpapersModule   stable wallpaperHashCode: Int   stable modifier: Modifier? = @static Companion ) restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun WallpaperDetailsScreen(   stable state: WallpaperDetailsScreenState   stable modifier: Modifier? = @static Companion )

Видим, что composable-функция, использующая context receivers, non-skippable. Критично ли это? Нет, ведь composable-функция, принимающая state — skippable. То есть при обходе дерева compose всегда будет вызывать функцию с вьюмоделью, а функцию со стейтом — только при необходимости. Подробности про оптимизацию compose-кода: статья от Ozon Tech.

А на самом деле как?

Хороший вопрос. На практике я не заметил разницы, да и layout inspector не показывал ненужных рекомпозиций.

Из минусов: context receivers пока нестабильны(ожидается стабилизация после прихода K2 компилятора) и работают только в JVM (то есть нет поддержки IOS и браузера). Если Jetbrains решит изменить способ вызова, то придется адаптировать весь проект под изменения.

Как костыль альтернативное решение — самим добавлять параметры в функции. Например:

context(WallpapersModule) @Composable fun WallpaperDetailsScreen(wallpaperHashCode: Int, modifier: Modifier = Modifier) {     // ... }

Заменяем на:

@Composable fun WallpaperDetailsScreen(   module: WallpapersModule,    wallpaperHashCode: Int,    modifier: Modifier = Modifier ) {     // ... }

Исходники варианта 2: gitlab.

Заключение

Более перспективным вариантом, по-моему, здесь является context receivers, они дают гарантию предоставления зависимостей.

Что мы имеем от этой реализации:

  • Разрешение зависимости на этапе компиляции

  • Уменьшенное время компиляции по сравнению с Dagger

  • Подсветка в IDE при отсутствии каких-либо зависимостей (уменьшение feedback loop)

  • Независимость от изменений в Dagger

Про минусы не забываем:

  • Возможное падение приложения при использовании CompositionLocal

  • Невозможность использовать на context receivers на IOS и в браузере без костылей

Легко ли было реализовать ручной DI? Довольно просто. А было ли нужно? Я оставлю этот вопрос для вас.

Советую ознакомиться с альтернативными реализациями ручного DI:

Что делать в вашем проекте — решать прежде всего вам.


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *