Яндекс выпускает Yatagan — опенсорс-фреймворк для внедрения зависимостей, позволяющий ускорить сборку

в 6:59, , рубрики: dagger, dagger 2, dependency injection, di, github, kotlin, open source, Блог компании Яндекс, внедрение зависимостей, гитхаб, разработка мобильных приложений, Разработка под android
Яндекс выпускает Yatagan — опенсорс-фреймворк для внедрения зависимостей, позволяющий ускорить сборку - 1

Меня зовут Фёдор Игнаткевич, я делаю приложение Яндекс и мобильный Яндекс Браузер для Android. Примерно год назад я предложил команде идею фреймворка для внедрения зависимостей, который более чем вдвое ускорил сборку обоих проектов и который мы сегодня выложили на Гитхаб — чтобы разработчики других приложений тоже могли улучшить скорость сборки. Я с нуля реализовал фреймворк, а затем мы вместе с командой интегрировали его в проекты и сейчас активно используем.

Как раз про свой опыт разработки я и хочу рассказать. Давайте попробуем разобраться, какие есть факторы замедления сборки, как Yatagan, совместимый с Dagger по API, с ними справляется и какие ещё задачи могут стоять перед DI-фреймворком — например, в части зависимостей под рантайм-условиями. Кстати, нативная поддержка этих зависимостей в Yatagan избавила нас от ручной обработки состояний A/B-экспериментов в DI.

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

Специфика проекта

Чтобы полностью понять технические решения, которые я принимал при разработке, предлагаю сначала слегка погрузиться в специфику проекта, для которого Yatagan проектировался изначально. Наш проект имеет примерно 150 Gradle-модулей, 2 млн LoC на Java/Kotlin и богатую «историю». Проект большой, и проблемы скорости сборки для него стоят остро.

Продукты и эксперименты

Из кодовой базы проекта могут собираться несколько приложений, каждое со своей спецификой. Также в нём обильно используются A/B-эксперименты для проверки гипотез и оценки влияния тех или иных изменений на ключевые метрики приложений.

Для обработки состояний экспериментов мы применяем следующую структурную модель: части кода в программе могут быть включены или выключены по условию, которое может быть известно уже во время сборки (статически) или вычисляется уже во время выполнения программы (динамически).

  1. Статические условия возникают из возможности собираться в те самые несколько конечных проектов и необходимости менять поведение для некоторых из них.
  2. Динамические условия обуславливаются состоянием A/B-экспериментов на клиенте в момент времени.

Такие условия могут влиять на всё: делать тонкую настройку параметров алгоритма или включать целую фичу для пользователя. Их эффект может быть локализован или же, наоборот, размыт по всему коду.

В контексте использования DI в Java/Kotlin эта модель реализовывается так: условно включаемые части кода — это классы, которые включены в DI-графы. Присутствие/отсутствие определённого класса в DI-графе обуславливается определённым флагом — включен ли определённый эксперимент (динамическое условие). Статические условия выражаются в том, включен ли определённый класс в специфичный для приложения DI-граф или нет.

От рефлексии к кодогенерации: минутка истории DI в проекте

Кратко пройдёмся по истории DI в проекте, чтобы понять, каким образом и с какой стороны мы пришли к Dagger. Dagger имеет непростой API, который позволяет организовывать DI разными способами. Я бы скорее отнёс это к минусам, так как опыт показывает, что действительно правильно организовать на нём DI достаточно сложно, а перекраивать уже однажды написанный DI-код для больших проектов будет дорого и больно.

Для справки: JSR-330 — стандарт DI для Java

Многие DI-фреймворки для Java, например Spring или Dagger, частично используют стандарт JSR-330. Он регламентирует основные аннотации, которые должен использовать DI — @Inject, @Scope, @Qualifier, и описывает базовые контракты, которым совместимый DI должен следовать.

Dagger использует аннотации из JSR-330 и реализовывает контракты поведения из стандарта, за исключением некоторых не очень важных деталей.

1. Рефлексия — IoContainer

Проект разрабатывается довольно давно, с 2013 года, и свой путь начинал ещё в «додаггерные» времена, когда уже был Square/Dagger, но не было Google/Dagger. Первая рефлексийная версия даггера тогда не была популярна, и ведущие умы в команде приняли решение написать свой простой по функционалу DI-фреймворк — IoContainer. Это классический сервис-локатор, в котором требовалось явно регистрировать все классы, участвующие в DI.

Пример класса:

public class MyImpl implements MyApi {
    @Inject public MyImpl(
            Activity context,  // Обычная (прямая) зависимость
            Optional<FeatureSpecific> optionalDetail  // Опциональная зависимость
    ) {}
}

Пример регистрации на старте Android-приложения:

registar.register(Activity.class, activityInstance);  // Готовый экземпляр класса
registar.register(MyApi.class, MyImpl.class); // Регистрация на интерфейс aka @Binds
if (myFeatureA || myFeatureB) {
    // Регистрация под условием
    registar.register(FeatureSpecific.class);
}
IoContainer.complete(registar);  // Завершает регистрацию

И использование:

IoContainer.resolve(context, MyApi.class)  // отдаёт созданный MyImpl
IoContainer.resolveOptional(context, AnyUnregisteredClass.class)  // Отдаст Optional.empty()

Граф зависимостей в IoContainer по своей природе динамический, опциональные зависимости можно делать очень просто: нет регистрации, нет класса. Кодогенерации тоже нет никакой — сборка быстрая. Реализация, грубо говоря — иерархия словарей с обслуживающим кодом — просто и сердито. За годы использования этот инструмент оптимизировали, и в итоге он стал весьма производительным.

Основной недостаток такого подхода — нет проверки, что реальный граф валиден при всех условиях. Если класс запрашивает зависимость, а зависимость по каким-то причинам не занесли в контейнер, то будет выброшен Missing Dependency Exception. Автотесты и QA могут проверить только несколько конфигураций из комбинаторно большого числа возможных. Соответственно, альфа-версия приложения могла регулярно взрываться такими крэшами, что было очень неприятно.

2. Dagger — начало

