Всем привет, меня зовут Анатолий Спитченко, я Android-разработчик в ПСБ. В этой статье расскажу про свои эксперименты с Dagger. Наткнувшись в проекте на огромный модуль Application (11,5 Мб), я стал искать альтернативы обертке Dagger Android. Поэкспериментировал с продвигаемым Google Dagger Hilt, а также с более старым подходом — Dagger Components. Последний, как ни странно, позволяет немного сократить Application и в целом имеет больше плюсов, чем минусов. Подробности под катом.

Минусы Dagger Android

Однажды мне стало интересно, из чего состоит наш проект — в каком модуле больше всего кода. Я посчитал, сколько мегабайт занимают все Java и Kotlin-файлы в директории Build.

Оказалось, что больше всего кода в Application, который является своего рода точкой входа, собирающей все модули. Причем выглядит все довольно странно. В самом Application не более 500 строк кода, которые указывают, какие модули к нему подключаются. А внутри оказался огромный класс на 93 тыс. строк (11,5 Мб). 

Класс появился из используемой у нас обертки над стандартным Dagger — Dagger Android. Он генерирует интерфейсы Subcomponents в Gradle модулях, а реализации интерфейсов попадают в DaggerApplicationComponent. Именно так он вырос до таких нереальных масштабов.

У Java есть ограничение на максимальный размер метода — 65 тыс. строк. Но здесь используются вложенные классы, поэтому ничего плохого не происходит. Но то, что код неравномерно распределен по модулям, не очень хорошо. При параллельной сборке мы получаем бутылочное горлышко. Так что я начал искать способ бороться с ним.

Dagger Android

Когда я пришел на проект, Dagger Android здесь уже был. Могу предположить, что взяли его, потому что было не очень удобно взаимодействовать с обычным Dagger. У нас сложный жизненный цикл Android-компонентов:

  • Activity

  • ContentProvider

  • BroadcastReceiver

  • Fragment 

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

Важно учесть, что у нас есть иерархия этих объектов: сначала появляется ContentProvider, потом Application, и только затем стартуют остальные компоненты и выстраивают свой жизненный цикл. Это осложняет задачу.

Ранее мы брали базовый компонент и в нем прописывали функцию Inject.

class FrombulationActivity : AppCompatActivity() {
    @Inject
    lateinit var frombulator: Frombulator
    override fun onCreate(savedInstanceState: Bundle?) {
        // Важно выполнить инъекцию зависимостей первым делом
        (applicationContext as SomeApplicationBaseType)
            .applicationComponent
            .newActivityComponentBuilder()
            .activity(this)
            .build()
            .inject(this)
        super.onCreate(savedInstanceState)

Frombulator — вымышленный термин, здесь я его использую, чтобы показать, что существует какой-то о��ъект. Перед тем как вызвать super.onCreate, нужный компонент мы ищем в базовом классе в родительском Application. Далее инжектим свойство activity и можем использовать объект.

В Dagger Android дела обстоят иначе. Компоненты не знают про существование инжектора. Есть базовый метод Injection.inject, который по иерархии обходит все родительские классы. Он ищет родительские фрагменты, потом уходит на activity и на Application — ищет класс Injector по ключу компонента и выполняет инжекцию.

class FrombulationActivity : AppCompatActivity() {
    @Inject
    lateinit var frombulator: Frombulator

    override fun onCreate(savedInstanceState: Bundle?) {
        AndroidInjection.inject(this)
        super.onCreate(savedInstanceState)

        // Теперь можно использовать frombulator
        // Ваш основной код активити
    }
}

После этого можно использовать frombulator.

Есть и более короткий вариант, если нет никакого base activity, — можно отнаследовать Dagger от CompatActivity, и получится даже более красивый код.

class FrombulationActivity : DaggerAppCompatActivity() {
    @Inject
    lateinit var frombulator: Frombulator
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Теперь можно использовать frombulator
        // Ваш основной код активити
    }
}

Этот инжект произойдет где-то в super, и им можно будет сразу пользоваться. 

У такого подхода есть как плюсы, так и минусы.

Плюсы:

  • классы, в которые инжектим, больше не знают про конкретный инжектор;

  • мы уменьшаем бойлерплейт (не нужно явно писать Subcomponent — можно без них обходиться; и в модуле просто прописывать аннотацию. ContributesAndroidInjector, после чего у нас появляется иерархия). 

Минусы:

  • могут быть проблемы в многомодульном проекте — растет од��н большой класс;

  • мы увеличиваем бойлерплейт (нужно использовать специальные аннотации, которые есть именно у Dagger Android).

Этому решению есть альтернативы. Во-первых, Dagger Hilt — про него сейчас все говорят, и это предпочтительное решение, которое продвигает Google. Также есть более старый подход — Dagger Components, который вполне избавляет от минусов. Существуют и другие решения, но здесь я не буду их рассматривать, поскольку после появления в Android Studio плагина для Dagger, который подсвечивает, что заинжектилось, наверное, в этом нет смысла.

Dagger Hilt

Начнем с поиска плюсов у Dagger Hilt — решения, рекомендуемого Google.

Dagger Hilt патчит байткод таким образом, что не нужно лезть в него руками, наследоваться от DaggerActivity и так далее. Также не нужно привязывать руками модули к компонентам: все это происходит автоматически, главное — подобрать нужный скоуп. 

В этом случае тестовая activity будет выглядеть следующим образом:

@AndroidEntryPoint
class FrombulationActivity : AppCompatActivity() {
    @Inject
    lateinit var frombulator: Frombulator
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Теперь можно использовать frombulator
        // Ваш основной код активити
    }
}

Здесь мы ничего не наследуем, кроме как от CompatActivity. Но необходимо помнить о том, что следует поставить аннотацию @AndroidEntryPoint — без нее работать не будет. Все компоненты Android должны идти с аннотациями.

Рассмотрим, как все это устроено под капотом.

Здесь есть класс-посредник для inject. На этапе сборки появляются классы Hilt** (далее название activity или компонента). Они наследуются от базового класса, который прописан в коде, и между делом туда добавляется логика по инъекциям.

/**
 * A generated base class to be extended by the @dagger.hilt.android.AndroidEntryPoint annotated class. If using the Gradle plugin, this is swapped as the base class via bytecode transformation.
 */
@Generated("dagger.hilt.android.processor.internal.androidentrypoint.ActivityGenerator")
public abstract class Hilt_FrombulationActivity extends AppCompatActivity implements GeneratedComponentManagerHolder {
  private SavedStateHandleHolder savedStateHandleHolder;
  private boolean injected = false;
  Hilt_FrombulationActivity() {
    super();
    _initHiltInternal();
  }

  private void _initHiltInternal() {
    addOnContextAvailableListener(new OnContextAvailableListener() {
      @Override
      public void onContextAvailable(Context context) {
        inject();
      }
    });
  }

Все инжектится в нужный момент жизненного цикла.

Модули при этом выглядят довольно красиво. Для них достаточно выбрать, на каком скоупе представить.

@InstallIn(SingletonComponent::class)
@Module
class FrombulatorModule {
    @Singleton
    @Provides
    fun provideFrombulator() = Frombulator()
}

Здесь я использ��ю самый распространенный SingletonComponent. Можно использовать аннотацию скоупа @Singleton, и тогда Frombulator будет в единственном числе.

В данном случае DaggerApplicationComponent уже не будет. Вместо него появился аналог — класс DaggerApplication_HiltComponents_SingletonC с вложенными классами сабкомпонентами под разные скоупы:

  • ActivityCImpl

  • FragmentCImpl

