Многие из Android-разработчиков для Dependency Injection используют Dagger или его «обёртку» Hilt. Но не многие из них используют Dagger SPI. Этот механизм предоставляет нам доступ к графу зависимостей, что позволяет нам добавить свои проверки графа и не только. В этой статье я хочу рассмотреть работу с Dagger SPI на примере поиска неиспользуемых Component Dependencies. После прочтения статьи вы сможете находить их, или при желании сможете писать свои проверки графа зависимостей. Ну или что вам там в голову взбредёт. 

По всем правилам приличия представлюсь — меня зовут Перевалов Данил, а теперь давайте перейдём к теме.

Зависимости

Для начала давайте посмотрим, что такое Component Dependencies, и с чем их «едят».
Допустим, у нас есть какой-то Component, назовём его… FeatureComponent. Он отвечает за хранение и отправку на сервер данных о текущем устройстве. Как следствие, нам понадобятся зависимости для:

  • хранения данных, например, в базе данных;

  • получения информации о текущем устройстве;

  • отправки данных на сервер, по сути, нам нужен сетевой клиент.

Можно предоставить эти зависимости нашему Component либо через иерархию SubComponent, либо через Component Dependencies. Нас сегодня интересует второй вариант. 

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

Способ 1. Общий интерфейс

Мы создаём один общий интерфейс, в котором описываем все зависимости, которые могут понадобиться в текущем компоненте. 

interface FeatureComponentDependencies {

    fun provideDeviceInfoManager(): DeviceInfoManager
    fun provideDatabaseManager(): DatabaseManager
    fun provideNetworkManager(): NetworkManager
}

Далее просто указываем этот интерфейс в поле dependencies аннотации Component. 

@Component(
   dependencies = [
       FeatureComponentDependencies::class
   ]
)
internal interface FeatureComponent

Затем кто-то где-то создаёт реализацию этого интерфейса специально под наш Component, и при его создании подсовывает эту реализацию в Component.Builder или Component.Factory

Теперь о втором способе.

Способ 2. Набор интерфейсов

Мы создаём собственный интерфейс под каждый тип зависимостей: отдельный для информации об устройстве — CoreDeviceDependencies, отдельный для баз данных — CoreDatabaseDependencies и отдельный для работы с сетью — CoreNetworkDependencies. 

interface CoreDeviceDependencies {

    fun provideDeviceInfoManager(): DeviceInfoManager
}

interface CoreDatabaseDependencies {

    fun provideDatabaseManager(): DatabaseManager
}

interface CoreNetworkDependencies {

    fun provideNetworkManager(): NetworkManager
}

Далее указываем наши интерфейсы в поле dependencies аннотации Component. 

@Component(
   dependencies = [
       CoreDeviceDependencies::class,
       CoreDatabaseDependencies::class,
       CoreNetworkDependencies::class,
   ]
)
internal interface SomeComponent

Реализации этих интерфейсов создаются уже не конкретно под наш Component, а сразу под Component'ы всех потенциальных фичей. Затем просто подсовываем эти реализации при создании нашего компонента. 

И вроде всё хорошо, но всё меняется со временем…

Неиспользуемые зависимости

И наш Component тоже. Допустим, для FeatureComponent мы передумали отправлять информацию об устройстве на сервер. Теперь мы просто собираем информацию и храним её. Мы удалили код, отвечающий за отправку, а вот про объявление CoreNetworkDependencies в нашем FeatureComponent благополучно забыли. Понятно, что для фичи, которая делала буквально три действия, это странно, но в реальном приложении фичи обычно куда более крупные, так что забыть удалить какую-либо Component Dependency очень и очень просто.

Помимо того, что у нас в целом немного замусоривается наш FeatureComponent, в многомодульном приложении из-за этого могут оставаться лишние связи между модулями. Это сказывается на: 

  • Времени холодной сборки. Из-за лишних связей могут образовываться бутылочные горлышки среди модулей.

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

Да и в целом, как-то не по-программистски это — оставлять код, который ничего не делает. Поэтому очевидно, их надо как-то находить для последующего удаления. К сожалению, Dagger из коробки ничего такого делать не умеет. Зато он предлагает нам Dagger SPI, который предоставляет нам собранный граф, с помощью которого реализовать поиск лишних Component Dependencies не проблема. 

