Всем привет, меня зовут Анатолий Спитченко, я 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 — я написал утилиту, которая показывает объем кода в Мб.
