
У нас в проекте с незапамятных времён для DI используется Dagger. И в целом он нас всем устраивает. Ну, разве что кроме одного маленького пунктика — скорость сборки с kapt. Он прилично увеличивает время сборки. Казалось бы, смирись, страдай, прими ситуацию. Но относительно недавно Yandex представили библиотеку для Dependency Injection. Имя ей Yatagan. У неё есть две важных особенности — она спроектирована быть похожей на Dagger по API, и одна из её целей — меньше влиять на время сборки. Меньшее время сборки — это всегда хорошо, а значит, стоит её как минимум попробовать.
А как сейчас?
У нас среднего размера проект, и я уже как-то писал про измерения времени сборки в нём. Методология измерений и сценарии будут оттуда же, почти полностью. Кратко:
- В качестве инструмента измерения выступает Gradle Profiler. 
- 6 прогонов. Первый для «прогрева», а потом берём медианный из оставшихся пяти. 
- 
Сценария два: - Холодная сборка. Делаем clean assembleDebug --no-build-cache. 
- Горячая сборка. Изменяем один файл в фичёвом модуле, в котором есть Dagger, и делаем assembleDebug. 
 
- Включён Configuration Cache, так как хочется оценить влияние именно на build составляющую. 
- Проект: 479 Gradle модулей, 813 тысяч строк Kotlin, 53 тысячи строк Java и 136 тысяч строк XML. 
- И самое главное в нём — 528 Dagger компонентов. 
Перед началом внедрения стоит понять, как обстоит ситуация сейчас. Замеряем время сборки с Dagger 2.

Не слишком быстро, но и не слишком долго. Нас в целом устраивает.
А в чём суть?
Yatagan позиционирует себя как замену Dagger2. У него очень схожее API. На первый взгляд совпадает процентов на 90%. Зачастую достаточно автозаменой просто поменять package в импортах.
Так за счёт чего вообще планируется получить уменьшение времени сборки? Есть несколько факторов:
- Yatagan не генерирует Factory для всех зависимостей в отличие от Dagger. Меньше кода — быстрее работа и быстрее генерация. 
- Yatagan имеет возможность работать без кодогенерации вообще. То есть DI полностью работает на рефлексии. Профит в том, чтобы для debug сборок, которые разработчики собирают во время собственно разработки, использовать рефлексию, а для билда для тестировщиков и релиза — уже кодогенерацию. 
- Yatagan имеет два способа кодогенерации: ksp и kapt. KSP в теории должен генерировать быстрее, так как у kapt есть неприятная особенность — он генерирует так называемые стабы. Это отдельная задача, которая сама по себе отнимает много времени сборки. 
Очень важный плюс Yatagan, что не нужно сразу переходить на использование рефлексии и KSP. Можно переходить мягко и гладко. Сначала просто перейти на Yatagan с kapt, потом запариться с рефлексией и в самом конце уже распробовать KSP.
Поэтому я решил быстро и на коленке попробовать внедрить Yatagan, померить потенциальный профит, понять основные проблемы, обсудить с командой, и если всё понравится, внедрять на уровне компании.
Внедряем
Собственно, изначальный план следующий:
- Внедряем Yatagan просто как замену Dagger. Замеряем. 
- Внедряем использование рефлексии для debug сборок. Замеряем. 
- Внедряем KSP для кодогенерации. Замеряем. 
Далее я буду рассказывать в основном о проблемах внедрения. Это не значит, что библиотека плохая или сложная. Просто хочется рассказать, с какими проблемами вы можете столкнуться при миграции.
Ну и, естественно, я опишу только наш путь. Поэтому некоторые проблемы будут весьма специфичны. А с некоторыми проблемами, которые могут возникнуть у вас, я не столкнулся.
Внедряем просто как замену
Начнём с самого первого шага и очевидного шага — в build.gradle поменять зависимости с Dagger на Yatagan. У нас все зависимости и их версии описаны в отдельном файле, а в builde.gradle подключается константа. Так что этот шаг для меня заключался в смене значения двух строк. Начало позитивное.
Дальше просто последуем инструкции Migration from Dagger с Github страницы библиотеки.
В ней довольно простые шаги. Где-то нужно поменять package у импортов, а где-то поменять имена аннотаций. Главное, что объединяет почти все пункты — их можно сделать обычной глобальной автозаменой в Android Studio.
Почти все, кроме одного… Самый вредный и плохой пункт «Replace DaggerMyComponent.builder() with Yatagan.builder(MyComponent.Builder::class.java) or similar.».
Когда с ходу читаешь гайд по миграции, этот пункт не особо бросается в глаза. В чём суть? Когда вы написали какой-либо Component, например:
@Component(
   dependencies = [
       RouterDependencies::class
   ],
   modules = [
       CrashModule::class
   ]
)
interface CrashComponentТо Dagger заботливо генерирует для вас не только DaggerCrashComponent, но и класс Builder, который нужен для создания компонента. Для обращения к сгенерированному Builder достаточно вызвать статический метод builder у сгенерированного компонента.
fun build(routerDependencies: RouterDependencies, crashModule: CrashModule) {
   DaggerCrashComponent.builder()
       .routerDependencies(routerDependencies)
       .crashModule(crashModule)
       .build()
}Yatagan же требует иного подхода, чтобы у каждого компонента был руками прописан интерфейс с аннотацией Component.Builder, на основе которого и будет сгенерирован Builder.
@Component(
   dependencies = [
       RouterDependencies::class
   ],
   modules = [
       CrashModule::class
   ]
)
interface CrashComponent {
   @Component.Builder
   interface Builder {
       fun routerDependencies(dep: RouterDependencies): Builder
       fun crashModule(mod: CrashModule): Builder
      