Поиск

Уточню, что поиск мы будем делать на kapt, так как версия Dagger с KSP вышла недавно и там встречаются баги. Да и честно говоря, в нашем плагине для Dagger SPI с приходом KSP поменяется не так и много. 

Ах да. Поиск мы будем реализовывать для второго способа, который — набор интерфейсов. Всё дело в том, что код поиска неиспользуемых зависимостей для второго способа включает в себя код для первого способа. А повторяться не очень хочется.

Dagger SPI

Итак, этот Dagger SPI, как он работает вообще?

Всё достаточно просто. Для начала создаём новый библиотечный (library) модуль и вместо плагина id("com.android.library") подключаем kotlin("jvm").

В зависимостях добавляем Dagger, Dagger SPI и AutoService. Получится примерно так:

plugins {
   kotlin("jvm")
   kotlin("kapt")
}

dependencies {
   implementation("com.google.dagger:dagger:2.44")
   implementation("com.google.dagger:dagger-spi:2.44")
   compileOnly("com.google.auto.service:auto-service:1.1.1")
   kapt("com.google.auto.service:auto-service:1.1.1")
}

BindingGraphPlugin

В новом модуле создаём новый класс, в нашем случае это будет DaggerUnusedValidator, наследуем его от BindingGraphPlugin и вешаем аннотацию @AutoService(BindingGraphPlugin::class). Эта аннотация позволит Dagger SPI находить все свои плагины.

@AutoService(BindingGraphPlugin::class)
class DaggerUnusedValidator : BindingGraphPlugin {

   override fun visitGraph(
       bindingGraph: BindingGraph,
       diagnosticReporter: DiagnosticReporter
   ) {
   }
}

В методе visitGraph мы видим два параметра: bindingGraph и diagnosticReporter. Первый содержит в себе граф зависимостей, а второй отвечает за удобное логирование проблем с элементами графа.

Важно, что метод visitGraph будет вызываться на каждый ваш отдельный Component и Module, возможно, даже не один раз, так как в кодогенерации может быть несколько раундов. Отдельные вызовы visitGraph логичны, ведь, с точки зрения Dagger, каждый отдельный Component и Module — это отдельные графы зависимостей, которые мы уже потом стыкуем между собой. 

Осталось подключить наш модуль к каждому модулю, который использует Dagger. 

"kapt"(project(":kapt_validate_dagger_deps"))

Теперь при сборке проекта, после генерации кода Dagger, в метод visitGraph будет приходить готовый граф зависимостей.

Но пока наш метод ничего не делает. Пора бы это исправить! 

Ищем компоненты

Для начала, само собой, нам нужно достать Component.

Делается это очень просто. У BindingGraph уже есть специальный метод rootComponentNode. Но, как я писал выше, помимо «реальных» Component в метод visitGraph может прийти ещё и Module.
Чтобы их отличать, воспользуемся флагом isRealComponent. 

val currentComponent = bindingGraph.rootComponentNode()
if (!currentComponent.isRealComponent) {
    return
}

По названию isRealComponent понятно, что этот флаг будет true как раз таки у «реальных» Component. Что нам и нужно.

Нужный Component мы получили, теперь надо как-то выколупать из него Component Dependencies.

Пытаемся достать зависимости

К сожалению, Dagger SPI возвращает нам уже собранный граф. Это означает, что неиспользуемых Component Dependencies в нём уже быть не может. Поэтому придётся немного заморочиться, чтобы их достать.

Но! Мы можем получить TypeElement нашего Component через методы componentPath().currentComponent().

TypeElement — это что-то вроде Class в терминах кодогенерации kapt. Ведь на этапе кодогенерации классов как таковых не существует, они появляются в Runtime. (Что, кстати, позволяет обращаться к классам Runtime самого кодогенератора.)

Поэтому план прост: получаем TypeElement нашего Component, у него получаем аннотацию Component, у которой, в свою очередь, обращаемся к полю dependencies. Звучит просто, получится как-то так: 

val currentComponent = componentNode.componentPath().currentComponent()
val dependencies = currentComponent.getAnnotation(Component::class.java).dependencies