Постепенно Dagger 2 становился популярным, и команда задумалась о миграции на него. Он решал основную проблему IoContainer — устранение Missing Dependency Exception, так как весь граф зависимостей проверялся во время сборки проекта. Также от него ожидали улучшения времени старта приложения.

Минусом было то, что Dagger не предоставляет никакой поддержки динамических графов. Если статические условия ещё можно кое-как выразить через @BindsOptionalOf (хотя мы так и не воспользовались этим), с динамическими условиями сложностей было больше. Самое лаконичное, что приходилось писать (в терминах примера про IoC), выглядело примерно так:

@Provides 
Optional<FeatureSpecific> optionalOfFeatureSpecific(Provider<FeatureSpecific> provider) {
    if (Features.myFeatureA.isEnabled() || Features.myFeatureB.isEnabled()) {
        return Optional.of(provider.get());
    }
    return Optional.empty();
}

И ещё рядом такой же биндинг на Optional<Lazy<FeatureSpecific>>, если кому-то нужен Lazy-вариант.

Но в таком виде никто не запрещал зависеть от FeatureSpecific-класса напрямую и случайно использовать его, даже если условия не выполнялись. Другие варианты оформления Dagger-модулей для симуляции условий так же имели свои минусы, в том числе не отличались компактностью.

В ретроспективе: как ещё можно делать опциональные зависимости в Dagger

Конечно, есть хорошая идея, как можно оформить архитектуру для решения таких задач. У опциональной функциональности необходимо выделить интерфейс и далее, помимо реальной, написать «заглушечную» реализацию этого интерфейса. И внутри одного @Provides-метода отдавать либо реализацию, либо заглушку, в зависимости от условия. Так внешний код не должен никак обрабатывать отсутствие/присутствие функциональности, и отпадает необходимость использовать Optional<T>.

То есть вместо кода из примера выше лучше делать следующим образом:

@Provides MyApi provideMyApi(Provider<FeatureSpecificApiImpl> implProvider) {
    if (...) return implProvider.get() else return new MyApiStub();
}

И насколько можно судить, такой подход — отличное решение для проекта, в котором умеренно используются условные части в DI. Главная сложность тут заключается в верном разделении таких интерфейсов под каждое условие и проектировании их API так, чтобы была возможность сделать noop-реализацию, которая будет соответствовать контрактам API.

Конкретно в нашем проекте, который местами полностью состоит из переключаемых частей с большим количеством условий, формулировать такие API-фасады и заглушки было бы очень нетривиальным рефакторингом с неопределённо большой стоимостью — сыграло роль IoContainer-прошлое, где использование Optional<T> было фактически бесплатным и весь код был усеян этими optional-зависимостями.

По мере перевода проекта на Dagger, DI-код было всё сложнее поддерживать из-за необходимости писать огромное количество таких «условных» биндингов. Пространство для ошибки было большим, а код условий — труднодоступным.

3. Dagger + Whetstone

Миграция на Dagger только началась, но проблемы с условиями уже были очевидны. Тогда я решил сделать фреймворк-компаньон Dagger, который будет уметь в наши runtime-условия (плюс ещё несколько наших хотелок) и генерировать вспомогательные модули для Dagger с кодом, как если бы мы сами писали его руками, но делать это безопасно. Называлось это счастье Whetstone. Не путайте с публичными инструментами с таким названием — наш никуда не публиковался.

Основой Whetstone был специальный Condition API, который позволял объявлять динамические условия прямо на биндингах и классах. Для начала нужно было объявить фичу — специальную конструкцию, помеченную @Condition-аннотациями. Например:

@AnyCondition(
   // Означает: у класса Features найти статический метод/поле MY_FEATURE, 
   // у полученного значения найти метод/поле isEnabled, и результат должен быть boolean.
   // Отрицания выражаются через '!' в начале строки.
   Condition(Features::class, "MY_FEATURE_A.isEnabled"),
   Condition(Features::class, "MY_FEATURE_B.isEnabled"),
)
annotation class FeatureAorB

@AllConditions(
   Condition(Features::class, "MY_FEATURE_A.isEnabled"),
   Condition(Features::class, "MY_FEATURE_B.isEnabled"),
)
annotation class FeatureAandB

Они позволяли закодировать любую булеву функцию в конъюнктивной нормальной форме. @Condition кодировал булеву переменную под отрицанием или без. Чтобы применить оператор «И», нужно было просто аннотировать фичу несколькими условиями (для Java) или применить @AllConditions (для Kotlin). Если нужен был оператор «ИЛИ», то применялась аннотация @AnyCondition. Использовать такую фичу можно было так:

@BindIn(SomeModule::class, condition = FeatureAorB::class)
class UnderAorB @Inject constructor(/*...*/)

@BindIn(SomeModule::class, condition = FeatureAandB::class)
class UnderAandB @Inject constructor(/*...*/)

SomeModule выглядел так:

@Module(includes = [WhetstoneSomeModule::class])  // Этот модуль как раз и генерировался Whetstone
interface SomeModule { /* */ }

После этого Whetstone собирал все @BindIn из проекта и группировал их по целевым модулям, для каждого из которых он и генерировал модуль-компаньон, где содержался весь boilerplate-код с биндингами условий.

Whetstone умел ещё несколько трюков:

  • Гарантировать, что каждый уникальный @Condition будет вычислен только один раз. Генерировались специальные холдеры, которые кэшировали условия.
  • Конструкции вида @BindsAlternatives binds(a: FeatureSpecificImpl1, b: FeatureSpecificImpl2, c: DefaultImpl): Api, которые позволяли привязывать к интерфейсу первый присутствующий в графе класс. То есть если присутствовал FeatureSpecificImpl1, то по запросу Api возвращался он, если нет, то FeatureSpecificImpl2. А если и для того условия не выполнялись, то брался DefaultImpl. Если даже последний вариант оказывался под условием, то весь Api получался под условием.
  • Прочие вещи, специфичные для DI нашего проекта, которые было несложно автоматизировать, например автоматические подписки на события. Мы не будем рассматривать их здесь, так как они в итоге не попали в Yatagan.