       fun build(): CrashComponent
   }
}У нас ни у одного из компонентов такого интерфейса нет. Мы всё время пользовались тем, что генерировал Dagger. Более того, у нас есть KSP генератор, который использует сгенерированные Dagger Builder’ы, чтобы не писать кучу boilerplate кода при создании экземпляра компонента.
Как я понял, требование интерфейса с аннотацией Component.Builder упирается в использование рефлексии. Так как не происходит генерации, а создавать Component по какому-то правилу надо.
Поэтому в рамках моего эксперимента мне пришлось по-быстрому накидать скрипт, который пробегает по всем файлам, содержащих в названии Component. И дальше на основе аннотации Component генерирует interface с аннотацией Component.Builder.
Быстрое и рабочее решение. Хотя оно мне не по вкусу. При внедрении в, так сказать, production код я бы сделал по-другому. Так как даже простой Dagger компонент начинает выглядеть странно:
@PerPresentationScope
@Component(
   dependencies = [
       RouterDependencies::class
   ],
   modules = [
       CrashModule::class
   ]
)
interface CrashComponent {
   fun inject(graph: CrashGraph)
   @Component.Builder
   interface Builder {
       fun routerDependencies(dep: RouterDependencies): Builder
       fun crashModule(mod: CrashModule): Builder
      
       fun build(): CrashComponent
   }
  