А вот и фигушки! Такой код работать не будет. При попытке обратится к полю dependencies мы получим MirroredTypeException.

Проблема с MirroredTypeException

Эта ошибка возникает из-за того, что в поле dependencies аннотации Component указан тип Class[]. Как я уже упоминал ранее, во время кодогенерации классы не существуют. Если бы аннотация Component содержала массив строк с именами классов вместо массива Class, такой проблемы не возникло бы. Если вам интересно, вы можете прочитать об этом явлении подробнее в этой статье.

И вы можете возразить: «Но Dagger как-то справляется с этим!». Это верно, факт того, что Dagger вообще работает, говорит о том, что существует способ обойти эту проблему. Однако этот способ немного... уродский. Надеюсь, с приходом KSP это исправят.

Теперь план не такой простой, поэтому разобьём его на два этапа: 

  1. Находим метод Dependencies

    1. Получаем все аннотации через annotationMirrors.

    2. Находим среди них аннотацию Component.

    3. Находим у неё метод dependencies.

  2. Получаем типы dependencies

    1. Получаем из метода список Component Dependencies как AnnotationValue.

    2. Извлекаем из AnnotationValue значение, которым будет DeclaredType изначального класса.

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

Начнём с первого этапа плана.

Этап 1. Находим метод Dependencies

Не забываем о том, что не все Component'ы обязаны иметь Component Dependencies. Так что вполне нормально, если у какого-то Component не будет объявленного метода dependencies. Сразу закладываемся на то, что нам может прийти null.

Дальше следуем плану: получаем все аннотации через annotationMirrors, в них находим аннотацию Component, а у неё находим метод dependencies, у которого и попытаемся получить значение. 

private fun getDependenciesMethod(
   componentNode: BindingGraph.ComponentNode
): AnnotationValue? {
   val component = componentNode.componentPath().currentComponent()
   val annotationMirrors = component.annotationMirrors
   val componentAnnotationMirror = annotationMirrors
       .first { it.annotationType.toString() == Component::class.java.name }
   val dependenciesMethod = componentAnnotationMirror.elementValues.entries
       .firstOrNull { it.key.simpleName.toString() == Component::dependencies.name }
   return dependenciesMethod?.value
}

Отлично, значение поля dependencies у нас есть. Теперь приступаем ко второму этапу плана.

Этап 2. Получаем типы dependencies

Мы точно знаем, что значение поля dependencies в коде — это массив классов, что на языке annotationMirror значит List<AnnotationValue>. Так что смело его кастим и извлекаем значение AnnotationValue, тип которого нам тоже известен. Поэтому опять очень смело кастим значения к DeclaredType.

Ах да, нельзя забывать и о том, что у компонента может и не быть Component Dependencies. Следовательно, не забываем добавить проверку на null. 

private fun getComponentDependencies(
   componentNode: BindingGraph.ComponentNode
): List<DeclaredType> {
   val dependenciesMethod = getDependenciesMethod(componentNode)

   return if (dependenciesMethod != null) {
       (dependenciesMethod.value as List<AnnotationValue>)
           .map { it.value }
           .map { it as DeclaredType }
       componentDependencies
   } else {
       emptyList()
   }
}

Наконец-то! Мы получили список наших Component Dependencies. Проблема с MirroredTypeException побеждена!
Продолжаем, теперь надо извлечь из них все зависимости в удобном формате. 

Получаем все типы

Для этого будем работать с каждой из Component Dependencies поочерёдно. Найдём все методы у Component Dependency и получим тип возвращаемого ими значения. 

private fun getMethodsReturnTypes(declaredType: DeclaredType): List<String> {
    val methods = declaredType.asElement().enclosedElements
        .filter { it.kind == ElementKind.METHOD }
        .map { it as ExecutableElement }
    val returnTypes = methods
        .map { it.returnType.toString() }
    return returnTypes
}

Ну и теперь собираем всё воедино. Находим типы Component Dependencies и собираем их в словарь из Component Dependency и списком типов (зависимостей), которые она предоставляет. 