Но самое важное, что давал Whetstone, — это compile-time-гарантию, что зависимости между классами под условиями корректны, то есть класс не может напрямую зависеть от другого класса под несовместимым условием. Для этого внутри фреймворка был написан специальный код валидации условий в общем виде. Задача на проверку конкретной зависимости биндинга A от биндинга B сводилась к доказательству утверждения «Если условие биндинга A выполняется, то и условие биндинга B выполняется, для любых значений входных условий».

Для любителей формальной постановки задачи

Пусть

$F_a(x_1,..,x_n)mbox{ — условие биндинга A,}\ F_b(y_1,..,y_n)mbox{ — условие биндинга B,}\ mbox{где }x_i,y_j in C, C mbox{— множество всех уникальных условий Condition.}$

Тогда задача на проверку корректности прямой зависимости биндинга A от биндинга B сводится к проверке следующего булева выражения на истинность:

$bot F_a(x) rightarrow F_b(y)=top overline{F_a(x)} vee F_b(y)=bot F_a(x)wedge overline{F_b(y)}.$

Такое выражение — задача для nSAT-решателя.

В теории это NP-полная задача, которая решается с помощью Boolean Satisfiability Solver. В Whetstone использовалась не слишком замороченная реализация алгоритма DPLL. На практике проблем со временем выполнения алгоритма не было, ибо размерности были небольшие.

Пример зависимости под условием

Рассмотрим классы из примера выше:

  1. Класс UnderAandB может зависеть от класса UnderAorB, так как если верно выражение A && B, то выражение A || B тоже верно. В таких случаях можно писать прямую зависимость: class UnderAandB @Inject constructor(ab: UnderAorB, ...).
  2. Обратное неверно: класс UnderAorB не может напрямую зависеть от UnderAandB, так как из истинности A || B не следует истинности A && B. В таких случаях необходимо писать optional-зависимость: class UnderAorB @Inject constructor(ab: Optional<UnderAandB>, ...).

Как безопасно переносили условия из IoContainer в Whetstone

Для финальной миграции на Whetstone нужно было корректно перенести все условия из портянок регистраций IoContainer на @Condition Whetstone. Так как весь код регистрации был на Java, задачу я решил, написав временный код прямо внутри самого Whetstone, используя Tree API из javac. Условие вычислялось из кода IoC для каждой регистрации по окружающим её конструкциям if/else. Далее алгоритм сравнивал условие из регистрации и условие, выраженное в терминах @Condition, и выдавал ошибки, если условия не были эквивалентны. Этот инструмент на порядок упростил миграцию и не давал делать ошибки в процессе. Разумеется, пока мы делали миграцию, нашли неконсистентности в условиях, которые были допущены во времена IoContainer из-за отсутствия жёсткой валидации.

В итоге после успешной миграции мы получили гибридный DI-фреймворк, который не позволяет нам сделать ошибку в наших динамических условиях, генерирует код и даёт профиты по скорости старта приложения в релизной конфигурации в сравнении с рефлексией. Но ложкой дёгтя в бочке мёда стала сильно просевшая скорость сборки проекта. Давайте рассмотрим Dagger + Whetstone с технической стороны, чтобы понять, что привело к ухудшению скорости сборки и как мы можем это исправить.

Медленно, но верно, или Проблемы со скоростью сборки

Пожив некоторое время на Dagger + Whetstone, мы поняли, что что-то не так со скоростью сборки. Миграция происходила постепенно, поэтому никто не заметил резкого скачка, так что осознание приходило постепенно. После многочисленных жалоб на медленную сборку и нескольких рабочих встреч я начал исследовать, какие конкретно технические аспекты подобной конфигурации влияют на скорость сборки.

Далее разберём все моменты, которые я отметил в результате исследований. Начнём с очевидного.

Проблема 1: kapt

Если зайти в какой-нибудь офис мобильной разработки, где используют Dagger, остановить в коридоре случайного разработчика и спросить его, чем Dagger замедляет сборку, то он, вероятно, ответит, что это из-за kapt. И будет прав — kapt вынужден запускать предварительную компиляцию Kotlin в специальном режиме генерации заглушек.

Заглушки (stubs) — это Java-исходники, сгенерированные из Kotlin, но без кода внутри методов. Они нужны для того, чтобы классические AP (процессоры аннотаций), коим является Dagger, могли видеть код на Kotlin. На генерацию стабов требуется значительное время, так как при этом запускается компиляция Kotlin в специальном режиме, что довольно дорого. Это сильно влияет на холодную сборку и меньше — на инкрементальную. Ладно, необходимость использовать kapt — один из главных минусов Dagger, идём дальше.

Проблема 2: Сгенерированные классы-фабрики

Dagger на каждый класс с @Inject-конструктором и на каждый @Provides-метод генерирует специальную фабрику (MyClass_Factory). Это плохо по нескольким причинам:

  • Этих классов очень много, они сильно раздувают объём байткода в приложении (в debug-конфигурации, без proguard/r8), что может ухудшать старт приложения — нужно загружать гораздо больше классов.
  • Их нужно дополнительно компилировать, что негативно влияет на скорость сборки проекта.
  • Они часто видны в различных поисках и списках классов в IDE, что раздражает и мешает комфортной навигации по проекту.

Отдельно стоит отметить, что необходимость генерировать такие фабрики вынуждает пользователя включать Dagger в каждом модуле проекта, где присутствует хоть один @Inject/@Provides. Если этого не делать, то проект всё равно будет собираться и даже корректно работать, но есть нюанс в инкрементальной сборке. Система сборки Gradle поддерживает инкрементальную обработку аннотаций при выполнении определённых условий со стороны самого AP и проекта, который его включает. Для нас важно, что AP имеет право генерировать код только для тех элементов программы, которые находятся непосредственно в текущей единице компиляции, то есть в gradle-модуле. Если пытаться генерировать код для класса из библиотеки (а зависимые подпроекты уже фактически являются библиотеками для текущего), то Gradle выдаст warning про отсутствующие originating element и уведомит, что инкрементальная компиляция далее невозможна. Проблема именно в том, что если в зависимом модуле Dagger не включен, то он пытается догенерировать фабрики для классов из этого модуля в рамках текущего — Gradle такое не нравится. Но включение Dagger в каждом модуле, где есть хоть один @Inject, почти наверняка просадит сборку — нужно генерить ещё больше стабов и ждать AP. А если не удовлетворять систему инкрементальной обработки в Gradle, то мы пожертвуем инкрементальностью. Что ж, похоже на проблему о двух стульях грустно, но и это ещё не всё.