   companion object {
       fun build(
           routerDependencies: RouterDependencies, 
           crashModule: CrashModule
       ) {
           DaggerCrashComponent.builder()
               .routerDependencies(routerDependencies)
               .crashModule(crashModule)
               .build()
       }
   }
}Как будто один и тот же код про RouterDependencies и CrashModule повторили четыре раза. А ведь у нас есть компонент, у которого 61 зависимость. Если умножить на 4, то получится какое-то неприличное количество строк для DI-компонента. Это, конечно, исключительный случай, но всё же.
Поэтому в идеале стоит попробовать расширить Yatagan, добавив возможность работы с чем-то вроде менеджера зависимостей. Который бы работал только по информации из аннотации Component.
Помимо этого, пришлось перевести работу нашего KSP генератора на работу с Yatagan и interface Component.Builder.
Больше в проекте ничего красным не подсвечивалось, поэтому пробую собрать и… Конечно же, ошибка. Выявилась ещё пара проблем.
Во-первых, Yatagan не хочет работать с аннотацией Binds, если у неё есть Scope, сообщая, что надо ставить аннотацию Scope над конкретным классом или Provides. Dagger позволяет подобные выкрутасы. Поэтому я по-быстрому перенёс Scope на реализации.
Во-вторых, как было сказано выше, Yatagan не генерирует Factory. У нас в своё время Dagger Module на Java имели package local методы, и местами это осталось. Благо, что немного. С Factory это не вызывает проблем, так как Factory генерируется в этом же пакете. А вот если Factory нет, то надо либо перевести Dagger Module на Kotlin, где использовать модификатор internal, либо добавить модификатор public в Java коде. Я выбрал второй вариант, так как это быстрее.
После этого всё. Наконец-то заработало. Начинаем замерять.

Минус 5% для горячей сборки и аж минус 17% для холодной. Вполне отличный результат для замены всего одной библиотеки.
Но двигаемся дальше.
Внедряем рефлексию для debug
Теперь настало время попробовать внедрить рефлексию. По идее, всё должно было быть просто: заменяем зависимости в build.gradle. Вместо:
implementation("com.yandex.yatagan:api-compiled:1.1.0")
kapt("com.yandex.yatagan:processor-jap:1.1.0") Везде вставляем следующую конструкцию:
debugImplementation("com.yandex.yatagan:api-dynamic:1.1.0")
releaseImplementation("com.yandex.yatagan:api-compiled:1.1.0")
kaptRelease("com.yandex.yatagan:processor-jap:1.1.0")Звучит просто. С ходу, конечно же, ничего не заработало. Оказалось, что у нас в проекте Dagger не везде был подключен красиво и правильно. В некоторых модулях он был подключен явно не к месту, в других было подключение только kapt, а сама библиотека подтягивалась через api от других модулей, а в третьих было и implementation, и kapt. Ни сборке, ни в работе это не мешает, но это тот исключительный случай «Не думаю, что стоит заморачиваться с выносом этого в отдельное место. Маловероятно, что нам когда-то по-быстрому надо будет поменять DI на всё проекте разом».
Пришлось немного покопаться и в специальном модуле base-di, в котором у нас лежат разные примочки для DI, объявить:
debugApi("com.yandex.yatagan:api-dynamic:1.1.0")
releaseApi("com.yandex.yatagan:api-compiled:1.1.0")При этом из всех остальных модулей подключение библиотеки убрать, оставив только kapt.
kaptRelease("com.yandex.yatagan:processor-jap:1.1.0")Теперь всё завелось. Начинаем тесты.

Результат… Ну, такое… Не слишком впечатляет. И причина тут в том, что плагин kapt никуда не делся, а значит, стабы всё равно генерируются. По сути, просто ушла лишь генерация самого Yatagan, а он, видимо, и так очень быстрый.
Это было ожидаемо. Подключение через рефлексию даст максимальный профит в случае, если ваш проект на Java и вместо kapt вы используете annotationProcessor, ну или Dagger последняя библиотека, которая использует kapt. Я бы сказал, что такой подход является заменой Dagger Reflect, который уже давненько не поддерживается.
Поэтому данный этап для меня скорее был переходным к следующему шагу — внедрению KSP.
Используем KSP
Я прошёлся по проекту автозаменой и поменял kaptRelease на kspRelease. Ну и пришлось пробежаться по всем четырём с лишним сотням build.gradle файлов чтобы убрать kapt там, где он стал не нужен, и подключить KSP там, где он стал нужен. Также оказалось, что местами плагин kapt был подключен там, где и не был нужен. Видимо, неудачно скопипастили код build.gradle, в таким местах смело его убираем.
Это была муторная, но простая работа, во время выполнения которой я осознал страшный и неприятный факт: kapt всё ещё нужен большому количеству модулей. Библиотек также использующих kapt оказалось целый три:
- Realm. Остался у нас в паре модулей. Планируем в ближайшее время избавиться от него полностью. 
- Room. Сейчас у нас использует kapt. Он подключен в небольшом количестве модулей. Имеет KSP плагин, просто надо на него перейти. 
- Moxy. И вот это проблема. На новый ViewModel + Compose мы перешли недавно, а значит, 90% экранов всё ещё используют Moxy, а ему нужен kapt. 
Как следствие, избавиться от kapt удалось только в трети модулей, которые и не очень-то большие по количеству кода. Я, конечно, расстроился, но решил продолжить. Уж больно хотелось взглянуть на результаты.