  • ViewModelCImpl 

Минус в том, что эти глобальные субкомпоненты будут просачиваться в код. Возможно, это будет происходить не так сильно и раздуваться начнут другие классы, но так или иначе Application будет расти. В итоге Dagger Hilt упрощает работу с DI, но до конца не избавляет от проблемы огромного модуля Application. 

Dagger Components

Еще один способ обойти проблему — Dagger Components. В этом случае мы берем в проект чистый Dagger, то есть, если полностью перейти на этот подход, зависимость от Dagger Android можно удалить.

Подход подразумевает, что для каждой фичи (Fragment, Activity) мы выделяем отдельный Component, у которого можно явно указать зависимости, которые требуется передать. Получается довольно наглядно.

Приведу пример реализации. Здесь мы выделяем маркерный интерфейс ComponentDependencies — это не просто какой-то класс any, а именно наши зависимости. Для нашего кода мы делаем упрощение — мультибиндинг, что позволит соотнести эти ComponentDependencies для какого-то класса и сами объекты.

/**
 * Маркерный интерфейс для зависимостей Android-компонента (Активити/Фрагмент и т.д.)
 */
interface ComponentDependencies

typealias ComponentDependenciesProvider =
    Map<Class<out ComponentDependencies>, @JvmSuppressWildcards ComponentDependencies>

У нас также появится интерфейс, похожий на тот, что есть в Dagger Android. Это родительский компонент, который предоставляет реализацию данной мапы.

/**
 * Родительский Android-компонент, который содержит провайдеры для дочерних
 */
interface HasComponentDependencies {
    val dependencies: ComponentDependenciesProvider
}

Для удобства мы также можем ввести функцию, которая будет обходить все родительские фрагменты:

/**
 * Логика, аналогичная AndroidSupportInjection.inject
 *
 * 1. Происходит поиск всех родительских фрагментов, которые реализуют HasComponentDependencies
 * и добавление их dependencies в список
 * 2. К списку dependencies добавляются dependencies из активити, если реализован HasComponentDependencies
 * 3. Добавляются dependencies из Application, если реализован HasComponentDependencies
 * 4. Список мержится в Map<Class<out ComponentDependencies>, ComponentDependencies> для дальнейшего использования
 *
 * Дубли Class<out ComponentDependencies> в текущей реализации игнорируются
 * (выигрывает самый ближний к фрагменту компонент)
 */
fun Fragment.findComponentDependenciesProvider(): ComponentDependenciesProvider {
    val allHasComponentDependencies = mutableListOf<ComponentDependenciesProvider>()

    var current: Fragment? = parentFragment
    do {
        (current as? HasComponentDependencies)?.dependencies?.let(allHasComponentDependencies::add)
        current = current?.parentFragment
    } while (current != null)

    (activity as? HasComponentDependencies)?.dependencies?.let(allHasComponentDependencies::add)
    (activity?.application as? HasComponentDependencies)?.dependencies?.let(allHasComponentDependencies::add)

    val result = mutableMapOf<Class<out ComponentDependencies>, @JvmSuppressWildcards ComponentDependencies>()

    allHasComponentDependencies.reversed()
        .forEach(result::putAll)

    return result
}

Например, если мы находимся во Fragment, то ищем среди родительских фрагментов тот, у которого есть эти зависимости, затем переходим на Activity. Если там ничего не найдем, то идем дальше на Application. Это аналогично тому, что происходит в Dagger Android, просто использован немного другой интерфейс.

/**
 * Ключ для связывания дочернего и родительского компонента
 */
@MapKey
@Target(AnnotationTarget.FUNCTION)
annotation class ComponentDependenciesKey(val value: KClass<out ComponentDependencies>)

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

Рассмотрим конкретный пример. Из нашего кода я взял отдельный фрагмент списка валютных операций и переписал его на подход с компонентами, посмотрев, от чего он зависит:

interface OperationsListFragmentDependencies : ComponentDependencies {
    val currencyOperationRepository: CurrencyOperationRepository
}

Репозиторий CurrencyOperationRepository в данном случае должен быть в Singleton скоупе, поэтому его нужно получить извне.
Я удалил ContributeAndroidInject и уже явным образом добавил зависимость OperationsListFragmentDependencies и, конечно, функцию inject, чтобы можно было в поля заинжектить все необходимое:

(dependencies = [OperationsListFragmentDependencies::class])
 internal interface OperationsListFragmentComponent {
 fun inject(fragment: OperationsListFragment)
 }

Далее потребовалась связь между родительским и дочерним компонентом. Родительским в данном случае был Application. Я сделал так, чтобы он явно реализовал OperationsListFragmentDependencies, чтобы Dagger на этапе сборки подсказывал мне, все ли я указал в этом родительском компоненте.

Пришлось также выделить отдельный модуль под ComponentDependenciesKey — он необходим для мультибиндинга, чтобы можно было у Application запросить зависимости и заинжектить их в Fragment.

/**
 * Связывает ApplicationComponent с конкретными реализациями Dependencies
 */
@Module
internal interface ApplicationComponentDependenciesModule {
    @Binds
    @IntoMap
    @ComponentDependenciesKey(OperationsListFragmentDependencies::class)
    fun bindOperationsListFragmentDependencies(impl: ApplicationComponent): ComponentDependencies    …
}

@Singleton
@Component(
    modules = [
        AcquiringModule::class,
        MainScreenModule::class,
        BookkeepingModule::class,
        AcquiringOfficeModule::class,
        CurrencyOperationsModule::class,
        AndroidInjectionModule::class,
        ApplicationComponentDependenciesModule::class,
    ]
)
internal interface ApplicationComponent: ApplicationComponentDependencies {
    fun inject(app: App)
}