После всего вышеперечисленного я обнаружил вишенку на верхушке торта. Эти классы фабрик вообще никак вразумительно не используются, если Dagger работает в режиме dagger.fastInit (о нём можно почитать здесь). А именно этот режим рекомендуется для больших Android-приложений. Вот оно как! Зачем же Dagger всё ещё генерирует их? Вероятно, для бинарной совместимости. Если кто-то захочет использовать такие классы в зависящих проектах без fastInit, ему будут нужны эти фабрики, и Google, кажется, решил не ломать такой, хоть и странный, use-case. А может быть, на это есть и другие причины, ещё менее очевидные.

От этих фабрик получается много неприятных проблем, которые по факту оказались не нужны.

Проблема 3: Особенности реализации Whetstone

Whetstone состоял из двух почти независимых сущностей: генератора и валидатора. Генератор представлял собой процессор аннотаций для генерации кода для Dagger. Валидатор — плагин для Dagger, использующий Dagger SPI, чтобы проверять корректность условий для построенных Dagger-графов.

Работа генерирующей части Whetstone базировалась на предположении, что Dagger увидит результат её работы.

Для справки: как в apt/kapt обрабатываются зависимости между разными AP

В рамках JSR-269 (оригинальная спецификация обработки аннотаций для Java) порядок вызова AP не определён, и на него нельзя никак влиять. Вместо этого AP могут работать в несколько заходов — раундов. В каждый раунд обработки каждый AP вызывается системой и пытается выполнить свою работу. В случае обнаружения каких-то отсутствующих (unresolved) элементов в коде AP может прекратить работу, сообщив системе, что код неполный, и ему не хватает классов для решения своей задачи. Тогда система помечает этот AP как «ожидающий» и запускает оставшиеся AP в надежде, что они догенерируют нужный первому AP код. Как только все AP отработали и какие-то из них остались «ожидающими», система начинает новый раунд — запускает ожидающие процессоры ещё раз. И так происходит, пока все процессоры не завершатся успешно или в очередном раунде не появится новый код.

Из-за системы с несколькими раундами Dagger мог вызываться раньше Whetstone и пробовать строить графы. При этом он натыкался на несуществующие Whetstone-модули и завершал работу, отдавая управление Whetstone. После того как Whetstone отрабатывал, Dagger снова парсил графы и дублировал часть первоначальной работы. Из-за этого связка Dagger + Whetstone работала медленнее, чем могла бы, будь она монолитным процессором. К тому же интеграция Whetstone с Dagger в нужной степени, чтобы его было действительно безопасно использовать, была достаточно сложной задачей, решение которой не улучшало ситуацию со скоростью сборки.

Стоит отметить, что Whetstone приходилось генерировать много кода для всех вариантов опциональных биндингов. Также он замещал Inject-конструктор класса под условием на @Provides-метод, чтобы контролировать его создание и использование. Всё это привносило ещё больше мусора в общий объём байткода, который отрицательно влиял на скорость сборки и на производительность приложения в debug-конфигурации.

Кроме того, Whetstone был, если говорить в терминах инкрементальной обработки аннотаций Gradle, агрегирущим процессором аннотаций. Это значит, что ему нужно было собрать со всего проекта синтаксически независимые друг от друга конструкции, помеченные @BindIn, и обработать их в совокупности. При изменении или добавлении нового класса под @BindIn было необходимо повторить весь процесс целиком, что почти сводило на нет пользу от инкрементальной компиляции, так как таких классов было очень много. Если вы работали с Dagger Hilt, то он работает таким же образом по отношению к @InstallIn. Удобно, но медленно.

Чтобы написать опциональный биндинг в рамках Dagger (руками или автоматически, неважно), как мы ранее разбирали, необходимо было запросить зависимость от объекта Provider<T>, чтобы затем внутри кода биндинга уже принять решение, вызывать provider.get() или нет. Но во время моих исследований кода, который генерирует Dagger в разных случаях, я выяснил следующий факт. Если класс где-то запрашивается как Lazy/Provider, Dagger меняет стратегию кодогенерации для этого класса на менее оптимальную, чтобы уметь отдавать наружу объект провайдера. Получалось, что опциональные биндинги таким образом портили большое количество классов в графе, так как привносили в граф провайдерные использования. Так что это могло в теории немного ухудшать производительность сгенерированного кода.

Подытожим все проблемы, найденные в Dagger + Whetstone по скорости сборки:

  • Медленно работает на kapt.
  • Dagger медленно генерирует ненужные фабрики. В них больше классов, что заставляет включать Dagger в большем количестве модулей — так дольше генерировать стабы.
  • Whetstone замедляет обработку аннотаций, генерирует больше классов и заставляет Dagger генерировать субоптимальный код.

Yatagan aka Dagger Lite

И вот, когда я откопал все вышеупомянутые корни медленной сборки проекта, я решил предложить написать yet another DI-фреймворк, который бы представлял собой новый движок для Dagger-like API и нативно поддерживал основную функциональность Whetstone для работы с runtime-условиями. При разработке такого фреймворка можно было бы учесть все описанные проблемы классического Dagger и придумать для них решения. Также я решил попробовать нативно поддержать режим работы на Java-рефлексии, чтобы ещё больше ускорить сборку проекта.

Фреймворк получил в моей голове кодовое название Dagger Lite, и назывался так у нас внутри, но далее в статье я буду упоминать публичное название Yatagan, чтобы вас не путать.

