У нас в проекте с незапамятных времён для 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)
Jeffset
00.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 генерит мусор, просто не использует его