Для горячей сборки результат действительно улучшился. Это из-за того, что модуль, в котором вносится изменение, написан уже на новой архитектуре и не содержит Moxy.
А вот для холодной… Результаты даже хуже, чем с kapt. Из-за использования Moxy стабы продолжают генерироваться для большей части кода. Так сверху ещё и KSP плагин добавили.
Чувство недосказанности
Возможно, на данном этапе вы подумали: «И это всё? Moxy не даёт использовать возможности KSP на полную и ничего не сделать? Ты с этим ничего не сделаешь, тряпка?». У меня на душе к этому этапу оставалось гаденькое чувство, что работа не закончена. Поэтому я решил попробовать симулировать ситуацию, когда у мы полностью избавились от kapt, либо кто-то сделал для неё KSP плагин.
Для этого просто выпиливаю весь kapt, кроме мест, где он необходим для Realm. Но это буквально пара модулей и без этого никак. Да, проект будет падать при запуске, но нам это и не важно в данной ситуации. Важно, что он соберётся и можно сделать замеры.
Поэтому автозаменой Android Studio заменяю подключение kapt на пустую строку, вспоминаю про Realm и возвращаю kapt в двух местах.
Теперь можно делать тесты. Результат получился следующим:

Для горячей, само собой, почти ничего не изменилось, а вот для холодной результаты весьма впечатляющие. На целых полторы минуты меньше, чем сборка с Dagger. Что в процентах значит, что сборка стала быстрее на четверть.
Что в итоге?
После моего маленького исследования мы собрались командой и обсудили внедрение Yatagan уже как production решения.
По факту для нас полезны два вида перехода:
- Внедрение просто как замена Dagger. 
- Внедрение с KSP и рефлексией. 
Второй вариант пока точно не реализуем с раскрытием всего своего потенциала, так как отказываться от kapt — дело явно не быстрое и не простое.
Первый вариант, конечно, уменьшает время сборки на 17%, но несёт в себе большой спектр работ, и как-то нужно доработать библиотеку под нас, чтобы не писать Component.Builder, а также есть некоторые риски и нюансы:
- Библиотека пока не популярная, могут быть проблемы с поддержкой, мало документации. 
- Сильно улучшилось время именно холодной сборки и это хорошо для CI. Горячая же, а именно её ежедневно по сотне раз собирают разработчики, ускорилась не так сильно – на 5%. А в абсолютных цифрах это вообще 3 секунды. 
- Есть опасения, что «овчинка выделки не стоит». Лучше дождаться, пока библиотека «подсохнет». Есть риск собрать грабли, которые сведут на нет все преимущества от ускорения сборки. 
- Время сборки нас сейчас не сильно тревожит, так как минута — неплохой результат. Иногда установка APK на телефон с micro-USB занимает больше времени. 
- Риски со стороны соискателей при поиске работы. Dagger и так не самая простая библиотека, а тут ещё и просто похожее решение. 
И по итогу мы решили отложить решение по внедрению до конца года. Там посмотреть на ситуацию и если что-то улучшится, запланировать на следующий год.
Само собой, для вас часть проблем может быть неактуальна. Тут лишь наша история, но я надеюсь, она поможет кому-то прикинуть наши результаты на свой проект и сэкономит время на собственные исследования, чтобы принять решение о переходе или полном отказе от этой идеи. Тут уж на ваше усмотрение.
Комментарии (5)
 - Jeffset00.00.0000 00:00+2- Большое спасибо за статью и детальные данные измерений! Рад, что получилось провести тестовую миграцию и подсветить возможные проблемы. В общем случае на любые проблемы, предложения и даже вопросы (тег - help wanted) можно смело заводить issue на github. Давай попробую пройтись по подсвеченным проблемам.- Насчет необходимости писать все билдеры/фабрики компонентов руками - Yatagan.builder(MyComponent.Builder::class.java). Да, к сожалению, так нужно делать в Yatagan. Это обсусловлено поддержкой рефлексии. Если ванильный Dagger и мог помочь нам и сгенерировать билдер самостоятельно, то рефлексия ничего генерировать не может. Ей обязательно нужен какой-то готовый интерфейс билдера, который она сможет динамически реализовать - такой же подход применяется и в dagger-reflect. А разрешать автогенерацию билдеров для kapt/ksp и ничего не делать для рефлексии - сломает возможность бесшовного переключения бэкендов. Если есть идеи, как упростить жизнь в этом моменте и не ломать совместимость кодогена и рефлексии - буду очень рад предложениям. Был упомянут "менеджер зависимостей", было бы интересно узнать подробнее.- Да, Yatagan не разрешает скоупы на - @Binds, так как по нашему опыту использования это больше приводит к ошибкам, чем дает профита. Вместо этого Yatagan разрешает иметь несколько скоупов на биндинге, чтобы избавить разработчика от надобности писать- @Bindsв разные скоупы.- Насчет того, что приходится делать некоторые сущности - publicиз-за того, что Yatagan отказывается работать с package-private сущностями. Пока это так, это частично обусловлено поддержкой рефлексии и отсутсвием сгенерированных фабрик. Есть issue#22, где будем пробовать ослабить требования- public/- internal, где это окажется возможно.- Вообще я очень рад, что KSP бэкенд завелся без проблем и показал хорошие результаты. У самого KSP есть неприятные проблемы, в том числе с моделированием Java-кода, так что на сложных конструкциях могут быть "приколы", которые стоит репортить. Потому статус поддержки KSP в Yatagan пока экспериментальный. - Напоследок добавлю, что документацию планируем доработать, проблемы с ней известны. Можно начать следить за issue#20. Поддержку и развитие не планируем прекращать, так как сами живем на этом в продакшене. И очень рады новым интеграторам! - Еще раз спасибо за статью. Если остались какие-то вопросы, пиши! 
 
           
 
kirich1409
Очень не хватило показателей сколько будет строк кода сгенерировано Yatagan по сравнению с Dagger, а также размер финального релизного APK
princeparadoxes Автор
К сожалению количество строк сгенерированного кода не считал, а вот по размеру сборки подскажу. Для debug сборки (без обфускации, AppBundle и прочего) размер такой:
- Dagger. Всего 240.6 Мб. Вес именно кода 73.3 Мб.
- Yatagan + kapt. Всего 239.1 Мб. Вес именно кода 71.8 Мб.
- Yatagan + Reflect. Всего 239 Мб. Вес именно кода 71.7 Мб.
- При использовании KSP вес примерно такой же, как и с Yatagan + Kapt.
Если что, размер релизной сборки после обфускации и AppBundle - 80 Мб.
По размеру сборки и кода понятно, что Yatagan у нас генерирует где-то 100 Кб кода, а Dagger 1.5 Мб. Так что, где-то в 15 раз разница по количеству генерируемого кода.
Ну и у нас для Dagger проброшенны аргументы formatGeneratedSource как disabled и fastInit как enable. По идее, оба флага могут чуть уменьшать размер сгенерированного Dagger кода.
kirich1409
Да, все верно про опции, но даже с fastInit генерит мусор, просто не использует его