Моя мотивация и предложения по решению проблем были следующими:

  • Yatagan не будет генерировать мусорные SomeClass_Factory и MyModule_Method_Factory, тем самым он решает пачку проблем, вызванных этим. Таким образом, фреймворк можно применять только к тем модулям, в которых есть корневые @Component-объявления, так как генерация кода происходит именно для них.
  • Yatagan будет нативно поддерживать runtime-условия с Whetstone-like API и уметь генерировать для них оптимальный код. Это решает проблемы с многораундовой обработкой, генерацией лишних Provider, code-bloat и прочих.
  • Yatagan будет поддерживать и классический kapt, и новый движок KSP, что должно, по заявлению Google, решать проблемы с производительностью kapt.

Также я предложил дополнительные пункты для оптимизации:

  • Yatagan будет нативно поддерживать reflection-only режим. Такой режим можно включать для локальной разработки, где не нужна пиковая производительность приложения, а нужна пиковая скорость пересборки после изменений. При включении такого режима Yatagan будет строить граф полностью в runtime, используя данные Java Reflection. Так мы полностью избавим сборку от шага обработки аннотаций, что значительно её ускорит. Для очень больших графов построение во время выполнения может замедлять старт приложения на несколько секунд, но обычно это не критично по сравнению с ускорением сборки. Улучшается общая метрика «от правки кода до работающего приложения», которая важна при разработке.
  • Yatagan может генерировать код как для однопоточного, так и для многопоточного использования (по требованию). Однопоточная реализация может иметь лучшую производительность из-за отсутствия синхронизации и вспомогательных объектов. Тип thread-safety может быть выбран при использовании для каждой иерархии графов отдельно.

Основной компромисс, на который придётся пойти, заключается в том, что будет реализовано только подмножество API Dagger:

  • Часть Dagger API не использует почти никто, например dagger.producers.*, так что и волноваться незачем.
  • Часть не получится реализовать вообще, например dagger.hilt.*, из-за технических особенностей Yatagan (далее поясню, почему).
  • Часть можно будет реализовать позже по запросу.

Давайте сразу разберём несколько вопросов, которые могут возникнуть по предложениям, сначала — о режиме reflection-only, ведь есть же готовый Dagger Reflect, который реализовывает Dagger на рефлексии. У себя мы не сможем его использовать, потому что в нём нет функциональности Whetstone. К тому же это отдельный проект, для которого нет жёсткой гарантии, что он будет вести себя так же, как и Dagger с кодогенерацией. А мы очень хотим иметь такую гарантию, чтобы убедить разработчиков, что их код будет работать одинаково с рефлексией и без неё.

Дальше рассмотрим вопрос о поддержке KSP. Dagger планирует поддержать KSP у себя. К тому же есть Anvil. Dagger так и не поддерживает KSP на момент написания (и я могу их понять после работы, которую проделал сам), а Anvil как инструмент ускорения Dagger будет давать меньше профита, чем Yatagan, если всё получится реализовать.

Почему Anvil будет давать меньше выигрыша в сборке, чем Yatagan

Anvil — это плагин к компилятору Kotlin, который генерирует эти самые ненужные фабрики, избавляя пользователя от необходимости включать даггер и генерировать стабы в лишних модулях. В Yatagan эти фабрики вообще не генерируются.

Об идейной поддержке Hilt

Dagger Hilt позиционируется как отдельный продукт, целевая аудитория которого, по моему мнению, — небольшие новые проекты мобильных приложений на Android. Hilt разработали специально, чтобы нивелировать высокую стоимость начальной настройки DI для Android-приложений, которая была присуща классическому Dagger. Yatagan же в данный момент целится на решение проблем в больших проектах, где уже используется Dagger. В большинстве небольших приложений проблемы со скоростью сборки не будут стоять так остро. Также в таких приложениях не будет надобности коренным образом решать проблему с условными биндингами.

Готов ли Yatagan в будущем предложить что-то и для таких проектов? Вполне возможно, но пока это только планы.

Самым важным поставленным требованием, пожалуй, было то, что новый фреймворк должен быть совместим с Dagger по API или хотя бы не требовать нетривиальную миграцию c Dagger. API Yatagan базируется на API Dagger 2 и в некоторых местах полностью его повторяет. Также этот API поглотил Whetstone с некоторыми изменениями — @BindIn был заменён на метку @Conditional. Поведение Yatagan в некоторых местах немного отличается от того, что делает Dagger, но все такие места я постарался задокументировать. Для поддержки динамических условий фреймворк использует систему @Condition/@Conditional, а для поддержки статических условий вместо @BindsOptionalOf в нём используется система вариантов, которая чем-то напоминает flavors/variants при сборке Android-приложений. Рассмотрение этих систем в подробностях выходит за рамки статьи, но вы можете найти это в документации к API Yatagan.

Кроме того, для наших проектов критически важна скорость работы приложений, поэтому я много раз проводил замеры производительности и могу уверенно сказать, что код, который генерирует Yatagan, как минимум не уступает коду Dagger по производительности, а в каких-то случаях и выигрывает. Стратегия генерации кода в Yatagan похожа на ту, что использует Dagger в режиме fastInit.

Но при этом важно отметить, что Yatagan изначально не планировался как полностью универсальная замена для Dagger, потому что он не поддерживает некоторые дополнительные возможности Dagger, хотя вся ключевая функциональность в нём реализована.

Архитектура и реализация Yatagan

Чтобы реализовать все заявленные пункты, в особенности поддержку сразу трёх бэкендов (kapt, ksp, reflection), нужна была соответствующая архитектура проекта. Нам было важно, чтобы Yatagan вёл себя одинаково вне зависимости от выбранного бэкенда. Давайте попробуем разобраться, как такое сделать.

