Всем привет, меня зовут Анатолий Спитченко, я 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, горячий билд ускорится.
Полезные ссылки
Dagger Android — документация;
Многомодульность и Dagger 2. Лекция Яндекса — статья с подробным описанием подхода Dagger Components (основной упор на скорость сборки и пересборки проекта);
Ветка с переводом тестового проекта на Component — здесь видно, что удалилось, а что добавилось;
Визуализация кода из build — я написал утилиту, которая показывает объем кода в Мб.
