Все мы знаем, что 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:

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

Комментарии (2)


  1. HuKers
    12.07.2023 15:39

    ты сейчас описал Koin.

    https://insert-koin.io/


    1. Renattele Автор
      12.07.2023 15:39
      +2

      Koin - сервис локатор. У меня немного другой подход, вся(или почти вся, см. зависимости, требующие activity) реализация графа определена еще на этапе компиляции, чего нет в koin.