Итоговая архитектура Yatagan базируется на нескольких основных слоях абстракции:

  1. :lang — абстракция языковой модели. Она моделирует типы, классы, методы и прочие конструкции ЯП. Языковые элементы в основном имеют семантику языка Java, не Kotlin, так как Dagger работает с типами с точки зрения системы типов Java, и я посчитал нужным не отходить от этого в Yatagan. Когда-то в lang была начальная поддержка некоторых сущностей из Kotlin, например свойств (properties), но потом эту поддержку удалили из-за производительности. В API содержится только то, что необходимо следующим слоям абстракции для работы, а также минимальный набор API, который может понадобиться разработчикам плагинов (да, к Yatagan можно написать плагин, смотрите документацию). У lang есть три основных реализации:
    • lang:jap — реализация для apt/kapt, использующая javax.lang.model.**
    • lang:ksp — реализация для KSP, использующая com.google.devtools.ksp.**
    • lang:rt — реализация на рефлексии, использующая java.lang.Class и java.lang.reflect.*
  2. :core:model — абстракция элементарных сущностей в Yatagan: компоненты, модули, узлы графа (node). Эти модели строятся на основании языковых элементов из lang.
  3. :core:graph — тут происходит полное построение графа биндингов (bindings) на основании элементарных моделей из :core:model. С построенным графом уже можно производить любые операции: проверить на ошибки, отправить в генерацию или сконструировать рефлексийную реализацию через механизм java.lang.reflect.Proxy.

Принципиальная схема структуры Yatagan (которой примерно соответствует разделение проекта на модули, если опустить детали реализации):

Яндекс выпускает Yatagan — опенсорс-фреймворк для внедрения зависимостей, позволяющий ускорить сборку - 4

Таким образом, Yatagan обеспечивает гарантию, что все бэкенды будут работать идентично, поскольку:

  • Построение моделей и финального графа привязок происходит в общем коде.
  • Все бэкенд-специфичные вещи изолированы внутри lang-реализации и публичного бэкенд-специфичного артефакта.
  • Чтобы проверять, что бэкенд-специфичный код работает одинаково, в Yatagan есть универсальные интеграционные тесты, которые параметризованы бэкендом. Каждый тест запускается для каждого бэкенда и проверяет, что поведение корректное. Код тестов никак не связан с реализацией и будет актуален, даже если полностью переделать всю реализацию того или иного компонента проекта.

Специфика реализации Reflection

Поддержка построения графов во время выполнения сразу наложила два ограничения на API Yatagan:

  1. Компоненты (@Component) и их фабрики (@Component.Builder) могут быть только интерфейсами, чтобы можно было использовать java.lang.reflect.Proxy для построения реализации компонента/фабрики «на лету».
  2. Компоненты создаются не через прямое обращение к сгенерированному классу, как в Dagger (например, для компонента MyComponent нужно было бы писать DaggerMyComponent.builder().build()), а через специальную точку входа — объект Yatagan, у которого есть методы для создания реализации компонентов — create() и builder(). Применяется это так: Yatagan.builder(MyComponent.Builder.class).build() или Yatagan.create(MyComponent.class) — если у компонента нет фабрики.

Точно такой же подход применяется в Dagger Reflect, и у него присутствует такое же требование интерфейсов и использование специальной точки входа. У Yatagan этот подход унифицирован — класс-точку входа нужно использовать в любом случае, так как сгенерированные имена компонентов задекорированы (mangled), а kapt/ksp-реализация выдаст ошибку, если вместо интерфейса для компонента использовать абстрактный класс.

Почему Yatagan не может даже в теории поддержать Hilt

Реальная причина одна: Hilt — агрегирующий процессор. Если движки обработки аннотаций и предоставляют такой режим, который впрочем ухудшает инкрементальность сборки, как мы разбирали на примере Whetstone, то Java Reflection не умеет в агрегацию. Другими словами, невозможно попросить Java вернуть все классы, помеченные определённой аннотацией, которые он найдёт в runtime classpath. Если очень постараться, то технически можно разработать решение, которое будет уметь отвечать на такие вопросы, но оно будет работать непозволительно медленно и столкнётся с другими проблемами. Ещё можно пытаться выполнять агрегацию в compile-time и читать сгенерированные списки классов в runtime, но это сведёт на нет всю суть использования рефлексии в данном случае. В общем, никакой агрегирующей обработки через рефлексию делать нельзя, а значит нельзя ни в одном из бэкендов, чтобы не сломать совместимость.

Reflection и Android

Для Android-приложений Yatagan в режиме рефлексии можно спокойно применять, начиная с версии платформы 24, в которой появилась поддержка статических методов в интерфейсах без дешугаринга (desugaring). Так что, включая рефлексию в Yatagan, нужно удостовериться, что приложение собирается с minSdk = 24, либо вы гарантированно не используете нигде статические методы в модулях-интерфейсах. Кстати, выставлять такой minSdk в любом случае рекомендуется для отладочных сборок — об этом можно почитать в разделе «Использование minSdk 24+» в хабрастатье моего коллеги.

Что случилось с поддержкой конструкций из Kotlin в :lang?

Ещё интересный момент связан с поддержкой Kotlin-специфичных языковых конструкций в языковой модели. Для всех трёх бэкендов существует способ получения информации о языковых конструкциях Kotlin. Для :lang:jap это библиотека kotlinx-metadata, которая умеет расшифровывать данные из аннотаций @kotlin.Metadata. Для KSP ничего особого делать не нужно — этот фреймворк и так моделирует язык с точки зрения Kotlin. Для рефлексии же, на первый взгляд, тоже всё просто — можно использовать kotlin-reflect.jar, тогда в KClass будет доступна полноценная информация о сущностях Kotlin.

Но, как оказалось, kotlin-reflect не только весит примерно 1.6 МБ, что не так страшно для режима, который позиционируется как debug-only, но и ухудшает старт приложения на 3-5 лишних секунд. С учётом того, что парсинг графа на старте приложения из рефлексии и так занимает несколько секунд (для огромных графов, как у нас приложении), ухудшение времени старта приложения становилось непозволительно большим. Такая долгая инициализация была обусловлена тем, что библиотека синхронно вычитывала информацию из файлов kotlin_module, даже когда к ней ещё никто не обращался. И не обратился бы, ведь она не нужна для простого вычитывания свойств внутри класса. Немного расстроившись, я подумал и решил попробовать применить библиотеку kotlinx-metadata прямо в runtime, вместо kotlin-reflect. Почему бы и нет? Аннотации @kotlin.Metadata как раз доступны в runtime. Это сработало и улучшило ситуацию, но, к сожалению, не намного — kotlinx-metadata использует внутри обычный Service Provider Interface, чтобы находить и загружать расширения метаданных. А он так же работает очень и очень долго на большом classpath внутри Android-приложения. Вероятно, эту проблему можно было бы решить, подправив код внутри библиотеки и опубликовав её как kotlinx-metadata-jvm-only с hardcoded расширением для jvm, но игра не стоила свеч, и я отказался от моделирования сущностей из Kotlin.

