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

Внедряем

Собственно, изначальный план следующий:

  1. Внедряем Yatagan просто как замену Dagger. Замеряем.

  2. Внедряем использование рефлексии для debug сборок. Замеряем.

  3. Внедряем 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 решения. 

По факту для нас полезны два вида перехода: 

  1. Внедрение просто как замена Dagger.

  2. Внедрение с KSP и рефлексией. 

Второй вариант пока точно не реализуем с раскрытием всего своего потенциала, так как отказываться от kapt — дело явно не быстрое и не простое. 

Первый вариант, конечно, уменьшает время сборки на 17%, но несёт в себе большой спектр работ, и как-то нужно доработать библиотеку под нас, чтобы не писать Component.Builder, а также есть некоторые риски и нюансы:

  • Библиотека пока не популярная, могут быть проблемы с поддержкой, мало документации.

  • Сильно улучшилось время именно холодной сборки и это хорошо для CI. Горячая же, а именно её ежедневно по сотне раз собирают разработчики, ускорилась не так сильно – на 5%. А в абсолютных цифрах это вообще 3 секунды.

  • Есть опасения, что «овчинка выделки не стоит». Лучше дождаться, пока библиотека «подсохнет». Есть риск собрать грабли, которые сведут на нет все преимущества от ускорения сборки.

  • Время сборки нас сейчас не сильно тревожит, так как минута — неплохой результат. Иногда установка APK на телефон с micro-USB занимает больше времени.

  • Риски со стороны соискателей при поиске работы. Dagger и так не самая простая библиотека, а тут ещё и просто похожее решение.

И по итогу мы решили отложить решение по внедрению до конца года. Там посмотреть на ситуацию и если что-то улучшится, запланировать на следующий год.

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

Комментарии (5)


  1. kirich1409
    00.00.0000 00:00

    Очень не хватило показателей сколько будет строк кода сгенерировано Yatagan по сравнению с Dagger, а также размер финального релизного APK


    1. princeparadoxes Автор
      00.00.0000 00:00
      +4

      К сожалению количество строк сгенерированного кода не считал, а вот по размеру сборки подскажу. Для 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 кода.


      1. kirich1409
        00.00.0000 00:00

        Да, все верно про опции, но даже с fastInit генерит мусор, просто не использует его


  1. 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. Поддержку и развитие не планируем прекращать, так как сами живем на этом в продакшене. И очень рады новым интеграторам!

    Еще раз спасибо за статью. Если остались какие-то вопросы, пиши!


    1. Jeffset
      00.00.0000 00:00
      +1

      issue#23 на подумать про то, как не писать лишние билдеры.