 Родительский компонент — наш App — выглядит следующим образом:

class App: DaggerApplication(), HasComponentDependencies {
    @Inject
    internal lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<DaggerApplication>

    @Inject
    override lateinit var dependencies: ComponentDependenciesProvider

    override fun applicationInjector(): AndroidInjector<out DaggerApplication> = dispatchingAndroidInjector

    override fun onCreate() {
        DaggerApplicationComponent.create()
            .inject(this)
        super.onCreate()
    }
}

У App есть ComponentDependencies. ComponentDependenciesProvider — это мапа с классом и объектом реализации зависимостей, через которую происходит связь дочерних и родительских компонент (так можно зависимость прокинуть с Application до самого низа). 

Вот как это можно использовать. Здесь я взял компонент, указал, что у него есть зависимость от родительского компонента, собрал его и заинжектил Fragment:

override fun onCreate(savedInstanceState: Bundle?) {
    DaggerOperationsListFragmentComponent.builder()
        .operationsListFragmentDependencies(findComponentDependencies())
        .build()
        .inject(this)
        super.onCreate(savedInstanceState)
}

К сожалению, здесь OperationsListFragment уже знает про то, что существует Dagger. Без этого не обойтись.

После того как я добавил компонент в репозиторий и закоммитил его, GitHub помог мне подсветить, что SubcomponentImpl пропал. На данном этапе DaggerApplicationComponent уменьшился на 136 строчек.

Я представил промежуточную реализацию. Если дальше продолжать переход и полностью перейти на компоненты, то DaggerApplicationComponent уменьшится примерно в 5 раз по сравнению с вариантом Dagger Android.

У этого подхода, конечно, есть плюсы:

  • в каждом модуле у нас появляется свой граф зависимостей. Если потребуется, это упростит переезд на отдельные репозитории;

  • стало проще создавать SampleApp. Без этого подхода требуется замокать все, что есть в приложении (все зависимости, которые коннектятся к DaggerApplication), а это боль;

  • есть возможность использовать DynamicDelivery — это подход, при котором приложение выкладывается в Store, а то, чего не хватает, докачивается по ходу дела. Это будет полезно, если приложение еще не выгнали из Google Play. С Dagger Hilt и Dagger Android это невозможно, надо использовать чистый Dagger;

  • меньше абстракций (в том числе специфичных для Android аннотаций @ContributesAndroidInjector, AndroidInjection и так далее);

  • легкость интеграции с Dagger Android. Если приложение целиком пронизано Dagger Android, то полностью перейти на Dagger Hilt будет тяжело. 

Но есть и минус:

  • приходится писать руками явно интерфейс Dependencies — что конкретно необходимо компоненту.

Однако:

  • Dagger не даст собрать проект и предупредит, если не хватает зависимостей в Dependencies;

  • если указать лишнюю зависимость, IDE ее подсветит и лишний код не генерируется.

В целом выбор подхода с Dagger влияет на скорость разработки. И по идее, если мы каждый раз не будем пересобирать огромный Application, горячий билд ускорится.

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