Примечание: companion object и object всё еще распознаются, только для этого используются эвристики, а не реальные Kotlin-метаданные.

Проверка графов в runtime

Отнюдь не лишним будет задаться вопросом, а каким же образом происходит репортинг ошибок для графов в режиме reflection. Как я уже говорил, Yatagan спроектирован таким образом, что поведение фреймворка должно быть униформным для каждого бэкенда. У рефлексийного бэкенда конкретно в этом вопросе всё же есть специфика.

Поговорим о поведении, которое включено по умолчанию. Если граф валиден, то и поведение, соответственно, определённое. Если же граф содержит ошибку, например отсутствующий биндинг, то поведение бэкенда не определено. В реальности для критических ошибок в графе может быть выброшено исключение с информацией о характере ошибки, но может получиться так, что компонент отработает без ошибок, а результат окажется неверным — классический undefined behavior. То есть, если граф содержит ошибку то контракт рефлексийного бэкенда по умолчанию считает, что граф «ill-formed, no diagnostic required». Простите за эту проклятую фразу из стандарта C++.

Такое решение было принято, чтобы не ухудшать производительность, если граф не содержит ошибок. Режим рефлексии позиционируется исключительно как отладочный и подходящий только для локальных сборок для разработки. Полную валидацию должны производить кодогенераторы на серверах CI.

Но можно включить и полную валидацию! Мы ввели специальный API, который позволяет клиенту предоставить реализацию специального делегата, который умеет запускать валидацию графа и печатать полученные из неё ошибки. При создании корневого компонента в делегат будет сразу же отправлена задача на валидацию всей иерархии. Реализация может выполнить валидацию или сразу же, или отправить её в асинхронный executor, или даже отложить на некоторое время. Если же во время работы компонента возникнет ошибка, то движок сделает соответствующей задаче валидации вызов await(), чтобы она точно завершилась и смогла напечатать все реальные ошибки, и только потом выбросит исключение.

Так, полная валидация в режиме рефлексии может быть включена, при этом она не будет просаживать время построения валидного графа. Если же ошибка действительно возникает, тогда скорость работы нас уже не сильно интересует, так как приложение в любом случае будет аварийно завершено. Но зато все ошибки будут напечатаны в консоль или, например, в logcat в том же виде, как и для кодогенераторов, поэтому идентичность поведения бэкендов сохраняется и здесь.

Специфика реализации KSP

Реализация Yatagan на движке KSP на данный момент предоставляется в экспериментальном режиме. Давайте разберёмся, почему она не может быть полностью стабильной.

Когда Yatagan изначально проектировался и формировались требования на основе нужд именно нашего проекта, то я принял решение, что все бэкенды, включая KSP, должны вести себя одинаково. Это позволит переключать их без миграции и без изменения поведения.

Kotlin и Java — разные языки, в том числе в контексте системы типов. Для такого фреймворка, как Dagger, очень важен аспект системы типов — эквивалентность. У Kotlin и Java в этом вопросе разная семантика. Допустим, java.lang.Integer не эквивалентен int, но в Kotlin на уровне языка эти типы не различаются. В Kotlin есть List и MutableList, в то время как в Java это один и тот же тип. Так же в Kotlin есть информация о nullability, а в Java её нет. Отметим, что ванильный Dagger всегда рассуждает в контексте системы типов Java, так как он работает на javax.lang.model.types.*, которые моделируют типы Java, а Kotlin он понимает постольку, поскольку его переводит в заглушки на Java трансформация kapt.

KSP же, хоть в нём и заявлена поддержка Java, моделирует языковые сущности с точки зрения Kotlin. Java-конструкции тоже моделируются так, как их видит Kotlin. Но нам для обеспечения совместимости с другими бэкендами и ванильным Dagger и просто для генерации кода на Java было необходимо трансформировать типы в Java-эквиваленты. Оказалось, что в KSP это сделать весьма нетривиально: местами нужной информации не было доступно вообще, а где-то приходилось прибегать к сложным махинациям с параллельным сбором информации из разных мест, чтобы в итоге получился тип, ведущий себя как Java. После всех этих манипуляций можно найти кейсы, где KSP может вести себя иначе, нежели kapt или RT.

Получается, если нужно работать с кодом с точки зрения Java или хотя бы даже генерировать Java-код на выходе, то KSP — весьма плохой вариант для фреймворка, особенно для интенсивно читающего код и чувствительного к системе типов. Почему так получилось? Наверное, потому, что KSP задумывался как Kotlin-first, и поддержка Java там не получила должного внимания. Почти весь Java-специфичный API помечен как @KspExperimental, что говорит о том, что поддержка Java в принципе экспериментальна и нестабильна. Так что я понимаю, через что проходят разработчики Dagger в рамках поддержки KSP у себя, но у них ситуация, вероятно, ещё хуже — им нужно поддержать всё то, что уже написано, и в точности так, как это работало. При всех вводных Yatagan — новый фреймворк, который не заявляет о полной совместимости с Dagger, так что мне было немного проще.

С другой стороны, KSP работает достаточно хорошо для Kotlin-only проектов. Так что, если в вашем проекте содержится значительная часть кода на Java, нужно отнестись к KSP с осторожностью. Если же ваш проект на Kotlin — можете смело пробовать использовать KSP.