private fun getDependencies(
    componentNode: BindingGraph.ComponentNode
): Map<String, List<String>> {
    val dependenciesTypes = getComponentDependencies(componentNode)
    val dependenciesMap = dependenciesTypes
        .associateWith { getMethodsReturnTypes(it) }
        .mapKeys { it.key.toString() }
    return dependenciesMap
}

Самое сложное позади. У нас есть список зависимостей, которые могут предоставить Component Dependencies, указанные в Component. Теперь надо получить список зависимостей, которые нужны нашему Component. 

Достаём список необходимых зависимостей

На удивление, это очень просто. Dagger SPI сразу предоставляет нам отдельный список используемых зависимостей через метод bindings.

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

val bindings = bindingGraph.bindings()
    .map { contextBinding -> contextBinding.key().type().toString() }

Отлично! У нас есть список необходимых зависимостей, осталось сравнить его с тем, что предоставляет нам конкретная Component Dependency. 

Осуществляем поиск

Пробежимся по списку Component Dependencies и посмотрим, сколько из представленных ей зависимостей не используется. Если все, то смело объявляем «анафему» такой Component Dependency, ведь мы точно знаем, что ни один её метод не пригодился, а значит, она не используется и может быть удалена. 

dependencies.forEach { (dependency, dependencyMethods) ->
    val unusedMethods = dependencyMethods.subtract(bindings)
    if (unusedMethods.size == dependencyMethods.size) {
        diagnosticReporter.reportComponent(
            Diagnostic.Kind.ERROR,
            currentComponent,
            "Dependency ${dependency} is unused"
        )
    }
}

На этом всё. Попробуем собрать проект, и на этапе кодогенерации Dagger выдаст нам ожидаемую ошибку. 

SomeComponent.java:8: error: 
[ru.cian.validate.dagger.deps.unused.DaggerUnusedValidator] 
Dependency CoreNetworkDependencies is unused

Если кому-то интересен полный код, то его можно подглядеть в Gist.

Что касается нашего проекта, то таких забытых Component Dependencies оказалось много. Удалив все неиспользуемые зависимости, мы уменьшили количество ненужных связей между модулями. В этом нам помог Dependency Analysis Gradle Plugin, за что ему спасибо. 

В основном неиспользуемые Component Dependencies были в старых фичах, которым 3 и более года, тогда как в новых фичах неиспользуемые Component Dependencies почти не встречались.

Что ещё можно сделать с SPI?

Скорее всего, у вас возникнет мысль, что находить неиспользуемые Component Dependencies — это, конечно, весело, интересно, полезно и в целом весьма благородно, но вот затаскивать Dagger SPI ради одной такой фичи как-то перебор.

Это весьма справедливо. Поэтому я взял на себя смелость представить, для чего ещё может понадобиться Dagger SPI. С ходу мне в голову пришли следующие варианты: 

  • Аналитика

    • Визуализация и анализ графа зависимостей. Большинство визуализаторов графа Dagger используют Dagger SPI для своей работы. Визуализация графа зависимостей приятна для глаз, но ещё более полезной может быть возможность сохранить граф зависимостей в отдельный файл. Это позволит провести более сложный анализ графа с помощью внешнего скрипта. 

    • Метрики здоровья Dagger. Можно собирать данные о том, насколько хорошо у вас используется Dagger. Например, количество всех зависимостей конкретного Component. Если их стало слишком много, то это повод завести задачу на рефакторинг. 

  • Валидация

    • Использование Qualifier. Можно обязать разработчиков использовать Qualifier для базовых типов, например, Int или String. Это избавит вас от проблем, когда вы хотели получить одну строку, а получили совсем другую. 

    • Запрет использования. Можно запретить прокидывать в Dagger напрямую классы, которые склонны вызывать утечки памяти, вроде Activity, Fragment, View и т.п. 

    • Поиск лишних provide. Можно найти все provide-методы, которые предоставляют зависимости, которые никому не нужны. Ну и в дальнейшем их можно удалить. 

  • Улучшение логов

    • Dagger не всегда выдаёт понятные для новичка ошибки. Никто не мешает вам в случае их нахождения добавить в лог пояснения, как исправить их именно в вашем проекте.

На этом всё, но я думаю, можно придумать ещё много всего.

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