В планах развития Yatagan есть поддержка Kotlin Multiplatform, для которого необходимо будет генерировать код компонентов на Kotlin. Вероятно, для этого будет введён какой-то отдельный «pure-kotlin» бэкенд, несовместимый с остальными, который не будет приводить все типы к Java, упрощая свою реализацию.

Различие в API Yatagan и Dagger 2

Полная табличка соответствия API есть в документации на Гитхабе.

Yatagan API местами полностью повторяет API Dagger 2, но местами у них есть небольшие различия.

Поддерживается API из пакетов:

  • dagger.*
  • dagger.multibindings.*
  • dagger.assisted.*

Остальное (dagger.android, dagger.producers,…) не поддерживается. Отдельно стоит сказать о dagger.spi. У Yatagan есть поддержка плагинов для валидации графов, но плагинам предлагается использовать внутренний общий API моделей Yatagan, а не выделенный, как у Dagger. Подробности и полную таблицу соответствия API Yatagan и Dagger смотрите в документации.

Результаты у нас

В нашем проекте Yatagan ускорил инкрементальную сборку проекта на 50-70% в различных сценариях при использовании kapt (Yatagan kapt vs Dagger kapt). KSP у нас не давал больших профитов по сравнению с kapt (Yatagan KSP vs Yatagan kapt, когда я делал замеры). Использование рефлексии даёт сверх вышеупомянутых профитов ещё 16-25%, так как полностью выключает обработку аннотаций в больших модулях проекта.

Также код, который генерирует Yatagan, ускорил старт приложения в различных сценариях на несколько процентов на момент конца миграции. Сейчас сложно подсчитать итоговый результат, поскольку оптимизации генерации кода в Yatagan писали постепенно и профит размазался по времени, но можно говорить о порядках улучшений на 5-10%, что для нас очень неплохой результат, учитывая специфику.

В другом внутреннем Android-проекте, где не было Whetstone, и который переезжал на Yatagan с чистого Dagger, профиты от использования RT по сравнению с Dagger на kapt были порядка 40%.

Выводы из этого можно сделать следующие. Профит будет напрямую зависеть от того, как вы используете Dagger у себя в проекте и в какой конфигурации вы будете использовать Yatagan.

Миграция с Dagger на Yatagan сводится к механической работе конвертации API, которая в основном состоит из замены имён пакетов в импортах. Местами нужно поправить имена аннотаций. Теоретически проект может заработать уже на этом этапе. На практике же могут понадобиться дополнительные правки для учёта некоторых различий Yatagan и Dagger. Полный список известных различий есть в документации.

Кому Yatagan может быть полезен

Если у вас небольшой проект JVM/Android, вас полностью устраивает скорость сборки с Dagger, и у вас нет необходимости использовать условные биндинги внутри DI, то, скорее всего, вы не будете использовать Yatagan.

Yatagan даст максимальный профит по скорости отладочной сборки, если выполняются все нижеперечисленные пункты:

  1. Есть значительное количество gradle-модулей в проекте, где используется только один AP — Dagger.
  2. Если в модулях с Dagger, где есть корневые компоненты (@Component), применяются другие AP, то они должны уметь работать в режиме рефлексии или хотя бы KSP.
  3. Разработчики готовы использовать Yatagan в режиме рефлексии для отладочных сборок.

При таком раскладе в большом количестве модулей можно будет полностью выключить kapt, что даёт огромный буст сборке.

Yatagan даст меньшее, но всё ещё ощутимое ускорение, если не выполняется третий пункт, и команда будет использовать Yatagan в режиме kapt или KSP. Тогда в модулях, где нет деклараций корневых компонентов (@Component), всё ещё можно будет уйти от kapt, так как в них не нужно применять Yatagan. KSP (vs kapt) может дать тут больше профита в особых случаях.

Если другие AP из второго пункта поддерживают режим KSP, то стоит применять его. Некоторые фреймворки могут работать без кодогенерации, в режиме рефлексии — тут стоит рассмотреть использование такого подхода для отладочных сборок. Если вместе с Dagger используются kapt-only AP, то их нужно постараться вынести в отдельные небольшие модули.

Если первые два пункта совсем не выполняются, то есть в большинстве модулей проекта включен Dagger и минимум ещё один другой kapt-only AP, то профит от Yatagan будет минимальный. Таким образом, ни в одном модуле не получится уйти от kapt, и основной профит будет идти только от отсутствия надобности компилировать сгенерированные фабрики. Ускорение сборки в таком случае будет всего порядка нескольких секунд.

Как стоит применять Yatagan

Рассмотрим применение Yatagan в контексте Android-проектов. Для pure-JVM-проектов рассуждения будут аналогичными.

debugImplementation("com.yandex.yatagan:api-dynamic:1.0.0")
releaseImplementation("com.yandex.yatagan:api-compiled:1.0.0")
kaptRelease("com.yandex.yatagan:processor-jap:1.0.0")

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

Для сборки релизов рефлексию использовать не стоит, даже если почему-то захочется. На данный момент она не дружит с минификаторами кода (proguard/r8).

Если рефлексия не подходит, нужно использовать kapt или KSP. Чтобы решить, что именно использовать, стоит учесть пару моментов:

  1. KSP имеет только экспериментальную поддержку Java, даже если Google не пишет об этом. Она часто ломается, и даже была сломана на момент начала написания этой статьи. Так что её рискованно использовать в проектах, где присутствует значительная часть кода на Java.
  2. KSP можно пробовать использовать в pure-Kotlin проектах, но его 100% потенциал возможно не будет раскрыт, так как код компонентов всё равно пока что генерируется на Java, что запустит дополнительный шаг компиляции Java и слегка замедлит сборку.
  3. Если в вашем модуле/проекте вообще нет Kotlin, то используйте kapt, который будет там прекрасно работать.

Заключение

Мы будем развивать проект. Если поймём, что Yatagan оказался интересен кому-то кроме нас, то планируем, например, реализовать поддержку Kotlin Multiplatform с выделенным KSP-only режимом. Если вы хотите предложить сделать что-то полезное и интересное в Yatagan, смело заводите нам issue на Гитхабе. Нам ценен любой фидбэк.

Автор: Федор Игнаткевич

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js